From 9726e691f527340fe0a8c9c50c30c766f18a2596 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 12:17:42 -0800 Subject: [PATCH 1/8] .ai import: names that aren't UTF-8 can be read in a legacy code page A legacy .ai stores layer, object, color, gradient, pattern, style and swatch names as bytes, without saying which code page they are in. Names that aren't UTF-8 are still read as Windows-1252 by default, and now each gives a warning naming it and the encoding it was read in (the first three, then a count). document.open takes legacyNameEncoding (windows-1252, macintosh, windows-949 or euc-kr, shift_jis, gbk, big5) and vectorcraft-cli convert takes --legacy-name-encoding, to read those names in another code page. Definitions and the fills that name them are read alike, so they still match. Type, comments, dictionary keys and PDF layer names are read as before. encoding_rs becomes a direct dependency of vectorcraft-eps. Part of #2831 --- Cargo.lock | 1 + Cargo.toml | 3 + apps/vectorcraft-cli/src/main.rs | 38 ++- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 7 +- crates/engine/src/cmd/fileio/pdfimport.rs | 18 +- .../engine/src/cmd/fileio/tests_epsimport.rs | 35 ++ crates/engine/tests/import_fuzz.rs | 37 +++ crates/eps/Cargo.toml | 2 + crates/eps/src/import.rs | 15 +- crates/eps/src/import/ai.rs | 5 +- crates/eps/src/import/ai/obj.rs | 298 ++++++++++++++++++ crates/eps/src/import/ai/paint.rs | 91 ++++-- crates/eps/src/import/ai/palette.rs | 40 ++- crates/eps/src/import/ai/read.rs | 18 +- crates/eps/src/import/ai/read/directives.rs | 10 +- crates/eps/src/import/ai/read/document.rs | 42 ++- crates/eps/src/import/ai/read/ops.rs | 14 +- crates/eps/src/import/ai/style.rs | 25 +- crates/eps/src/import/ai/style/tests.rs | 4 + crates/eps/src/import/ai/tests.rs | 118 ++++++- crates/eps/src/import/native.rs | 2 +- crates/eps/src/import/native/build.rs | 5 +- crates/eps/src/import/native/layers.rs | 82 +++-- crates/eps/src/import/tests_native.rs | 19 ++ crates/eps/src/lib.rs | 5 +- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tests.rs | 22 ++ crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + docs/file-format-parity.md | 2 +- 54 files changed, 918 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 692dd5a91..7611568dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6257,6 +6257,7 @@ dependencies = [ name = "vectorcraft-eps" version = "0.20.0" dependencies = [ + "encoding_rs", "fax", "flate2", "hayro-ccitt", diff --git a/Cargo.toml b/Cargo.toml index 3088b469b..5b274663e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,9 @@ ruzstd = { version = "0.9.1", default-features = false, features = ["std"] } hayro-ccitt = "0.3" # Substring search for the EPS reader's PostScript `search`. memchr = "2" +# The WHATWG Encoding Standard's legacy encodings: names in legacy .ai files read in the code page +# the user picks (Mac OS Roman, Korean, Japanese, Chinese). +encoding_rs = "0.8.42" jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] } gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] } # CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index b7334586c..fa7b4329d 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -4,7 +4,7 @@ //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... //! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] //! vectorcraft-cli commands -//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] +//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--legacy-name-encoding ENC] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] //! vectorcraft-cli info FILE //! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! vectorcraft-cli perf [--paths 50000] [--deep] @@ -94,7 +94,7 @@ USAGE: vectorcraft-cli commands Print the command catalogue as JSON. - vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] + vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--legacy-name-encoding ENC] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] Open IN (any readable format) and export OUT in the format its extension picks (see Writable formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard @@ -107,7 +107,9 @@ USAGE: IN left things out (hidden text, art or layers it could not read; the notes say which) unless --replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy even where that draws differently from its PDF part or page (which can be older), as long as - it can be read. A PDF OUT omits the embedded native document unless --preserve-editing is + it can be read. --legacy-name-encoding reads the names in a .ai's or an EPS's editing data + that aren't UTF-8 (layers, objects, colors, swatches) in windows-1252 (the default), + macintosh, windows-949, shift_jis, gbk or big5. A PDF OUT omits the embedded native document unless --preserve-editing is given (the app's Save As PDF still defaults to keeping it). With --preserve-editing, when the attachment is written the result's warnings say so (it is left out, with its own warning, for --artboard or a --range without every artboard). @@ -279,7 +281,19 @@ fn commands() -> Result<(), String> { } fn convert(args: &[String]) -> Result<(), String> { - let valued = ["--scale", "-s", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"]; + let valued = [ + "--scale", + "-s", + "--artboard", + "-a", + "--range", + "-r", + "--decimals", + "--ai-version", + "--hidden-objects", + "--transparent-images", + "--legacy-name-encoding", + ]; if help_requested(args, &valued) { return help(); } @@ -287,6 +301,9 @@ fn convert(args: &[String]) -> Result<(), String> { let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data, mut preserve_editing) = (1.0f64, None::, None::, false, false, false, false); let mut decimals = None::; + // The encoding of a legacy `.ai`'s names that aren't UTF-8, passed on as `document.open`'s + // `legacyNameEncoding`; the engine checks it. + let mut legacy_names = None::; // Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`, // `transparentImages`); the engine checks their values. let mut legacy: Vec<(&str, String)> = vec![]; @@ -301,6 +318,9 @@ fn convert(args: &[String]) -> Result<(), String> { "--replace-lossy" => replace_lossy = true, "--editing-data" => editing_data = true, "--preserve-editing" => preserve_editing = true, + "--legacy-name-encoding" => { + legacy_names = Some(it.next().cloned().ok_or("--legacy-name-encoding needs an encoding such as windows-949")?); + } "--ai-version" => legacy.push(("aiVersion", it.next().cloned().ok_or("--ai-version needs 7 or pdf")?)), "--hidden-objects" => legacy.push(("hiddenObjects", it.next().cloned().ok_or("--hidden-objects needs layer, omit or visible")?)), "--transparent-images" => legacy.push(("transparentImages", it.next().cloned().ok_or("--transparent-images needs flatten or omit")?)), @@ -313,8 +333,14 @@ fn convert(args: &[String]) -> Result<(), String> { return Err("a .ait template holds every artboard: --artboard and --range don't apply".into()); } let mut h = Headless::new(); - let opened = if editing_data { - let params = json!({"path": input, "editingData": "always"}); + let opened = if editing_data || legacy_names.is_some() { + let mut params = json!({"path": input}); + if editing_data { + params["editingData"] = json!("always"); + } + if let Some(e) = &legacy_names { + params["legacyNameEncoding"] = json!(e); + } h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))? } else { h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))? diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index f80302eee..5277e3841 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles and swatches; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 008313069..45209ad34 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -169,7 +169,8 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning } let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings, false) }; let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines; - let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data, unit); + let (doc, mut warnings, read) = + vectorcraft_eps::layered_ai_with(&private, doc, warnings, outlined, opts.trust_editing_data, unit, opts.legacy_names); // The layers weren't read (or couldn't be): the notes of reading the PDF part still hold. if !read { return (doc, warnings, false); @@ -195,7 +196,7 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option Result _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); restore_or_import(editing, || { - let r = vectorcraft_eps::import_trusting(bytes, opts.editing_data, opts.trust_editing_data) + let r = vectorcraft_eps::import_names(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names) .map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; Ok((r.document, r.warnings)) })? diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 80575c23f..3b120e03c 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -39,6 +39,9 @@ pub struct LoadOptions { /// A `.ai` opens from its editing copy even where that draws differently from its PDF part /// (which can be older than it), with a warning, instead of from the PDF part. pub trust_editing_data: bool, + /// The encoding names in a `.ai` or EPS's editing data are read in when they aren't UTF-8 + /// (`legacyNameEncoding`). + pub legacy_names: vectorcraft_eps::NameEncoding, } impl Default for LoadOptions { @@ -54,6 +57,7 @@ impl Default for LoadOptions { dxf: Default::default(), editing_data: true, trust_editing_data: false, + legacy_names: vectorcraft_eps::NameEncoding::default(), } } } @@ -61,8 +65,9 @@ impl Default for LoadOptions { impl LoadOptions { /// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when /// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`, - /// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`) and `dxf` - /// (the DXF import options). + /// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`), + /// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF + /// import options). pub fn from_params(cmd: &str, p: &Value) -> Result { let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) { None => None, @@ -90,8 +95,15 @@ impl LoadOptions { Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true), Some(v) => (v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true, false or \"always\""))?, false), }; + let legacy_names = match p.get("legacyNameEncoding").filter(|v| !v.is_null()) { + None => vectorcraft_eps::NameEncoding::default(), + Some(v) => v + .as_str() + .and_then(vectorcraft_eps::NameEncoding::from_label) + .ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?, + }; let dxf = super::dxfimport::options(cmd, p.get("dxf"))?; - Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data }) + Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data, legacy_names }) } /// Does the document read only part of the file, or read it differently (a page range, another diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 8c4141902..59caf85d6 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -184,6 +184,41 @@ showpage assert!(e.contains("junk.eps"), "{e}"); } +/// `legacyNameEncoding` picks the code page of a legacy `.ai`'s layer names that aren't UTF-8; by +/// default they come in as Windows-1252, with a warning naming them. An encoding it doesn't know is +/// refused. +#[test] +fn legacy_ai_layer_names_open_in_the_encoding_picked() { + let mut s = Session::new(); + // A layer named 레이어 1 in Windows code page 949. + let ai = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%DocumentNeededResources: procset Adobe_IllustratorA_AI3 1.0 0\n%AI3_TemplateBox: 50 50 50 50\n%%EndComments\n\ + %%BeginProlog\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\ + %AI5_BeginLayer\n1 1 1 1 0 0 2 0 0 0 Lb\n(\\267\\271\\300\\314\\276\\356 1) Ln\n0 0 0 1 K\n0 i 1 w\n10 10 m\n90 90 L\nS\nLB\n%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n"; + let open_as = |s: &mut Session, encoding: Option<&str>| { + let mut p = json!({"name": "korean.ai", "dataBase64": vectorcraft_format::base64_encode(ai)}); + if let Some(e) = encoding { + p["legacyNameEncoding"] = json!(e); + } + s.execute("document.open", &p) + }; + let r = open_as(&mut s, None).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].name.as_deref(), Some("·¹À̾î 1")); + assert_eq!( + r["warnings"], + json!(["the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding"]) + ); + for encoding in ["windows-949", "EUC-KR"] { + let r = open_as(&mut s, Some(encoding)).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers[0].name.as_deref(), Some("레이어 1"), "{encoding}"); + assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("`레이어 1`") && w.contains("windows-949")), "{r}"); + } + let e = open_as(&mut s, Some("utf-16")).unwrap_err(); + assert!( + matches!(&e, EngineError::BadParams { msg, .. } if msg == "legacyNameEncoding must be one of windows-1252, macintosh, windows-949, shift_jis, gbk, big5"), + "{e}" + ); +} + #[test] fn eps_files_place_as_one_group_clipped_to_their_bounding_box() { let mut s = rich(); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 19c6ef93a..49fc32fb2 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2911,6 +2911,23 @@ fn the_shown_stories_fixture_opens_with_its_layers() { assert!(texts.iter().all(|t| t.contains('\u{2028}') && t.contains('\n')), "{texts:?}"); } +/// The fuzzed editing data with its layer, pattern and global colour named in code page 949 opens +/// with those names, read in that encoding, from its layers. +#[test] +fn the_editing_fixture_reads_names_in_a_legacy_encoding() { + // 레이어 1, then 빨강. + let text = editing_text() + .replace("(One) Ln", r"(\267\271\300\314\276\356 1) Ln") + .replace("(Dots)", r"(\273\241\260\255)") + .replace("(Black global)", r"(\273\241\260\255 global)"); + let bytes = editing_eps(&text); + let r = vectorcraft_eps::import_names(&bytes, true, true, vectorcraft_eps::NameEncoding::Korean).unwrap(); + let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["레이어 1", "Two"], "{:?}", r.warnings); + assert!(r.document.pattern("빨강").is_some(), "{:?}", r.warnings); + assert!(r.document.swatch("빨강 global").is_some_and(|s| s.global), "{:?}", r.warnings); +} + proptest! { #![proptest_config(config())] @@ -2954,6 +2971,26 @@ proptest! { survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?; } + /// Names of any bytes (a layer's, a pattern's, a global colour's) read in any legacy name + /// encoding: each byte written as an octal escape, so every code page sees bytes it doesn't + /// define, cut sequences and UTF-8 alike. + #[test] + fn eps_editing_data_names_in_any_encoding_never_panic( + name in prop::collection::vec(any::(), 0..24), + encoding in prop::sample::select(vectorcraft_eps::NameEncoding::LABELS.to_vec()), + ) { + let escaped: String = name.iter().map(|b| format!("\\{b:03o}")).collect(); + let text = editing_text() + .replace("(One) Ln", &format!("({escaped}) Ln")) + .replace("(Dots)", &format!("({escaped})")) + .replace("(Black global)", &format!("({escaped} global)")); + let bytes = editing_eps(&text); + let encoding = vectorcraft_eps::NameEncoding::from_label(encoding).unwrap(); + survive("EPS editing data names in a legacy encoding", || { + vectorcraft_eps::import_names(&bytes, true, false, encoding).ok().map(|r| r.document) + })?; + } + /// A `.ai` whose envelopes are damaged or hostile: any kind, warp style, options word, /// Fidelity and grid size, corners out of range or repeated, the contents block cut short. #[test] diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index 6743c2a2a..bbd42fc0f 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -31,6 +31,8 @@ hayro-ccitt = { workspace = true } serde_json = { workspace = true } # Substring search for PostScript `search`. memchr = { workspace = true } +# Names in legacy .ai files that aren't UTF-8, read in the encoding the user picks. +encoding_rs = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 5a7a9b491..b7ca1a7a5 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -69,7 +69,8 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub use native::{ai_alone, is_loss, layered_ai}; +pub use ai::NameEncoding; +pub use native::{ai_alone, ai_alone_with, is_loss, layered_ai, layered_ai_with}; pub use text::family_style; /// The page of a PostScript file without a bounding box (US Letter). @@ -239,6 +240,12 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result /// [`import_with`]; `trust`: an Illustrator EPS's layers are used even where they draw differently /// from its page, with a note (`editingData: "always"`, as for a `.ai`). pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result { + import_names(bytes, editing_data, trust, NameEncoding::default()) +} + +/// [`import_trusting`], with the names in the editing copy that aren't UTF-8 (layers, objects, +/// colors, gradients, patterns, graphic styles, swatches) read in `names`. +pub fn import_names(bytes: &[u8], editing_data: bool, trust: bool, names: NameEncoding) -> Result { let ps = program(bytes)?; let dsc = Dsc::read(ps); // A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no @@ -246,7 +253,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< // printed). if editing_data && dsc.own_legacy - && let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator) + && let Some(Ok((document, warnings))) = native::program_alone(ps, dsc.illustrator, names) { return Ok(Imported { document, warnings, preview: false }); } @@ -266,7 +273,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust)), + (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust, names)), (Ok(()), true) => return Ok(finish(out)), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), @@ -274,7 +281,7 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< // A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the // prolog defining its operators without including it): its layers, which the program has, or // its art on one layer when it has none (Illustrator 3). - let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator)).flatten() { + let why = match editing_data.then(|| native::program_alone(ps, dsc.illustrator, names)).flatten() { Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }), Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"), None => why, diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index c32bf2217..f7fa9ec71 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -35,7 +35,10 @@ use crate::ps; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; pub(crate) use frames::text_frames; -pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read, variables}; +pub use obj::NameEncoding; +#[cfg(test)] +pub use read::read; +pub use read::{IMAGE_TOO_LARGE, PaletteSwatches, Structure, artboards, palette, read_with, variables}; pub(crate) use read::{join_and, slot_frame, slot_of}; #[cfg(test)] diff --git a/crates/eps/src/import/ai/obj.rs b/crates/eps/src/import/ai/obj.rs index 10027f010..98be86a65 100644 --- a/crates/eps/src/import/ai/obj.rs +++ b/crates/eps/src/import/ai/obj.rs @@ -47,6 +47,201 @@ pub(super) fn text(bytes: &[u8]) -> String { } } +/// The encoding names in the editing data that aren't UTF-8 are read in (layers, objects, +/// artboards, colors, gradients, patterns, graphic styles, swatches). Legacy files store them in +/// the code page of the system that wrote them and don't say which. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum NameEncoding { + /// Windows-1252, Latin-1 for its gaps. + #[default] + Windows1252, + /// Mac OS Roman. + Macintosh, + /// Korean: Windows code page 949 (EUC-KR and Unified Hangul Code). + Korean, + /// Japanese: Shift_JIS (Windows code page 932). + ShiftJis, + /// Simplified Chinese: GBK (Windows code page 936). + Gbk, + /// Traditional Chinese: Big5 (Windows code page 950). + Big5, +} + +impl NameEncoding { + /// The labels [`NameEncoding::from_label`] reads, one for each encoding. + pub const LABELS: [&str; 6] = ["windows-1252", "macintosh", "windows-949", "shift_jis", "gbk", "big5"]; + + /// The encoding with label `label` (any case): one of [`Self::LABELS`], or `euc-kr` for + /// [`NameEncoding::Korean`]. + pub fn from_label(label: &str) -> Option { + let label = label.trim().to_ascii_lowercase(); + Some(match label.as_str() { + "windows-1252" => Self::Windows1252, + "macintosh" => Self::Macintosh, + "windows-949" | "euc-kr" => Self::Korean, + "shift_jis" => Self::ShiftJis, + "gbk" => Self::Gbk, + "big5" => Self::Big5, + _ => return None, + }) + } + + /// The encoding's label, as [`Self::LABELS`] gives it. + pub fn label(self) -> &'static str { + match self { + Self::Windows1252 => "windows-1252", + Self::Macintosh => "macintosh", + Self::Korean => "windows-949", + Self::ShiftJis => "shift_jis", + Self::Gbk => "gbk", + Self::Big5 => "big5", + } + } + + /// `bytes` as text: UTF-8 as it is, else read in this encoding, with each sequence it doesn't + /// define as U+FFFD (Windows-1252 defines every byte). + fn decode(self, bytes: &[u8]) -> Decoded { + if let Ok(s) = std::str::from_utf8(bytes) { + return Decoded { text: s.to_string(), fallback: false, replaced: false }; + } + let other = match self { + Self::Windows1252 => return Decoded { text: bytes.iter().map(|b| cp1252(*b)).collect(), fallback: true, replaced: false }, + Self::Macintosh => encoding_rs::MACINTOSH, + Self::Korean => encoding_rs::EUC_KR, + Self::ShiftJis => encoding_rs::SHIFT_JIS, + Self::Gbk => encoding_rs::GBK, + Self::Big5 => encoding_rs::BIG5, + }; + let (text, replaced) = other.decode_without_bom_handling(bytes); + Decoded { text: text.into_owned(), fallback: true, replaced } + } +} + +/// A name's hash, for counting the names past the notes. +fn hash(name: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + name.hash(&mut h); + h.finish() +} + +/// Bytes read as text by [`NameEncoding::decode`]. +struct Decoded { + text: String, + /// The bytes weren't UTF-8. + fallback: bool, + /// Some weren't valid in the encoding either, and became U+FFFD. + replaced: bool, +} + +/// Most names that weren't UTF-8 each named in a note; the rest are counted in one. +const MAX_NAME_NOTES: usize = 3; +/// Most other names counted (the count stops there). +const MAX_NAMES_COUNTED: usize = 10_000; + +/// The names read in a [`NameEncoding`], and the notes on those that weren't UTF-8: one for each +/// name (the first [`MAX_NAME_NOTES`]), then one counting the others (up to +/// [`MAX_NAMES_COUNTED`]). +#[derive(Debug, Default)] +pub(super) struct Names { + pub encoding: NameEncoding, + noted: Vec<(String, bool)>, + /// Hashes of the other names. + more: std::collections::HashSet, +} + +impl Names { + pub fn new(encoding: NameEncoding) -> Self { + Self { encoding, ..Self::default() } + } + + /// A string's or name's text as a name: a string that isn't UTF-8 read in the encoding, + /// with a note. + pub fn text(&mut self, v: &V) -> Option { + match v { + V::Str(s) => Some(self.bytes(s)), + V::Name(n) => Some(n.clone()), + _ => None, + } + } + + /// `bytes` read as a name (see [`Names::text`]). + pub fn bytes(&mut self, bytes: &[u8]) -> String { + let d = self.encoding.decode(bytes); + if d.fallback { + self.note(&d.text, d.replaced); + } + d.text + } + + /// The first text value of `o`'s entry `key`, as a name. + pub fn entry(&mut self, o: &Obj, key: &str) -> Option { + let v = o.get(key)?.iter().find(|v| matches!(v, V::Str(_)))?; + self.text(v) + } + + fn note(&mut self, name: &str, replaced: bool) { + if self.noted.iter().any(|(n, _)| n == name) { + return; + } + if self.noted.len() < MAX_NAME_NOTES { + self.noted.push((name.to_string(), replaced)); + } else { + self.count(hash(name)); + } + } + + fn count(&mut self, h: u64) { + if self.more.len() < MAX_NAMES_COUNTED { + self.more.insert(h); + } + } + + /// Take the notes of `other`, a reader's for part of the same data. + pub fn absorb(&mut self, other: Names) { + for (name, replaced) in other.noted { + self.note(&name, replaced); + } + for h in other.more { + self.count(h); + } + } + + /// The notes on the names that weren't UTF-8. + pub fn notes(&self) -> Vec { + self.notes_of(self.noted.iter().collect(), self.more.len()) + } + + /// The notes on the names in `used` that weren't UTF-8 (names read but then not used, such as + /// the layers of data whose layers aren't used, left out). + pub fn notes_among(&self, used: &[&str]) -> Vec { + let noted: Vec<&(String, bool)> = self.noted.iter().filter(|(n, _)| used.contains(&n.as_str())).collect(); + let others: std::collections::HashSet = + used.iter().filter(|n| !noted.iter().any(|(m, _)| m == *n)).map(|n| hash(n)).filter(|h| self.more.contains(h)).collect(); + self.notes_of(noted, others.len()) + } + + fn notes_of(&self, noted: Vec<&(String, bool)>, more: usize) -> Vec { + let label = self.encoding.label(); + let mut out: Vec = noted + .into_iter() + .map(|(name, replaced)| { + if *replaced { + format!( + "the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding" + ) + } else { + format!("the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding") + } + }) + .collect(); + if more > 0 { + out.push(format!("more names that aren't UTF-8 were read as {label} ({count})", count = more)); + } + out + } +} + fn cp1252(b: u8) -> char { const HIGH: [char; 32] = [ '€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', @@ -172,4 +367,107 @@ mod tests { assert_eq!(text(b"caf\xe9 \x80"), "café €"); assert_eq!(text("ü".as_bytes()), "ü"); } + + /// The default reads every byte as Windows-1252 does, the five bytes it leaves undefined as the + /// C1 controls of Latin-1, as `text` always has. + #[test] + fn names_default_to_windows_1252_with_latin_1_in_its_gaps() { + let all: Vec = (0x80..=0xff).collect(); + let mut names = Names::default(); + assert_eq!(names.bytes(&all), text(&all)); + let gaps: String = [0x81u8, 0x8d, 0x8f, 0x90, 0x9d].iter().map(|b| names.bytes(&[*b])).collect(); + assert_eq!(gaps, "\u{81}\u{8d}\u{8f}\u{90}\u{9d}"); + assert_eq!(names.bytes(b"\x80\x9f"), "€Ÿ"); + } + + /// A name that is UTF-8 is read as it is in every encoding, without a note. + #[test] + fn utf8_names_need_no_encoding() { + for label in NameEncoding::LABELS { + let mut names = Names::new(NameEncoding::from_label(label).unwrap()); + assert_eq!(names.bytes("레이어 1".as_bytes()), "레이어 1"); + assert_eq!(names.text(&V::Str(b"Layer 1".to_vec())).as_deref(), Some("Layer 1")); + assert!(names.notes().is_empty(), "{label}"); + } + } + + /// Each encoding reads the bytes of a name written in it. + #[test] + fn names_in_each_encoding() { + for (label, bytes, want) in [ + ("windows-1252", &b"Caf\xe9"[..], "Café"), + ("macintosh", b"Caf\x8e", "Café"), + ("windows-949", b"\xb7\xb9\xc0\xcc\xbe\xee 1", "레이어 1"), + ("euc-kr", b"\xb7\xb9\xc0\xcc\xbe\xee 1", "레이어 1"), + ("shift_jis", b"\x83\x8c\x83\x43\x83\x84\x81\x5b 1", "レイヤー 1"), + ("gbk", b"\xcd\xbc\xb2\xe3 1", "图层 1"), + ("big5", b"\xb9\xcf\xbch 1", "圖層 1"), + ] { + let mut names = Names::new(NameEncoding::from_label(label).unwrap()); + assert_eq!(names.bytes(bytes), want, "{label}"); + assert_eq!( + names.notes(), + vec![format!( + "the name `{want}` isn't UTF-8 and was read as {}: if it looks wrong, open the file with another legacyNameEncoding", + names.encoding.label() + )] + ); + } + assert_eq!(NameEncoding::from_label(" Windows-949 "), Some(NameEncoding::Korean)); + assert_eq!(NameEncoding::from_label("utf-8"), None); + for label in NameEncoding::LABELS { + assert_eq!(NameEncoding::from_label(label).map(NameEncoding::label), Some(label)); + } + } + + /// The Korean bytes read by default come in as Windows-1252, with a note naming what they + /// became. + #[test] + fn korean_bytes_by_default_come_in_as_windows_1252_with_a_note() { + let mut names = Names::default(); + let name = names.bytes(b"\xb7\xb9\xc0\xcc\xbe\xee 1"); + assert_eq!(name, "·¹À̾î 1"); + assert_eq!( + names.notes(), + vec!["the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding"] + ); + } + + /// Bytes the encoding doesn't define come in as U+FFFD, and the note says so. + #[test] + fn malformed_names_keep_a_replacement_character() { + let mut names = Names::new(NameEncoding::Korean); + let name = names.bytes(b"\xb7\xb9\xc0 1"); + assert!(name.contains('\u{fffd}'), "{name:?}"); + assert!(name.starts_with('레'), "{name:?}"); + let notes = names.notes(); + assert_eq!(notes.len(), 1); + assert!(notes[0].contains("isn't UTF-8, and parts of it aren't valid windows-949 either:"), "{notes:?}"); + } + + /// The notes name the first names once each, and count the others. + #[test] + fn name_notes_are_capped() { + let mut names = Names::new(NameEncoding::Macintosh); + for i in 0..10u8 { + let bytes = [b'a' + i, 0x8e]; + names.bytes(&bytes); + names.bytes(&bytes); + } + let notes = names.notes(); + assert_eq!(notes.len(), MAX_NAME_NOTES + 1); + assert!(notes[0].contains("`aé`"), "{notes:?}"); + assert_eq!(notes.last().map(String::as_str), Some("more names that aren't UTF-8 were read as macintosh (7)")); + let mut all = Names::new(NameEncoding::Macintosh); + all.absorb(names); + assert_eq!(all.notes(), notes); + // The count stops at its limit, whatever the file holds. + let mut many = Names::new(NameEncoding::Macintosh); + for i in 0..MAX_NAME_NOTES + MAX_NAMES_COUNTED + 50 { + let mut bytes = i.to_string().into_bytes(); + bytes.push(0x8e); + many.bytes(&bytes); + } + assert_eq!(many.notes().last().map(String::as_str), Some("more names that aren't UTF-8 were read as macintosh (10000)")); + } } diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index b624a2247..4231bade1 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -27,7 +27,7 @@ use kurbo::{Affine, Point, Vec2}; use vectorcraft_color::gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; use vectorcraft_color::{Color, Paint}; -use super::obj::V; +use super::obj::{Names, V}; /// Largest number of stops read in one gradient. const MAX_STOPS: usize = 256; @@ -44,8 +44,8 @@ pub(super) struct Named { } /// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named -/// colour it uses. `None` when the operands don't fit. -pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { +/// colour it uses, its name read by `names`. `None` when the operands don't fit. +pub(super) fn color_op_in(op: &str, vals: &[V], names: &mut Names) -> Option<(Paint, Option)> { let nums: Vec = vals.iter().filter_map(V::num).map(|v| v as f32).collect(); let unit = |v: f32| v.clamp(0.0, 1.0); let last = |n: usize| nums.get(nums.len().checked_sub(n)?..); @@ -59,9 +59,9 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { let [r, g, b] = last(3)? else { return None }; Some((Paint::solid(Color::rgb(unit(*r), unit(*g), unit(*b))), None)) } - "x" => named(vals, Some(false)), - "xx" => named(vals, None), - "xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))), + "x" => named(vals, Some(false), names), + "xx" => named(vals, None, names), + "xk" => named(vals, None, names).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))), "xs" | "xz" => registration(vals), _ => None, } @@ -69,9 +69,9 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { /// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab); /// `rgb` forces RGB or CMYK. -fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { +fn named(vals: &[V], rgb: Option, names: &mut Names) -> Option<(Paint, Option)> { let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?; - let name = vals.get(at)?.text()?; + let name = vals.get(at)?; let before: Vec = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect(); let after: Vec = vals.get(at + 1..)?.iter().filter_map(V::num).map(|v| v as f32).collect(); let unit = |v: f32| v.clamp(0.0, 1.0); @@ -91,6 +91,7 @@ fn named(vals: &[V], rgb: Option) -> Option<(Paint, Option)> { let [c, m, y, k] = before.get(from..from + 4)? else { return None }; Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k)) }; + let name = names.text(name)?; let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint }; Some((paint, Some(Named { name, color, tint, spot: true }))) } @@ -105,7 +106,7 @@ fn registration(vals: &[V]) -> Option<(Paint, Option)> { } /// The colour of a gradient stop's colour values and style. -fn stop_color(style: f64, comps: &[V]) -> Option<(Color, Option)> { +fn stop_color(style: f64, comps: &[V], names: &mut Names) -> Option<(Color, Option)> { let nums: Vec = comps.iter().filter_map(V::num).map(|v| v as f32).collect(); let unit = |v: f32| v.clamp(0.0, 1.0); let tail = |n: usize| nums.get(nums.len().checked_sub(n)?..); @@ -120,7 +121,7 @@ fn stop_color(style: f64, comps: &[V]) -> Option<(Color, Option)> { (Color::rgb(unit(*r), unit(*g), unit(*b)), None) } _ => { - let (paint, named) = named(comps, Some(style as i64 == 4))?; + let (paint, named) = named(comps, Some(style as i64 == 4), names)?; (paint.color()?, named) } }) @@ -134,16 +135,16 @@ pub(super) struct GradientDef { } impl GradientDef { - /// `(name) type stops Bd`. - pub fn begin(vals: &[V]) -> Option { - let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None })?; + /// `(name) type stops Bd`, the name read by `names`. + pub fn begin(vals: &[V], names: &mut Names) -> Option { + let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v))?; let nums: Vec = vals.iter().filter_map(V::num).collect(); let kind = if nums.first().copied() == Some(1.0) { GradientKind::Radial } else { GradientKind::Linear }; Some(Self { name, gradient: Gradient::new(kind, vec![]) }) } - /// A stop (`Bs`). - pub fn stop(&mut self, vals: &[V], spots: &mut Vec) { + /// A stop (`Bs`), the name of its colour read by `names`. + pub fn stop(&mut self, vals: &[V], spots: &mut Vec, names: &mut Names) { if self.gradient.stops.len() >= MAX_STOPS { return; } @@ -154,7 +155,7 @@ impl GradientDef { let newer = n >= 5 && n.checked_sub(3).and_then(num) == Some(6.0); let (style_at, opacity) = if newer { (n.checked_sub(5), n.checked_sub(4).and_then(num).unwrap_or(1.0)) } else { (n.checked_sub(3), 1.0) }; let (Some(style_at), Some(style)) = (style_at, style_at.and_then(num)) else { return }; - let Some((color, named)) = stop_color(style, vals.get(..style_at).unwrap_or_default()) else { return }; + let Some((color, named)) = stop_color(style, vals.get(..style_at).unwrap_or_default(), names) else { return }; let mut stop = GradientStop::new((ramp / 100.0).clamp(0.0, 1.0) as f32, color); stop.opacity = opacity.clamp(0.0, 1.0) as f32; stop.midpoint = (mid / 100.0).clamp(0.13, 0.87) as f32; @@ -211,10 +212,10 @@ impl Instance { Self { stroke, length: 1.0, ..Self::default() } } - /// `flag (name) x y angle length a b c d tx ty Bg`. - pub fn bg(&mut self, vals: &[V]) { + /// `flag (name) x y angle length a b c d tx ty Bg`, the name read by `names`. + pub fn bg(&mut self, vals: &[V], names: &mut Names) { let Some(at) = vals.iter().rposition(|v| matches!(v, V::Str(_))) else { return }; - self.name = vals.get(at).and_then(V::text); + self.name = vals.get(at).and_then(|v| names.text(v)); let n: Vec = vals.get(at + 1..).unwrap_or_default().iter().filter_map(V::num).collect(); if let [x, y, angle, length, m @ ..] = n.as_slice() { self.origin = Point::new(*x, *y); @@ -333,6 +334,35 @@ mod tests { v.iter().map(|n| V::Num(*n)).collect() } + fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option)> { + color_op_in(op, vals, &mut Names::default()) + } + + /// A named colour, a gradient and its stop's colour, and the gradient an object names, read in + /// the encoding picked, so that names written alike match. + #[test] + fn colour_and_gradient_names_read_in_the_encoding() { + let mut names = Names::new(super::super::obj::NameEncoding::Korean); + let mut v = nums(&[0.0, 0.5, 1.0, 0.0]); + v.extend([V::Str(b"\xbb\xa1\xb0\xad".to_vec()), V::Num(0.0)]); + let (_, named) = color_op_in("x", &v, &mut names).unwrap(); + assert_eq!(named.unwrap().name, "빨강"); + let mut d = GradientDef::begin(&[V::Str(b"\xb1\xd7\xb6\xf3".to_vec()), V::Num(0.0)], &mut names).unwrap(); + let mut stop = nums(&[0.0, 0.5, 1.0, 0.0]); + stop.extend([V::Str(b"\xbb\xa1\xb0\xad".to_vec()), V::Num(0.0), V::Num(3.0), V::Num(50.0), V::Num(0.0)]); + let mut spots = vec![]; + d.stop(&stop, &mut spots, &mut names); + assert_eq!(spots.first().map(|s| s.name.as_str()), Some("빨강")); + let (name, g) = d.finish().unwrap(); + assert_eq!(name, "그라"); + let mut i = Instance::begin(&[]); + let mut bg = vec![V::Num(1.0), V::Str(b"\xb1\xd7\xb6\xf3".to_vec())]; + bg.extend(nums(&[0.0, 0.0, 0.0, 10.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + i.bg(&bg, &mut names); + assert!(i.paint(&HashMap::from([(name, g)]), Affine::IDENTITY).is_some()); + assert_eq!(names.notes().len(), 2); + } + #[test] fn registration_paints_with_the_registration_swatch_at_its_tint() { let mut v = nums(&[0.75, 0.68, 0.67, 0.9, 0.0, 0.0, 0.0]); @@ -390,10 +420,11 @@ mod tests { #[test] fn gradient_definition_and_instance() { - let mut d = GradientDef::begin(&[V::Str(b"G".to_vec()), V::Num(1.0), V::Num(2.0)]).unwrap(); + let names = &mut Names::default(); + let mut d = GradientDef::begin(&[V::Str(b"G".to_vec()), V::Num(1.0), V::Num(2.0)], names).unwrap(); let mut spots = vec![]; - d.stop(&nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.5, 6.0, 30.0, 100.0]), &mut spots); - d.stop(&nums(&[0.81, 0.0, 1.0, 6.0, 50.0, 0.0]), &mut spots); + d.stop(&nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.5, 6.0, 30.0, 100.0]), &mut spots, names); + d.stop(&nums(&[0.81, 0.0, 1.0, 6.0, 50.0, 0.0]), &mut spots, names); let (name, g) = d.finish().unwrap(); assert_eq!(name, "G"); assert_eq!(g.kind, GradientKind::Radial); @@ -404,7 +435,7 @@ mod tests { let mut i = Instance::begin(&[]); let mut bg = vec![V::Num(1.0), V::Str(b"G".to_vec())]; bg.extend(nums(&[100.0, 100.0, 0.0, 50.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); - i.bg(&bg); + i.bg(&bg, names); i.bm(&nums(&[50.0, 0.0, 0.0, -50.0, 100.0, 100.0])); let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() }; let geom = gp.geom.unwrap(); @@ -414,7 +445,7 @@ mod tests { assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none()); // Newer files give the matrix with `Xm`; the `Bm`s after it are bands of a cached ramp. let mut i = Instance::begin(&[]); - i.bg(&bg); + i.bg(&bg, names); i.xm(&nums(&[40.0, 0.0, 0.0, -40.0, 10.0, 10.0])); i.bm(&nums(&[5.0, 0.0, 0.0, -40.0, 30.0, 10.0])); let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() }; @@ -424,11 +455,12 @@ mod tests { #[test] fn gradient_stops_beyond_the_cap_are_ignored() { - let mut d = GradientDef::begin(&[V::Str(b"H".to_vec()), V::Num(0.0)]).unwrap(); + let names = &mut Names::default(); + let mut d = GradientDef::begin(&[V::Str(b"H".to_vec()), V::Num(0.0)], names).unwrap(); let mut spots = vec![]; for i in 0..257 { let ramp = i as f64 / 256.0 * 100.0; - d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut spots); + d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut spots, names); } let (_, g) = d.finish().unwrap(); assert_eq!(g.stops.len(), MAX_STOPS); @@ -438,16 +470,17 @@ mod tests { /// art space, `Xm` for the whole ramp and a `Bm` for each pair of stops. #[test] fn a_linear_gradient_runs_its_whole_ramp_not_its_last_part() { - let mut d = GradientDef::begin(&[V::Str(b"L".to_vec()), V::Num(0.0), V::Num(3.0)]).unwrap(); + let names = &mut Names::default(); + let mut d = GradientDef::begin(&[V::Str(b"L".to_vec()), V::Num(0.0), V::Num(3.0)], names).unwrap(); for ramp in [100.0, 60.0, 20.0] { - d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut vec![]); + d.stop(&nums(&[0.2, 0.4, 0.6, 0.0, 1.0, 1.0, 6.0, 50.0, ramp]), &mut vec![], names); } let defs = HashMap::from([d.finish().unwrap()]); let instance = |xm: bool| { let mut i = Instance::begin(&[]); let mut bg = vec![V::Num(1.0), V::Str(b"L".to_vec())]; bg.extend(nums(&[0.3, -0.2, -90.0, 0.5, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0])); - i.bg(&bg); + i.bg(&bg, &mut Names::default()); if xm { i.xm(&nums(&[0.0, -200.0, -30.0, 0.0, 10.0, 300.0])); } diff --git a/crates/eps/src/import/ai/palette.rs b/crates/eps/src/import/ai/palette.rs index 43955d5fc..f96ae8bb5 100644 --- a/crates/eps/src/import/ai/palette.rs +++ b/crates/eps/src/import/ai/palette.rs @@ -18,7 +18,7 @@ use vectorcraft_color::Color; use super::lex::{Lexer, Tok}; -use super::obj::V; +use super::obj::{Names, V}; use super::paint::{self, Named}; /// Largest number of swatches read from one palette. @@ -60,8 +60,9 @@ impl Palette { } } -/// The palette of `data`: what follows `%AI5_BeginPalette`, up to its end. -pub(super) fn palette(data: &[u8]) -> Palette { +/// The palette of `data`: what follows `%AI5_BeginPalette`, up to its end, its names read by +/// `names`. +pub(super) fn palette(data: &[u8], names: &mut Names) -> Palette { let mut lex = Lexer::new(data); let mut out = Palette::default(); let mut vals: Vec = Vec::new(); @@ -110,29 +111,29 @@ pub(super) fn palette(data: &[u8]) -> Palette { cell = None; } "Bg" if in_gradient => { - if let Some(name) = last_text(&vals) { + if let Some(name) = last_text(&vals, names) { cell = Some(Cell::Gradient(name)); } } "BB" => in_gradient = false, - "p" => cell = first_text(&vals).map(Cell::Pattern), + "p" => cell = first_text(&vals, names).map(Cell::Pattern), "Xs" | "Xz" | "XS" | "XZ" => cell = None, "g" | "k" | "x" | "Xa" | "Xx" | "Xk" => { - cell = paint::color_op(w, &vals).and_then(|(p, named)| match named { + cell = paint::color_op_in(w, &vals, names).and_then(|(p, named)| match named { Some(Named { color, spot, .. }) => Some(Cell::Color { color, global: true, spot }), None => p.color().map(|color| Cell::Color { color, global: false, spot: false }), }); } "Pg" => { // Capped as swatches are: a file of many `Pg` would make as many empty groups. - if let Some(name) = first_text(&vals) + if let Some(name) = first_text(&vals, names) && out.groups.len() < MAX_SWATCHES { out.groups.push((name, Vec::new())); } } "Pc" => { - if let (Some(name), Some(c)) = (last_text(&vals), cell.take()) + if let (Some(name), Some(c)) = (last_text(&vals, names), cell.take()) && !name.starts_with('[') && out.len() < MAX_SWATCHES { @@ -157,12 +158,12 @@ fn push(vals: &mut Vec, v: V) { } } -fn first_text(vals: &[V]) -> Option { - vals.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text) +fn first_text(vals: &[V], names: &mut Names) -> Option { + vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v)) } -fn last_text(vals: &[V]) -> Option { - vals.iter().rev().find(|v| matches!(v, V::Str(_))).and_then(V::text) +fn last_text(vals: &[V], names: &mut Names) -> Option { + vals.iter().rev().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v)) } #[cfg(test)] @@ -173,6 +174,21 @@ mod tests { Entry { name: name.into(), cell: Cell::Color { color, global, spot } } } + fn palette(data: &[u8]) -> Palette { + super::palette(data, &mut Names::default()) + } + + /// Swatch, group and spot colour names that aren't UTF-8 are read in the encoding picked. + #[test] + fn palette_names_read_in_the_encoding() { + let mut names = Names::new(super::super::obj::NameEncoding::ShiftJis); + // 赤 (red) and 色 (color) in Shift_JIS. + let p = super::palette(b"0 0 Pb\n0 (\x90\x46) 1 Pg\n0 1 1 0 (\x90\xd4) 0 x\n(\x90\xd4)\nPc\nPB\n", &mut names); + assert_eq!(p.groups.first().map(|(n, s)| (n.as_str(), s.len())), Some(("色", 1))); + assert_eq!(p.groups.first().and_then(|(_, s)| s.first()), Some(&color("赤", Color::cmyk(0.0, 1.0, 1.0, 0.0), true, true))); + assert_eq!(names.notes().len(), 2); + } + #[test] fn a_palette_gives_its_swatches_in_order() { let p = palette( diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index cad61b744..7590e31a7 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -67,7 +67,7 @@ use vectorcraft_geom::{FillRule, PathData}; use super::super::ate::CANVAS_CENTRE; use super::lex::{Lexer, Tok}; -use super::obj::{self, Obj, V}; +use super::obj::{self, NameEncoding, Obj, V}; use super::paint::{self, GradientDef, Instance, Named}; use crate::ps; @@ -127,11 +127,20 @@ pub struct Structure { pub warnings: Vec, } +/// [`read_with`], names that aren't UTF-8 read as Windows-1252. +#[cfg(test)] +pub fn read(data: &[u8], large_canvas: bool) -> Result { + read_with(data, large_canvas, NameEncoding::default()) +} + /// Read the structure of the editing data `data`, applying the file's `%AI24_LargeCanvasScale` only /// when `large_canvas` is set: an EPS's page is drawn at the full size, but a `.ai`'s PDF part may -/// keep the reduced one, which the PDF import reads as it is (#2031). -pub fn read(data: &[u8], large_canvas: bool) -> Result { +/// keep the reduced one, which the PDF import reads as it is (#2031). Names that aren't UTF-8 +/// (layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches) are read in +/// `names`, each with a warning. +pub fn read_with(data: &[u8], large_canvas: bool, names: NameEncoding) -> Result { let mut r = Reader::new(data); + r.names = obj::Names::new(names); r.large_canvas = large_canvas.then_some(1.0); r.run()?; r.finish() @@ -272,6 +281,8 @@ struct Reader<'a> { /// The legacy text object being read (`To` … `TO`), and the modal type attributes. text: Option, type_state: legacy_text::TypeState, + /// How names that aren't UTF-8 are read, and the notes on them. + names: obj::Names, } /// A section comment's marker without its `AI_` prefix and value (`AI14_BeginSymbol` → @@ -364,6 +375,7 @@ impl<'a> Reader<'a> { pattern_tiles: HashMap::new(), text: None, type_state: legacy_text::TypeState::default(), + names: obj::Names::default(), } } diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 1d5806a3c..d710863e6 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -63,12 +63,12 @@ impl<'a> Reader<'a> { } if m == "BeginArtStyles" { let section = self.section("EndArtStyles")?; - self.styles = crate::import::ai::style::art_styles(section); + self.styles = crate::import::ai::style::art_styles_in(section, &mut self.names); return Ok(()); } if m == "BeginPalette" { let section = self.section("EndPalette")?; - self.palette = crate::import::ai::palette::palette(section); + self.palette = crate::import::ai::palette::palette(section, &mut self.names); return Ok(()); } if m == "BeginEncoding" { @@ -198,6 +198,7 @@ impl<'a> Reader<'a> { return; } let mut sub = Reader::new(section); + sub.names = super::obj::Names::new(self.names.encoding); sub.subreader_depth = self.subreader_depth + 1; sub.pattern = Some(PatternHead::default()); // The gradients defined so far, lent (and those it defines kept). @@ -207,6 +208,7 @@ impl<'a> Reader<'a> { let read = sub.open(Kind::Group, false).and_then(|()| sub.run()); self.nodes = sub.nodes; self.gradients = std::mem::take(&mut sub.gradients); + self.names.absorb(std::mem::take(&mut sub.names)); // Unread art in the definition, hidden or not, leaves the tile incomplete. let whole = read.is_ok() && sub.unsupported.is_empty() @@ -248,6 +250,7 @@ impl<'a> Reader<'a> { _ => return, }; let mut sub = Reader::new(section); + sub.names = super::obj::Names::new(self.names.encoding); sub.subreader_depth = self.subreader_depth + 1; sub.cropmarks = self.cropmarks; sub.template = self.template; @@ -272,7 +275,8 @@ impl<'a> Reader<'a> { head.taken = true; let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals }; let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default()); - let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals }; + let [x0, y0, x1, y1] = tile.as_slice() else { return vals }; + let Some(name) = vals.get(at).and_then(|v| self.names.text(v)) else { return vals }; head.name = name; head.tile = Some(Rect::new(*x0, *y0, *x1, *y1)); vals.split_off(at + 5) diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 0874ec095..c35c36819 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -5,6 +5,7 @@ use super::{ Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure, Swatch, Unit, V, obj, }; +use crate::import::ai::NameEncoding; use vectorcraft_doc::{Guide, ImageBlob, PatternDef}; /// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files). @@ -36,10 +37,11 @@ pub(crate) fn slot_frame(name: Option<&str>) -> usize { /// The artboards (names, and rects in art space) of the editing data `data` that [`read`] can't /// read as a whole, as far as it gets: where to put the pages of its PDF part instead, and what to -/// call them (#1068). -pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> { - let found = artboards_read(data); - if !found.is_empty() { +/// call them (#1068). Names that aren't UTF-8 are read in `names`, and the notes on them are +/// returned too. +pub fn artboards(data: &[u8], names: NameEncoding) -> (Vec<(String, Rect)>, Vec) { + let found = artboards_read(data, names); + if !found.0.is_empty() { return found; } // Reading can stop before the artboards (art kept in the document's data comes first): read @@ -48,7 +50,7 @@ pub fn artboards(data: &[u8]) -> Vec<(String, Rect)> { let mut alone = b"%_/Document :\n%_/Dictionary :\n".to_vec(); alone.extend_from_slice(array); alone.extend_from_slice(b"\n%_; /NotRecorded ,\n%_;\n"); - artboards_read(&alone) + artboards_read(&alone, names) }) } @@ -84,14 +86,18 @@ pub struct PaletteSwatches { pub patterns: Vec, /// The image blobs the art of `patterns` shows, by key. pub images: std::collections::BTreeMap, + /// The notes on the names that weren't UTF-8. + pub notes: Vec, } /// The swatches and color groups of the palette of the editing data `data`, for a file whose /// layers aren't read: the swatches its PDF part has are the defaults (#4036). They come as /// [`read`] makes them, with pattern swatches only for the patterns in `patterns` and those the -/// editing data defines; `None` when the palette is empty or reading stops before it. -pub fn palette(data: &[u8], patterns: &[PatternDef]) -> Option { +/// editing data defines; `None` when the palette is empty or reading stops before it. Names that +/// aren't UTF-8 are read in `names`. +pub fn palette(data: &[u8], patterns: &[PatternDef], names: NameEncoding) -> Option { let mut r = Reader::new(data); + r.names = obj::Names::new(names); // The palette, the gradients and the patterns it names come before the art, so an error in the // art leaves them read. let _ = r.run(); @@ -111,13 +117,22 @@ pub fn palette(data: &[u8], patterns: &[PatternDef]) -> Option images.insert(im.key.clone(), blob.clone()); } }); - Some(PaletteSwatches { swatches, groups, patterns: added, images }) + let used: Vec<&str> = swatches + .iter() + .chain(groups.iter().flat_map(|g| &g.swatches)) + .map(|s| s.name.as_str()) + .chain(groups.iter().map(|g| g.name.as_str())) + .chain(added.iter().map(|p| p.name.as_str())) + .collect(); + let notes = r.names.notes_among(&used); + Some(PaletteSwatches { swatches, groups, patterns: added, images, notes }) } /// [`artboards`] as far as reading `data` from its start gets: those read before an error, and /// those of a `/Document` still open when it stopped (its art can be what stopped it). -fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { +fn artboards_read(data: &[u8], names: NameEncoding) -> (Vec<(String, Rect)>, Vec) { let mut r = Reader::new(data); + r.names = obj::Names::new(names); let _ = r.run(); let open: Vec = r.frames.iter().filter_map(|f| if let Kind::Obj(o) = &f.kind { Some((**o).clone()) } else { None }).collect(); for o in &open { @@ -125,7 +140,9 @@ fn artboards_read(data: &[u8]) -> Vec<(String, Rect)> { r.document(o); } } - r.artboards + let used: Vec<&str> = r.artboards.iter().map(|(n, _)| n.as_str()).collect(); + let notes = r.names.notes_among(&used); + (r.artboards, notes) } /// How many variables and data sets the variable library of the editing data has: XML nodes @@ -263,7 +280,7 @@ impl<'a> Reader<'a> { self.unreadable("opacity masks without readable art", false); } } - let name = match o.text("AIArtName") { + let name = match self.names.entry(o, "AIArtName") { Some(name) => name, None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) { Some(id) => obj::xml_name(&id), @@ -292,7 +309,7 @@ impl<'a> Reader<'a> { if !sane(r) || r.area() <= 0.0 { continue; } - let name = ab.text("Name").unwrap_or_else(|| format!("Artboard {}", i + 1)); + let name = self.names.entry(ab, "Name").unwrap_or_else(|| format!("Artboard {}", i + 1)); self.artboards.push((name, r)); } } @@ -490,6 +507,7 @@ impl<'a> Reader<'a> { )); } warnings.extend(self.variables.note()); + warnings.extend(self.names.notes()); Ok(Structure { doc, to_doc, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index 7e7b79746..d7a5cfa4a 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -85,7 +85,7 @@ impl<'a> Reader<'a> { } if let Some(d) = self.gradient_def.as_mut() { match w { - "Bs" => d.stop(&vals, &mut self.stop_spots), + "Bs" => d.stop(&vals, &mut self.stop_spots, &mut self.names), "BD" => { if let Some((name, g)) = self.gradient_def.take().and_then(GradientDef::finish) { self.gradients.insert(name, g); @@ -133,7 +133,7 @@ impl<'a> Reader<'a> { "Lb" => self.layer_attrs(&vals), "Ln" => { if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() { - a.name = vals.iter().rev().find_map(V::text).unwrap_or_default(); + a.name = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default(); } } "LB" => self.end_layer()?, @@ -216,12 +216,12 @@ impl<'a> Reader<'a> { self.style_marker(&vals, hidden) } // Gradients. - "Bd" => self.gradient_def = GradientDef::begin(&vals), + "Bd" => self.gradient_def = GradientDef::begin(&vals, &mut self.names), "Bs" | "BD" => {} "Bb" => self.instance = Some(Instance::begin(&vals)), "Bg" => { if let Some(i) = self.instance.as_mut() { - i.bg(&vals); + i.bg(&vals, &mut self.names); } } "Bm" => { @@ -344,7 +344,7 @@ impl<'a> Reader<'a> { if self.frames.is_empty() { return; } - let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| self.names.text(v)); let Some(tile) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).copied() else { self.unread_pattern(w); return; @@ -379,7 +379,7 @@ impl<'a> Reader<'a> { } fn color(&mut self, w: &str, vals: &[V]) { - let Some((p, named)) = paint::color_op(w, vals) else { return }; + let Some((p, named)) = paint::color_op_in(w, vals, &mut self.names) else { return }; // Registration (`Xs`, `Xz`) says nothing of the document's colour model. let registration = matches!(w, "Xs" | "XS" | "Xz" | "XZ"); match p.color().filter(|_| !registration) { @@ -499,7 +499,7 @@ impl<'a> Reader<'a> { /// follows an object. fn style_marker(&mut self, vals: &[V], hidden: bool) { let code = Self::nums(vals).last().copied().unwrap_or(0.0); - let style = vals.iter().rev().find_map(V::text).unwrap_or_default(); + let style = vals.iter().rev().find_map(|v| self.names.text(v)).unwrap_or_default(); let after = std::mem::take(&mut self.after_object); // An object's own state block ends `6 () XW` (a layer's `7 () XW`): an `Xy` before it isn't the layer's. if code == 6.0 { diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index ede1ae8e9..6aa2512f5 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -44,7 +44,7 @@ use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{Dash, Effect, LineCap, LineJoin, WidthProfile}; use super::lex::{Lexer, Tok}; -use super::obj::V; +use super::obj::{Names, V}; use super::paint; const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files @@ -204,8 +204,15 @@ impl Default for Filter { /// Most filters read in one style. const MAX_FILTERS: usize = 4096; -/// The styles of the section `data` (between its markers), by name. +/// [`art_styles_in`], names read in the default encoding. +#[cfg(test)] pub(super) fn art_styles(data: &[u8]) -> HashMap { + art_styles_in(data, &mut Names::default()) +} + +/// The styles of the section `data` (between its markers), by name: the names of the styles and of +/// the colours, gradients and patterns they paint with read by `names`. +pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap { let mut styles = HashMap::new(); let mut name: Option = None; let mut filters: Vec = vec![]; @@ -239,7 +246,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { open.clear(); (pending, in_basic) = (None, false); } - b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text), + b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)), b"CompoundFilter" => pending = Some(true), b"BasicFilter" => pending = Some(false), b"Part" => in_basic = false, @@ -305,7 +312,7 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { open.pop(); } if let Some(f) = filters.last_mut() { - paint_op(f, op, &operands); + paint_op(f, op, &operands, names); } if op == "," || op == ";" || op.starts_with('X') || op.starts_with('B') || op.len() <= 2 { operands.clear(); @@ -322,12 +329,12 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap { styles } -/// A paint operator of a fill's or stroke's style. -fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { +/// A paint operator of a fill's or stroke's style, the names it gives read by `names`. +fn paint_op(f: &mut Filter, op: &str, vals: &[V], names: &mut Names) { let last = vals.iter().rev().find_map(V::num); match op { "Xa" | "k" | "g" | "x" | "Xk" | "XA" | "K" | "G" | "X" | "XK" => { - let Some((p, _)) = paint::color_op(op, vals) else { return }; + let Some((p, _)) = paint::color_op_in(op, vals, names) else { return }; if op.ends_with(|c: char| c.is_ascii_lowercase()) { f.fill = Some(p); } else { @@ -381,13 +388,13 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { "Bg" => { f.gradient = true; let at = vals.iter().rposition(|v| matches!(v, V::Str(_))); - let name = at.and_then(|i| vals.get(i)).and_then(V::text); + let name = at.and_then(|i| vals.get(i)).and_then(|v| names.text(v)); let n: Vec = at.map_or(&[][..], |i| vals.get(i + 1..).unwrap_or_default()).iter().filter_map(V::num).collect(); let angle = n.get(2).copied().filter(|a| a.is_finite()).unwrap_or(0.0); f.gradient_of = Some((name, angle)); } "p" => { - let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let name = vals.iter().find(|v| matches!(v, V::Str(_))).and_then(|v| names.text(v)); let matrix = vals.iter().rev().find_map(|v| match v { V::Arr(a) => { let n: Vec = a.iter().filter_map(V::num).collect(); diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 964b74f10..607ea0eb1 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1,5 +1,9 @@ use super::*; +fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { + super::paint_op(f, op, vals, &mut Names::default()); +} + #[test] fn a_variable_width_stroke_keeps_its_profile() { let data = b"/KnownStyle :\n(Tapered) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index a0a24d865..7489ea725 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -1775,7 +1775,7 @@ fn artboards_after_what_stops_the_reading() { let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board("one", 0), board("two", 200)); // Art in the document's own data that the reader can't follow comes before the artboards. let data = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n%_/Dictionary :\nLB\n{array}%_; /NotRecorded ,\n%_;\n"); - let boards: Vec<_> = super::artboards(data.as_bytes()).into_iter().map(|(n, r)| (n, r.x0)).collect(); + let boards: Vec<_> = super::artboards(data.as_bytes(), NameEncoding::default()).0.into_iter().map(|(n, r)| (n, r.x0)).collect(); assert_eq!(boards, [("one".to_string(), 0.0), ("two".to_string(), 200.0)]); } @@ -3102,3 +3102,119 @@ fn an_objects_on_path_group_in_versioned_content_opens_once() { assert_eq!(kids[0].children().unwrap().len(), 5); assert!(kids[0].extras().objects_on_path.is_some()); } + +/// `body` read with its names that aren't UTF-8 in `encoding`. +fn read_names(body: &str, encoding: &str) -> Structure { + let encoding = NameEncoding::from_label(encoding).unwrap(); + match super::read_with(stream(body).as_bytes(), true, encoding) { + Ok(s) => s, + Err(e) => panic!("{e}"), + } +} + +/// The notes on names that weren't UTF-8. +fn name_notes(s: &Structure) -> Vec<&str> { + s.warnings.iter().map(String::as_str).filter(|w| w.contains("isn't UTF-8") || w.contains("aren't UTF-8")).collect() +} + +/// A layer's name in a legacy code page (`(name) Ln`, the bytes as octal escapes) is read in the +/// encoding picked, with a note that names it and the encoding; one in UTF-8 is read as it is in +/// every encoding, without a note. +#[test] +fn layer_names_are_read_in_the_legacy_encoding_picked() { + let layer_named = |name: &str| { + format!( + "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{RED}{}f\nLB\n%AI5_EndLayer--\n", + rect_path(0.0, 0.0, 10.0, 10.0) + ) + }; + for (encoding, bytes, want) in [ + ("windows-949", r"\267\271\300\314\276\356 1", "레이어 1"), + ("shift_jis", r"\203\214\203C\203\204\201[ 1", "レイヤー 1"), + ("gbk", r"\315\274\262\343 1", "图层 1"), + ("big5", r"\271\317\274h 1", "圖層 1"), + ("macintosh", r"Caf\216", "Café"), + ("windows-1252", r"Caf\351", "Café"), + ] { + let s = read_names(&layer_named(bytes), encoding); + assert_eq!(s.doc.layers[0].name.as_deref(), Some(want), "{encoding}"); + assert_eq!( + name_notes(&s), + [format!("the name `{want}` isn't UTF-8 and was read as {encoding}: if it looks wrong, open the file with another legacyNameEncoding")], + "{encoding}" + ); + let utf8 = read_names(&layer_named(r"\353\240\210\354\235\264\354\226\264 1"), encoding); + assert_eq!(utf8.doc.layers[0].name.as_deref(), Some("레이어 1"), "{encoding}"); + assert!(name_notes(&utf8).is_empty(), "{encoding}: {:?}", utf8.warnings); + } + // By default the Korean bytes come in as Windows-1252, as before, now with the note. A comment + // in UTF-8 doesn't change how the names are read. + let s = read_names(&format!("%%Title: 레이어\n{}", layer_named(r"\267\271\300\314\276\356 1")), "windows-1252"); + assert_eq!(s.doc.layers[0].name.as_deref(), Some("·¹À̾î 1")); + assert_eq!( + name_notes(&s), + ["the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding"] + ); + // Bytes the encoding doesn't define come in as U+FFFD, and the note says so. + let s = read_names(&layer_named(r"\267\271\300 1"), "windows-949"); + assert_eq!(s.doc.layers[0].name.as_deref(), Some("레\u{fffd} 1")); + assert_eq!( + name_notes(&s), + ["the name `레\u{fffd} 1` isn't UTF-8, and parts of it aren't valid windows-949 either: open the file with another legacyNameEncoding"] + ); +} + +/// An object's name, an artboard's, a spot colour's and a pattern's are read in the encoding +/// picked too, and a fill names the pattern as its definition does, whichever encoding is picked. +#[test] +fn object_artboard_colour_and_pattern_names_are_read_in_the_encoding_picked() { + // 그라 (a pattern and a group), 빨강 (an artboard and a spot colour), 레이어 1 (the layer). + let (pattern, red, layer_name) = (r"\261\327\266\363", r"\273\241\260\255", r"\267\271\300\314\276\356 1"); + let boards = format!( + "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_200 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_({red}) /UnicodeString (Name) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n" + ); + let tile = DOTS.replace("(Dots)", &format!("({pattern})")); + let art_ = format!( + "0 Ae\nu\n0 1 1 0 ({red}) 0 x\n{}f\n({pattern}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\nU\n%_/ArtDictionary :\n%_({pattern}) /String (AIArtName) ,\n%_;\n9 () XW\n", + rect_path(0.0, 0.0, 10.0, 10.0), + rect_path(20.0, 0.0, 30.0, 10.0), + ); + let body = format!("{boards}{tile}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({layer_name}) Ln\n{art_}LB\n%AI5_EndLayer--\n"); + for (encoding, [pattern, red, layer_name]) in [("windows-949", ["그라", "빨강", "레이어 1"]), ("windows-1252", ["±×¶ó", "»¡°\u{ad}", "·¹À̾î 1"])] + { + let s = read_names(&body, encoding); + let doc = &s.doc; + assert_eq!(doc.layers[0].name.as_deref(), Some(layer_name), "{encoding}"); + assert_eq!(doc.artboards.first().map(|a| a.name.as_str()), Some(red), "{encoding}"); + let group = &art(doc)[0]; + assert_eq!(group.name.as_deref(), Some(pattern), "{encoding}"); + let kids = group.children().unwrap(); + assert!(matches!(fill(&kids[0]), Paint::Solid { swatch: Some(s), .. } if s == red), "{encoding}: {:?}", fill(&kids[0])); + assert!(doc.swatch(red).is_some_and(|s| s.spot), "{encoding}"); + assert!(matches!(fill(&kids[1]), Paint::Pattern { pattern: p, .. } if p == pattern), "{encoding}: {:?}", fill(&kids[1])); + assert!(doc.pattern(pattern).is_some(), "{encoding}"); + assert!(s.unread.is_empty(), "{encoding}: {:?}", s.unread); + assert_eq!(name_notes(&s).len(), 3, "{encoding}: {:?}", s.warnings); + } +} + +/// Past three names, the notes count the others in one. +#[test] +fn notes_on_names_that_arent_utf8_are_capped() { + let layers: String = (0..6) + .map(|i| { + format!( + "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L{i} Caf\\216) Ln\n{RED}{}f\nLB\n%AI5_EndLayer--\n", + rect_path(0.0, 0.0, 10.0, 10.0) + ) + }) + .collect(); + let s = read_names(&layers, "macintosh"); + let names: Vec<_> = s.doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert!(names.iter().all(|n| n.ends_with(" Café")), "{names:?}"); + let notes = name_notes(&s); + assert_eq!(notes.len(), 4, "{notes:?}"); + assert_eq!(notes[3], "more names that aren't UTF-8 were read as macintosh (3)"); +} diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 13a746b78..4d207a9e1 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -51,7 +51,7 @@ use build::{Frame, MADE, prune_unseen}; use compare::{ MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour, }; -pub use layers::{ai_alone, layered_ai}; +pub use layers::{ai_alone, ai_alone_with, layered_ai, layered_ai_with}; pub(super) use layers::{layered, program_alone}; pub use slots::is_loss; #[cfg(test)] diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index bf0a8d4a5..9ae1e0b90 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -35,7 +35,7 @@ pub(super) struct Frame<'a> { /// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and /// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in /// the text objects. `unit`: the size in points of a unit of the page's user space (see -/// [`layered_ai`]). +/// [`layered_ai`]). `names`: the encoding of names that aren't UTF-8. pub(super) fn build( data: &[u8], visible: Option<&Document>, @@ -43,10 +43,11 @@ pub(super) fn build( outlined: bool, trust: bool, unit: f64, + names: ai::NameEncoding, ) -> Result<(Document, Vec), String> { // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art // space, as its PDF part may (#2031). - build_from(ai::read(data, matches!(page, Page::Art))?, data, visible, page, outlined, trust, unit) + build_from(ai::read_with(data, matches!(page, Page::Art), names)?, data, visible, page, outlined, trust, unit) } /// [`build`] from `structure`, the editing copy `data` already read. diff --git a/crates/eps/src/import/native/layers.rs b/crates/eps/src/import/native/layers.rs index 6be56c200..66a211b61 100644 --- a/crates/eps/src/import/native/layers.rs +++ b/crates/eps/src/import/native/layers.rs @@ -8,13 +8,14 @@ use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why, with the swatches of the /// editing copy's palette ([`use_palette`]) when it could be read, and with its layer tree, empty, -/// below the page's layer when that could be read (#2033). -pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { +/// below the page's layer when that could be read (#2033). `names`: the encoding of names that +/// aren't UTF-8. +pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool, names: ai::NameEncoding) -> Imported { let Some(data) = editing_copy(ps) else { return visible }; // The layer tree is kept from the structure read, for the page to come in on when the layers // can't be used. let (tree, result) = match data.as_deref() { - Ok(d) => match ai::read(d, true) { + Ok(d) => match ai::read_with(d, true, names) { Ok(s) => (layer_tree(&s.doc.layers), build_from(s, d, Some(&visible.document), Page::Art, false, trust, 1.0)), Err(why) => (vec![], Err(why)), }, @@ -32,7 +33,8 @@ pub(in super::super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Im Err(why) => { let mut v = visible; if let Ok(data) = &data { - use_palette(data, &mut v.document); + let notes = use_palette(data, &mut v.document, names); + add_notes(&mut v.warnings, notes); } if tree.is_empty() { v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer")); @@ -87,8 +89,21 @@ fn layer_tree(nodes: &[Arc]) -> Vec> { /// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`, /// by which its art came in scaled. Editing data whose artboard matches the page only in those /// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from -/// it. +/// it. Names that aren't UTF-8 are read as Windows-1252 ([`layered_ai_with`]). pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outlined: bool, trust: bool, unit: f64) -> (Document, Vec, bool) { + layered_ai_with(private, visible, warnings, outlined, trust, unit, ai::NameEncoding::default()) +} + +/// [`layered_ai`], with the names that aren't UTF-8 read in `names`. +pub fn layered_ai_with( + private: &[u8], + visible: Document, + warnings: Vec, + outlined: bool, + trust: bool, + unit: f64, + names: ai::NameEncoding, +) -> (Document, Vec, bool) { let unread = |mut all: Vec, why: &str| { all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part")); all @@ -99,19 +114,25 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl }; if !has_layers(&data) { let mut visible = visible; - use_palette(&data, &mut visible); + let mut warnings = warnings; + add_notes(&mut warnings, use_palette(&data, &mut visible, names)); return (visible, warnings, false); } // The PDF part, its pages placed and named as the editing data's artboards are, when the // layers can't be used (`why`). let from_pdf_part = |mut visible: Document, warnings: Vec, why: &str| { - place_pages(&ai::artboards(&data), &mut visible, unit); - use_palette(&data, &mut visible); + let (boards, board_notes) = ai::artboards(&data, names); + let named = place_pages(&boards, &mut visible, unit); + let palette_notes = use_palette(&data, &mut visible, names); let mut all = unread(warnings, why); all.extend(ai::variables(&data).note()); + if named { + add_notes(&mut all, board_notes); + } + add_notes(&mut all, palette_notes); (visible, all, false) }; - match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { + match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit, names) { // An image too large for the editing data's reader would be missing from the layers, while // the PDF part has it: the file comes in as its PDF part instead (#1479). Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { @@ -132,9 +153,10 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec, outl /// document read from the layers. The spot and pattern swatches of the PDF part's art that the /// palette doesn't have follow the palette's swatches. The patterns that the editing data defines and /// the PDF part lacks come in for the palette's pattern swatches, with the images they show. Left as -/// it is when the palette is empty. -fn use_palette(data: &[u8], doc: &mut Document) { - let Some(ai::PaletteSwatches { swatches, groups, patterns, images }) = ai::palette(data, &doc.patterns) else { return }; +/// it is when the palette is empty. `names`: the encoding of names that aren't UTF-8; the notes on +/// those the swatches take are returned. +fn use_palette(data: &[u8], doc: &mut Document, names: ai::NameEncoding) -> Vec { + let Some(ai::PaletteSwatches { swatches, groups, patterns, images, notes }) = ai::palette(data, &doc.patterns, names) else { return vec![] }; for (key, blob) in images { doc.images.entry(key).or_insert(blob); } @@ -148,6 +170,16 @@ fn use_palette(data: &[u8], doc: &mut Document) { let mut swatches = swatches; swatches.extend(art); (doc.swatches, doc.swatch_groups) = (swatches.into(), groups.into()); + notes +} + +/// `notes` added to `warnings`, those it already has left out. +fn add_notes(warnings: &mut Vec, notes: Vec) { + for n in notes { + if !warnings.contains(&n) { + warnings.push(n); + } + } } /// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as @@ -155,10 +187,10 @@ fn use_palette(data: &[u8], doc: &mut Document) { /// each, to where they are, so the artboards keep their names and layout when the layers can't be /// read (#1068). Left as they are unless there are as many artboards as pages, each the size of its /// page in points, or, when `unit` is above 1, each the size of its page in units of `unit` points: -/// then the layout is drawn `unit` times as large. -fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) { +/// then the layout is drawn `unit` times as large. Whether the pages took the artboards' names. +fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) -> bool { if boards.is_empty() || boards.len() != doc.artboards.len() { - return; + return false; } let differ = |k: f64| { boards @@ -171,12 +203,12 @@ fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) { } else if unit.is_finite() && unit > 1.0 && !differ(unit) { unit } else { - return; + return false; }; for ((name, _), a) in boards.iter().zip(&mut doc.artboards) { a.name.clone_from(name); } - let [(_, first), _, ..] = boards else { return }; + let [(_, first), _, ..] = boards else { return true }; // Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it, // `k` points a unit. let to_doc = Affine::new([k, 0.0, 0.0, -k, -first.x0 * k, first.y1 * k]); @@ -188,6 +220,7 @@ fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) { for (a, v) in doc.artboards.iter_mut().zip(&moves) { a.rect = a.rect + *v; } + true } /// The objects in `layer` (and its sublayers) moved by the move of the page they are on. @@ -211,9 +244,15 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) { /// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy /// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes. +/// Names that aren't UTF-8 are read as Windows-1252 ([`ai_alone_with`]). pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec), String> { + ai_alone_with(private, ai::NameEncoding::default()) +} + +/// [`ai_alone`], with the names that aren't UTF-8 read in `names`. +pub fn ai_alone_with(private: &[u8], names: ai::NameEncoding) -> Result<(Document, Vec), String> { let data = ai::decode_private(private)?; - build(&data, None, Page::Artboard, false, false, 1.0) + build(&data, None, Page::Artboard, false, false, 1.0, names) } /// Does the editing data (or a program in the legacy format) have layers? @@ -236,15 +275,16 @@ fn editing_copy(ps: &[u8]) -> Option, String>> { /// but whose program has no layers (Illustrator 3 had none; image editors still export their /// paths so, storytold/photocraft#6036) is read with its art on one layer. `None` when the program /// has no layers and isn't in the format: other PostScript isn't read as the format's operators. -pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool) -> Option), String>> { +/// `names`: the encoding of names that aren't UTF-8. +pub(in super::super) fn program_alone(ps: &[u8], illustrator: bool, names: ai::NameEncoding) -> Option), String>> { if ai::has_eps_data(ps) { return None; } if has_layers(ps) { - return Some(build(ps, None, Page::Art, false, false, 1.0)); + return Some(build(ps, None, Page::Art, false, false, 1.0, names)); } let program = one_layer(ps).filter(|_| illustrator)?; - Some(build(&program, None, Page::Art, false, false, 1.0)) + Some(build(&program, None, Page::Art, false, false, 1.0, names)) } /// `ps` with its art (from its `%%EndSetup` to its `%%PageTrailer`, else its `%%Trailer`, else its diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 94127e8cf..0fee624fa 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1051,6 +1051,25 @@ fn an_ai_file_that_comes_in_as_its_pdf_part_keeps_the_swatches_of_its_palette() assert!(d.swatch("Red ink").is_some_and(|s| s.spot)); } +/// Such a file's palette names that aren't UTF-8 are read in the encoding picked, with the notes on +/// them; a layer name that isn't UTF-8, in layers that aren't used, gets none. +#[test] +fn an_ai_file_that_comes_in_as_its_pdf_part_notes_its_palette_names_in_a_legacy_encoding() { + let image = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] 6000 6000 0 Xh\n\ + [ 1 0 0 1 0 100 ] 0 0 6000 6000 6000 6000 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN"; + // 빨강 (red) in code page 949, for the group and its ink; 그라 for the layer. + let palette = PALETTE.replace("Inks", r"\273\241\260\255").replace("Red ink", r"\273\241\260\255 ink"); + let art = native(&format!("{palette}{}", layer(r"\261\327\266\363", true, &format!("{}\n{image}", square(10, 10))))); + let (d, warnings, read) = crate::layered_ai_with(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0, crate::NameEncoding::Korean); + assert!(!read, "the PDF part is used: {warnings:?}"); + let groups: Vec<&str> = d.swatch_groups.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(groups, ["빨강"]); + assert!(d.swatch("빨강 ink").is_some_and(|s| s.spot)); + let notes: Vec<&String> = warnings.iter().filter(|w| w.contains("isn't UTF-8")).collect(); + assert_eq!(notes.len(), 2, "{warnings:?}"); + assert!(notes.iter().all(|n| n.contains("windows-949") && !n.contains("그라")), "{notes:?}"); +} + /// A `.ai` pattern library (a palette of pattern swatches and no layers) whose PDF part defines no /// pattern gets the patterns its editing data defines with the palette's pattern swatches. #[test] diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 2b828e093..3f55ac220 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -37,7 +37,10 @@ mod ps; mod scene; mod tiff; -pub use import::{Imported, POSTSCRIPT_ERRORS, ai_alone, family_style, import, import_trusting, import_with, is_loss, layered_ai, page}; +pub use import::{ + Imported, NameEncoding, POSTSCRIPT_ERRORS, ai_alone, ai_alone_with, family_style, import, import_names, import_trusting, import_with, is_loss, + layered_ai, layered_ai_with, page, +}; pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai}; pub use print::{PrintJob, PrintPage, print}; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f3f6cbfbb..85412e9b6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4444,6 +4444,9 @@ axis Both И двете @msg its objects are nested too deeply обектите му са вложени твърде дълбоко @msg its section ending with `%{end}` doesn't end разделът му, завършващ на `%{end}`, няма край @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet обектите с множество запълвания или щрихове, ефекти или четки се появиха във вид на изрисувания си изглед ({_1}): тяхната поява все още не подлежи на редактиране +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding името `{name}` не е в UTF-8 и е прочетено като {label}: ако изглежда грешно, отворете файла с друго legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding името `{name}` не е в UTF-8, а части от него не са валидни и в {label}: отворете файла с друго legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) още имена, които не са в UTF-8, са прочетени като {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиентът „{name}“ не е дефиниран във файла: заменен е с обикновен цвят @msg an image VectorCraft can't read was left out изображение, което VectorCraft не може да разчете, беше пропуснато @msg an image whose data is cut short was left out изображение с непълни данни беше пропуснато diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d7efa9c0e..36b4c7773 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -4846,6 +4846,9 @@ imageTrace Outline Obris @msg its objects are nested too deeply njeni objekti su previše duboko ugniježđeni @msg its section ending with `%{end}` doesn't end njen odjeljak koji završava s `%{end}` ne završava @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekti s više ispuna ili poteza, efekata ili četkica stigli su kao njihov iscrtani izgled ({_1}): njihov izgled se još ne može uređivati +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding naziv `{name}` nije u UTF-8 i pročitan je kao {label}: ako izgleda pogrešno, otvorite datoteku s drugim legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding naziv `{name}` nije u UTF-8, a dijelovi nisu ispravni ni u {label}: otvorite datoteku s drugim legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) još naziva koji nisu u UTF-8 pročitano je kao {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradijent „{name}” nije definisan u datoteci: umjesto njega koristi se obična boja @msg an image VectorCraft can't read was left out izostavljena je slika koju VectorCraft ne može pročitati @msg an image whose data is cut short was left out izostavljena je slika čiji su podaci skraćeni diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cd6172d6a..4b380f0d6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5267,6 +5267,9 @@ digits Malayalam Malaiàlam @msg objects can't be nested more than 32 deep els objectes no es poden niar més de 32 nivells @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path els objectes dins d’una fusió, un embolcall, una repetició o un grup Intertwine no es poden adjuntar a un traçat @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet els objectes amb diversos farciments o traços, efectes o pinzells han arribat amb el seu aspecte dibuixat ({_1}): la seva aparença encara no és editable +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding el nom `{name}` no és UTF-8 i s’ha llegit com a {label}: si sembla incorrecte, obriu el fitxer amb un altre legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding el nom `{name}` no és UTF-8, i algunes parts tampoc no són {label} vàlid: obriu el fitxer amb un altre legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) s’han llegit com a {label} més noms que no són UTF-8 ({count}) @msg Offset Path: select paths Desplaça el traçat: selecciona traçats @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Desplaça el traçat: el desplaçament no deixa res dels traçats seleccionats (una retracció més gran que les formes) @msg only a dock group above or below others collapses, not one beside another només es plega un grup d’acoblament situat a sobre o a sota d’altres, no un al costat d’un altre diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 34c7f4e03..bbad58f34 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4886,6 +4886,9 @@ imageTrace Outline Obrys @msg its objects are nested too deeply jeho objekty jsou vnořené příliš hluboko @msg its section ending with `%{end}` doesn't end jeho oddíl končící `%{end}` nemá konec @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekty s více výplněmi nebo tahy, efekty či štětci byly načteny jako vykreslený vzhled ({_1}): jejich vzhled zatím nelze upravovat +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding název `{name}` není v UTF-8 a byl načten jako {label}: pokud vypadá špatně, otevřete soubor s jiným legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding název `{name}` není v UTF-8 a jeho části nejsou platné ani v {label}: otevřete soubor s jiným legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) další názvy, které nejsou v UTF-8, byly načteny jako {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it přechod „{name}“ není v souboru definován: zastupuje ho plná barva @msg an image VectorCraft can't read was left out obrázek, který VectorCraft neumí přečíst, byl vynechán @msg an image whose data is cut short was left out obrázek s useknutými daty byl vynechán diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 5a2391342..83cf8b913 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4538,6 +4538,9 @@ imageTrace Outline Kontur @msg its objects are nested too deeply dens objekter er indlejret for dybt @msg its section ending with `%{end}` doesn't end dens sektion, der ender med `%{end}`, ender ikke @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekter med flere fyld eller streger, effekter eller pensler kom ind, som de er tegnet ({_1}): deres udseende er endnu ikke redigerbart +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding navnet `{name}` er ikke UTF-8 og blev læst som {label}: hvis det ser forkert ud, så åbn filen med en anden legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding navnet `{name}` er ikke UTF-8, og dele af det er heller ikke gyldig {label}: åbn filen med en anden legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) flere navne, der ikke er UTF-8, blev læst som {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradienten »{name}« er ikke defineret i filen: der bruges en flad farve i stedet @msg an image VectorCraft can't read was left out et billede, som VectorCraft ikke kan læse, blev udeladt @msg an image whose data is cut short was left out et billede, hvis data er afkortet, blev udeladt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 101bbefac..6b710bbdb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4495,6 +4495,9 @@ theme Appearance Erscheinungsbild @msg its objects are nested too deeply seine Objekte sind zu tief verschachtelt @msg its section ending with `%{end}` doesn't end sein Abschnitt, der mit `%{end}` enden soll, endet nicht @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet Objekte mit mehreren Flächen oder Konturen, Effekten oder Pinseln wurden so übernommen, wie sie gezeichnet aussehen ({_1}): Ihr Aussehen ist noch nicht bearbeitbar +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding Der Name `{name}` ist kein UTF-8 und wurde als {label} gelesen: Wenn er falsch aussieht, öffnen Sie die Datei mit einer anderen legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding Der Name `{name}` ist kein UTF-8, und Teile davon sind auch kein gültiges {label}: Öffnen Sie die Datei mit einer anderen legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) Weitere Namen, die kein UTF-8 sind, wurden als {label} gelesen ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it der Verlauf „{name}“ ist in der Datei nicht definiert: Eine einfache Farbe steht an seiner Stelle @msg an image VectorCraft can't read was left out ein Bild, das VectorCraft nicht lesen kann, wurde ausgelassen @msg an image whose data is cut short was left out ein Bild mit abgeschnittenen Daten wurde ausgelassen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e704faade..26ce306f8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4528,6 +4528,9 @@ digits Malayalam Μαλαγιάλαμ @msg its objects are nested too deeply τα αντικείμενά του έχουν υπερβολικά βαθιά ένθεση @msg its section ending with `%{end}` doesn't end η ενότητά του που τελειώνει σε `%{end}` δεν τερματίζεται @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet τα αντικείμενα με πολλαπλά γεμίσματα, περιγράμματα, εφέ ή πινέλα εισήχθησαν ως απεικονίσεις ({_1}): η εμφάνισή τους δεν είναι ακόμη επεξεργάσιμη +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding το όνομα `{name}` δεν είναι UTF-8 και διαβάστηκε ως {label}: αν φαίνεται λάθος, ανοίξτε το αρχείο με άλλο legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding το όνομα `{name}` δεν είναι UTF-8 και τμήματά του δεν είναι έγκυρα ούτε σε {label}: ανοίξτε το αρχείο με άλλο legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) περισσότερα ονόματα που δεν είναι UTF-8 διαβάστηκαν ως {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it το ντεγκραντέ «{name}» δεν ορίζεται στο αρχείο: χρησιμοποιείται στη θέση του ένα συμπαγές χρώμα @msg an image VectorCraft can't read was left out παραλείφθηκε εικόνα που δεν μπορεί να διαβάσει το VectorCraft @msg an image whose data is cut short was left out παραλείφθηκε εικόνα με περικομμένα δεδομένα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4ebddbe9a..3e483ce9f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4580,6 +4580,9 @@ axis Both Ambos @msg its objects are nested too deeply sus objetos están anidados a demasiada profundidad @msg its section ending with `%{end}` doesn't end su sección que termina con `%{end}` no termina @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet los objetos con varios rellenos o trazos, efectos o pinceles se importaron con su aspecto dibujado ({_1}): su aspecto aún no se puede editar +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding el nombre `{name}` no está en UTF-8 y se leyó como {label}: si no se ve bien, abre el archivo con otro legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding el nombre `{name}` no está en UTF-8 y partes de él tampoco son {label} válido: abre el archivo con otro legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) se leyeron como {label} más nombres que no están en UTF-8 ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it el degradado “{name}” no está definido en el archivo: lo sustituye un color sólido @msg an image VectorCraft can't read was left out se omitió una imagen que VectorCraft no puede leer @msg an image whose data is cut short was left out se omitió una imagen con datos incompletos diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7042b21f2..296ba6e25 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4788,6 +4788,9 @@ imageTrace Outline Ääriviiva @msg its objects are nested too deeply sen objektit ovat liian syvällä sisäkkäin @msg its section ending with `%{end}` doesn't end sen `%{end}`-merkintään päättyvä osio ei pääty @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objektit, joilla on useita täyttöjä tai viivoja, tehosteita tai siveltimiä, tuotiin piirretyn näköisinä ({_1}): niiden ulkoasua ei voi vielä muokata +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nimi `{name}` ei ole UTF-8:aa, ja se luettiin koodauksella {label}: jos se näyttää väärältä, avaa tiedosto toisella legacyNameEncoding-arvolla +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nimi `{name}` ei ole UTF-8:aa, eivätkä kaikki sen osat ole kelvollista {label}-koodausta: avaa tiedosto toisella legacyNameEncoding-arvolla +@msg more names that aren't UTF-8 were read as {label} ({count}) muita nimiä, jotka eivät ole UTF-8:aa, luettiin koodauksella {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it liukuväriä “{name}” ei ole määritetty tiedostossa: sen tilalla on tasaväri @msg an image VectorCraft can't read was left out kuva, jota VectorCraft ei voi lukea, jätettiin pois @msg an image whose data is cut short was left out kuva, jonka data on katkennut, jätettiin pois diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 48eb621eb..c70cfd228 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4570,6 +4570,9 @@ axis Both Les deux @msg its objects are nested too deeply leurs objets sont imbriqués trop profondément @msg its section ending with `%{end}` doesn't end leur section qui se termine par `%{end}` ne se termine pas @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet les objets à plusieurs fonds ou contours, effets ou formes ont été importés tels qu’ils sont dessinés ({_1}) : leur aspect n’est pas encore modifiable +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding le nom `{name}` n’est pas en UTF-8 et a été lu en {label} : s’il semble incorrect, ouvrez le fichier avec un autre legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding le nom `{name}` n’est pas en UTF-8, et certaines parties ne sont pas non plus du {label} valide : ouvrez le fichier avec un autre legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) d’autres noms qui ne sont pas en UTF-8 ont été lus en {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it le dégradé « {name} » n’est pas défini dans le fichier : une couleur unie le remplace @msg an image VectorCraft can't read was left out une image que VectorCraft ne peut pas lire a été ignorée @msg an image whose data is cut short was left out une image aux données tronquées a été ignorée diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 81f948263..b62e5755f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4973,6 +4973,9 @@ imageTrace Outline Körvonal @msg object {id} is not a path az objektum nem útvonal: {id} @msg object {id} is not a selected top-level object az objektum nem kijelölt legfelső szintű objektum: {id} @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet a több kitöltésű vagy körvonalú, effektusos vagy ecsetes objektumok a megrajzolt kinézetükkel kerültek be ({_1}): a megjelenésük még nem szerkeszthető +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding a(z) `{name}` név nem UTF-8, ezért {label} kódolással lett beolvasva: ha hibásnak tűnik, nyissa meg a fájlt másik legacyNameEncoding értékkel +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding a(z) `{name}` név nem UTF-8, és egyes részei {label} kódolásban sem érvényesek: nyissa meg a fájlt másik legacyNameEncoding értékkel +@msg more names that aren't UTF-8 were read as {label} ({count}) további nem UTF-8 nevek lettek {label} kódolással beolvasva ({count}) @msg Offset Path: select paths Útvonal eltolása: jelölj ki útvonalakat @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Útvonal eltolása: az eltolás után semmi sem marad a kijelölt útvonalakból (a befelé eltolás nagyobb az alakzatoknál) @msg onto must name a floating panel az onto egy lebegő panelt kell megnevezzen diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 89633ae99..157abb7b0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4538,6 +4538,9 @@ imageTrace Outline Kontur @msg its objects are nested too deeply objeknya bersarang terlalu dalam @msg its section ending with `%{end}` doesn't end bagian yang berakhir dengan `%{end}` tidak berakhir @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objek dengan beberapa isian atau goresan, efek, atau kuas masuk sebagai tampilan gambarnya ({_1}): penampilannya belum dapat diedit +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nama `{name}` bukan UTF-8 dan dibaca sebagai {label}: jika terlihat salah, buka file dengan legacyNameEncoding lain +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nama `{name}` bukan UTF-8, dan sebagiannya juga bukan {label} yang valid: buka file dengan legacyNameEncoding lain +@msg more names that aren't UTF-8 were read as {label} ({count}) nama lain yang bukan UTF-8 dibaca sebagai {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradien “{name}” tidak didefinisikan dalam berkas: warna polos dipakai sebagai pengganti @msg an image VectorCraft can't read was left out gambar yang tidak dapat dibaca VectorCraft tidak disertakan @msg an image whose data is cut short was left out gambar yang datanya terpotong tidak disertakan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6acfaf3c9..213570c25 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4621,6 +4621,9 @@ axis Both Entrambi @msg its objects are nested too deeply i loro oggetti sono annidati troppo in profondità @msg its section ending with `%{end}` doesn't end la loro sezione che termina con `%{end}` non termina @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet gli oggetti con più riempimenti o tratti, effetti o pennelli sono stati importati come appaiono disegnati ({_1}): il loro aspetto non è ancora modificabile +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding il nome `{name}` non è in UTF-8 ed è stato letto come {label}: se sembra errato, apri il file con un altro legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding il nome `{name}` non è in UTF-8 e alcune sue parti non sono valide nemmeno in {label}: apri il file con un altro legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) altri nomi non in UTF-8 sono stati letti come {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it la sfumatura “{name}” non è definita nel file: la sostituisce un colore pieno @msg an image VectorCraft can't read was left out un'immagine che VectorCraft non può leggere è stata omessa @msg an image whose data is cut short was left out un'immagine con dati incompleti è stata omessa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4bf3cd6bd..9c66309b1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4328,6 +4328,9 @@ swatch Bright Violet ビビッドバイオレット @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 表示に使われるフォントの 1 行に対して低すぎるフレーム内のエリア内文字は、フレームを高くしました ({_1}): そうしないと文字が表示されません @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it グラデーション「{name}」はファイル内で定義されていません: 代わりに単色を使用しています @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 塗りや線が複数ある、または効果やブラシを適用したオブジェクト {_1} 個は見た目のまま読み込まれました: アピアランスはまだ編集できません +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名前 `{name}` は UTF-8 ではないため {label} として読み込みました: 正しくない場合は別の legacyNameEncoding でファイルを開いてください +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名前 `{name}` は UTF-8 ではなく、一部は {label} としても無効です: 別の legacyNameEncoding でファイルを開いてください +@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8 ではないほかの名前を {label} として読み込みました ({count}) @msg patterns nested too deeply are filled with mid-grey 入れ子が深すぎるパターンは中間のグレーで塗りつぶされています @msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました @msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index cf411b072..72f7a3068 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4325,6 +4325,9 @@ font-technology Variable ცვლადი @msg its objects are nested too deeply მისი ობიექტები ზედმეტად ღრმადაა ჩადგმული @msg its section ending with `%{end}` doesn't end მისი სექცია, რომელიც `%{end}`-ით მთავრდება, არ სრულდება @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet რამდენიმე შევსების ან კონტურის, ეფექტის ან ფუნჯის მქონე ობიექტები შემოვიდა დახატული სახით ({_1}): მათი იერსახე ჯერ არ რედაქტირდება +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding სახელი `{name}` არ არის UTF-8 და წაკითხულია როგორც {label}: თუ არასწორად გამოიყურება, გახსენით ფაილი სხვა legacyNameEncoding-ით +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding სახელი `{name}` არ არის UTF-8 და მისი ნაწილები არც {label}-ში არის სწორი: გახსენით ფაილი სხვა legacyNameEncoding-ით +@msg more names that aren't UTF-8 were read as {label} ({count}) სხვა სახელები, რომლებიც არ არის UTF-8, წაკითხულია როგორც {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it გრადიენტი „{name}“ ფაილში განსაზღვრული არ არის: მის ნაცვლად ერთგვაროვანი ფერია @msg an image VectorCraft can't read was left out გამოსახულება, რომელსაც VectorCraft ვერ კითხულობს, გამოტოვებულია @msg an image whose data is cut short was left out გამოსახულება, რომლის მონაცემებიც შეკვეცილია, გამოტოვებულია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 81f6c600c..d10c6611f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4255,6 +4255,9 @@ theme Appearance 외관 @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise 표시되는 글꼴의 한 줄보다 낮은 프레임의 영역 문자는 프레임을 더 높였습니다({_1}). 그렇지 않으면 문자가 표시되지 않습니다 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 그레이디언트 “{name}”이(가) 파일에 정의되어 있지 않아 단색으로 대체했습니다 @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 칠이나 선이 여러 개인 개체, 효과, 브러시가 있는 개체는 그려진 모양({_1})으로 가져왔습니다: 모양새는 아직 편집할 수 없습니다 +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 이름 `{name}`은(는) UTF-8이 아니어서 {label}(으)로 읽었습니다: 잘못 보이면 다른 legacyNameEncoding으로 파일을 여세요 +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 이름 `{name}`은(는) UTF-8이 아니고 일부는 {label}에서도 올바르지 않습니다: 다른 legacyNameEncoding으로 파일을 여세요 +@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8이 아닌 다른 이름도 {label}(으)로 읽었습니다({count}) @msg patterns nested too deeply are filled with mid-grey 너무 깊게 중첩된 패턴은 중간 회색으로 채웁니다 @msg shadings of an unknown type were left out 알 수 없는 종류의 셰이딩은 제외했습니다 @msg shadings whose functions are of an unknown type are filled with their middle colour 함수 종류를 알 수 없는 셰이딩은 중간 색상으로 채웁니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 344853683..53bcb2100 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4550,6 +4550,9 @@ imageTrace Outline Contour @msg its objects are nested too deeply de objecten ervan zijn te diep genest @msg its section ending with `%{end}` doesn't end zijn sectie die op `%{end}` eindigt, eindigt niet @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objecten met meerdere vullingen of lijnen, effecten of penselen zijn als hun getekende uiterlijk binnengekomen ({_1}): hun vormgeving is nog niet bewerkbaar +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding de naam `{name}` is geen UTF-8 en is gelezen als {label}: als die er verkeerd uitziet, open het bestand dan met een andere legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding de naam `{name}` is geen UTF-8, en delen ervan zijn ook geen geldige {label}: open het bestand met een andere legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) meer namen die geen UTF-8 zijn, zijn gelezen als {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it verloop „{name}” is niet gedefinieerd in het bestand: er is een effen kleur voor gebruikt @msg an image VectorCraft can't read was left out een afbeelding die VectorCraft niet kan lezen, is weggelaten @msg an image whose data is cut short was left out een afbeelding waarvan de gegevens zijn afgekapt, is weggelaten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 14825f83c..d32a38eb9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4712,6 +4712,9 @@ imageTrace Outline Kontur @msg its objects are nested too deeply jego obiekty są zagnieżdżone zbyt głęboko @msg its section ending with `%{end}` doesn't end jego sekcja kończąca się na `%{end}` nie ma końca @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet obiekty z kilkoma wypełnieniami lub obrysami, efektami albo pędzlami wczytano jako ich narysowany wygląd ({_1}): ich wyglądu nie można jeszcze edytować +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding nazwa `{name}` nie jest w UTF-8 i została odczytana jako {label}: jeśli wygląda źle, otwórz plik z innym legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding nazwa `{name}` nie jest w UTF-8, a jej części nie są poprawne również w {label}: otwórz plik z innym legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) kolejne nazwy spoza UTF-8 odczytano jako {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradient „{name}” nie jest zdefiniowany w pliku: zastąpiono go jednolitym kolorem @msg an image VectorCraft can't read was left out pominięto obraz, którego VectorCraft nie potrafi odczytać @msg an image whose data is cut short was left out pominięto obraz z uciętymi danymi diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 96a31c3e5..895100d1b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4151,6 +4151,9 @@ axis Both Ambos @msg its objects are nested too deeply seus objetos estão aninhados com profundidade excessiva @msg its section ending with `%{end}` doesn't end sua seção que termina com `%{end}` não termina @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objetos com vários preenchimentos ou traços, efeitos ou pincéis entraram com a aparência desenhada ({_1}): sua aparência ainda não é editável +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding o nome `{name}` não está em UTF-8 e foi lido como {label}: se parecer errado, abra o arquivo com outro legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding o nome `{name}` não está em UTF-8, e partes dele também não são {label} válido: abra o arquivo com outro legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) mais nomes que não estão em UTF-8 foram lidos como {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it o gradiente “{name}” não está definido no arquivo: uma cor sólida o substitui @msg an image VectorCraft can't read was left out uma imagem que o VectorCraft não consegue ler foi omitida @msg an image whose data is cut short was left out uma imagem com dados incompletos foi omitida diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fdb51b80d..d168bba96 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4616,6 +4616,9 @@ axis Both Оба @msg its objects are nested too deeply объекты в них вложены слишком глубоко @msg its section ending with `%{end}` doesn't end раздел, заканчивающийся на `%{end}`, не заканчивается @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet объекты с несколькими заливками или обводками, эффектами или кистями импортированы в нарисованном виде ({_1}): их оформление пока нельзя редактировать +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding имя `{name}` не в UTF-8 и прочитано как {label}: если оно выглядит неверно, откройте файл с другим legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding имя `{name}` не в UTF-8, и его части недопустимы и в {label}: откройте файл с другим legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) другие имена не в UTF-8 прочитаны как {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градиент «{name}» не определён в файле: вместо него используется сплошной цвет @msg an image VectorCraft can't read was left out изображение, которое VectorCraft не может прочитать, пропущено @msg an image whose data is cut short was left out изображение с обрезанными данными пропущено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e2dddf022..00561a5f7 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4479,6 +4479,9 @@ axis Both Båda @msg its objects are nested too deeply dess objekt är för djupt kapslade @msg its section ending with `%{end}` doesn't end dess avsnitt som slutar med `%{end}` avslutas aldrig @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet objekt med flera fyllningar eller konturer, effekter eller penslar importerades som sitt ritade utseende ({_1}): deras utseende går inte att redigera än +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding namnet `{name}` är inte UTF-8 och lästes som {label}: om det ser fel ut, öppna filen med en annan legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding namnet `{name}` är inte UTF-8, och delar av det är inte heller giltig {label}: öppna filen med en annan legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) fler namn som inte är UTF-8 lästes som {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it gradienten ”{name}” är inte definierad i filen: en enfärgad fyllning används i stället @msg an image VectorCraft can't read was left out en bild som VectorCraft inte kan läsa utelämnades @msg an image whose data is cut short was left out en bild med avkortade data utelämnades diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 7e5ca938c..25fe28806 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1736,6 +1736,28 @@ fn saved_tracing_preset_errors_are_translated() { } } +/// The notes on names in a `.ai`'s editing data that aren't UTF-8 are translated in every language +/// that translates every message, keeping the name, the encoding and the param. +#[test] +fn legacy_name_notes_are_translated() { + let notes = [ + "the name `·¹À̾î 1` isn't UTF-8 and was read as windows-1252: if it looks wrong, open the file with another legacyNameEncoding", + "the name `레\u{fffd} 1` isn't UTF-8, and parts of it aren't valid windows-949 either: open the file with another legacyNameEncoding", + "more names that aren't UTF-8 were read as macintosh (7)", + ]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for note in notes { + let shown = message(lang, note); + assert_ne!(shown, note, "{code}"); + assert!(shown.contains("UTF-8"), "{code}: {shown}"); + } + let shown = message(lang, notes[0]); + assert!(shown.contains("`·¹À̾î 1`") && shown.contains("windows-1252") && shown.contains("legacyNameEncoding"), "{code}: {shown}"); + assert!(message(lang, notes[2]).contains("macintosh") && message(lang, notes[2]).contains('7'), "{code}"); + } +} + /// The status bar's line for an EPS file whose PostScript stops on an error, opened partly or /// refused, and the refusal's dialog keep the error's name as PostScript spells it and its meaning, /// which each language shows as its catalog translates the meaning. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9ecf2ccf9..966c7652c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4322,6 +4322,9 @@ axis Both Her İkisi @msg its objects are nested too deeply nesneleri çok derin bir şekilde iç içe geçmiş @msg its section ending with `%{end}` doesn't end `%{end}` ile biten bölümü bitmiyor @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet çeşitli dolgulara veya konturlara, efektlere veya fırçalara sahip nesneler çizilmiş görünümleri olarak geldi ({_1}): görünümleri henüz düzenlenemez +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding `{name}` adı UTF-8 değil ve {label} olarak okundu: yanlış görünüyorsa dosyayı başka bir legacyNameEncoding ile açın +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding `{name}` adı UTF-8 değil ve bazı bölümleri {label} olarak da geçerli değil: dosyayı başka bir legacyNameEncoding ile açın +@msg more names that aren't UTF-8 were read as {label} ({count}) UTF-8 olmayan diğer adlar {label} olarak okundu ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it “{name}” degradesi dosyada tanımlı değil: onun yerine düz bir renk geliyor @msg an image VectorCraft can't read was left out VectorCraft'ın okuyamadığı bir resim dışarıda bırakıldı @msg an image whose data is cut short was left out verileri kısa kesilen bir resim dışarıda bırakıldı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d348440a2..7bfe37d28 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4605,6 +4605,9 @@ axis Both Обидві @msg its objects are nested too deeply об’єкти в них вкладені надто глибоко @msg its section ending with `%{end}` doesn't end розділ, що закінчується на `%{end}`, не закінчується @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet об’єкти з кількома заливками або обведеннями, ефектами або пензлями імпортовані в намальованому вигляді ({_1}): їх оформлення поки що не можна редагувати +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding назва `{name}` не в UTF-8 і прочитана як {label}: якщо вона виглядає неправильно, відкрийте файл з іншим legacyNameEncoding +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding назва `{name}` не в UTF-8, і її частини недійсні також у {label}: відкрийте файл з іншим legacyNameEncoding +@msg more names that aren't UTF-8 were read as {label} ({count}) інші назви не в UTF-8 прочитано як {label} ({count}) @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it градієнт «{name}» не визначено у файлі: замість нього використовується суцільний колір @msg an image VectorCraft can't read was left out зображення, яке VectorCraft не може прочитати, пропущено @msg an image whose data is cut short was left out зображення з обрізаними даними пропущено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 09652ede2..b4f9d3690 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4627,6 +4627,9 @@ theme Appearance Giao diện @msg object {id} is not a path đối tượng {id} không phải là đường dẫn @msg object {id} is not a selected top-level object đối tượng {id} không phải là đối tượng cấp cao nhất đã chọn @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet đối tượng có nhiều tô hoặc nét, hiệu ứng hoặc cọ đã vào dưới dạng bề ngoài ({_1}): diện mạo chưa thể chỉnh sửa +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding tên `{name}` không phải UTF-8 và đã được đọc theo {label}: nếu trông sai, hãy mở tệp với legacyNameEncoding khác +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding tên `{name}` không phải UTF-8, và một số phần cũng không hợp lệ trong {label}: hãy mở tệp với legacyNameEncoding khác +@msg more names that aren't UTF-8 were read as {label} ({count}) thêm các tên không phải UTF-8 đã được đọc theo {label} ({count}) @msg Offset Path: select paths Đường dẫn độ lệch: hãy chọn đường dẫn @msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Đường dẫn độ lệch: độ lệch không để lại gì từ các đường dẫn đã chọn (độ lệch vào trong lớn hơn hình) @msg onto must name a floating panel onto phải đặt tên bảng đang thả nổi diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 841bd4528..863aefcab 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4051,6 +4051,9 @@ imageTrace Outline 轮廓 @msg an image whose data is cut short was left out 已略去数据不完整的图像 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 文件中未定义渐变“{name}”:已改用纯色代替 @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 带有多个填色或描边、效果或画笔的对象已按其绘制外观导入 ({_1}):其外观尚不可编辑 +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名称 `{name}` 不是 UTF-8,已按 {label} 读取:如果显示不正确,请使用其他 legacyNameEncoding 打开文件 +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名称 `{name}` 不是 UTF-8,且部分内容在 {label} 中也无效:请使用其他 legacyNameEncoding 打开文件 +@msg more names that aren't UTF-8 were read as {label} ({count}) 其他非 UTF-8 名称已按 {label} 读取 ({count}) @msg patterns nested too deeply are filled with mid-grey 嵌套过深的图案以中灰色填充 @msg shadings of an unknown type were left out 已略去未知类型的着色 @msg shadings whose functions are of an unknown type are filled with their middle colour 函数类型未知的着色以其中间颜色填充 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a515d59a7..5bdc3a1a7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1974,6 +1974,9 @@ file-filter Templates 範本 @msg an image whose data is cut short was left out 已略過資料不完整的影像 @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it 檔案中未定義漸層「{name}」:已改用單色替代 @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet 具有多個填色或筆畫、效果或筆刷的物件已依其繪製外觀匯入 ({_1}):其外觀目前尚無法編輯 +@msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding 名稱 `{name}` 不是 UTF-8,已依 {label} 讀取:若顯示不正確,請以其他 legacyNameEncoding 開啟檔案 +@msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding 名稱 `{name}` 不是 UTF-8,且部分內容在 {label} 中也無效:請以其他 legacyNameEncoding 開啟檔案 +@msg more names that aren't UTF-8 were read as {label} ({count}) 其他非 UTF-8 名稱已依 {label} 讀取 ({count}) @msg patterns nested too deeply are filled with mid-grey 巢狀層級過深的圖樣以中灰色填滿 @msg shadings of an unknown type were left out 已略過類型未知的著色 @msg shadings whose functions are of an unknown type are filled with their middle colour 函數類型未知的著色以其中間顏色填滿 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index de7e1cfce..ae8ab3341 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -84,7 +84,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data -the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches) are read as Windows-1252 unless `legacyNameEncoding` (`convert --legacy-name-encoding`) picks `macintosh`, `windows-949`, `shift_jis`, `gbk` or `big5`; the open result's warnings list the first three such names as read and count the rest. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. ## Format by format From 552205941717d295f529fcf8fd10b7af1d933d47 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 16:51:46 -0800 Subject: [PATCH 2/8] Save: the save commands and Export for Screens refuse an unknown option key document.save, file.saveAs, file.saveCopy and file.saveAsTemplate wrote the file when given a key that no save format reads, beside the path or in options, or an unknown key inside a PDF option object such as marks.useDocumentBleed. document.exportForScreens did the same for an unknown top-level key, a key in a format row and a key in the settings of a format. These commands now refuse such a key and name its path (options.noSuchOption, formats[1].marks.useDocumentBleed). The svg object is checked in every save, export and format row, also when the file written is not SVG. A save still leaves out a key that only another save format reads, and warns as before. A format row and a format's settings still take includeArtboardFill and richBlack, which the encoders read. document.exportPresets.save checks its settings, with the rows and settings in it, the same way. In the app, file.save and file.exportForScreens sent as commands are checked as document.save and document.exportForScreens, and app.save is checked before anything is written; MCP's app.save, file.save and file.exportForScreens are checked the same way. The SVG Options dialog no longer passes its internal fields on to the save. --- crates/engine/src/cmd/fileio.rs | 9 +- crates/engine/src/cmd/fileio/commands.rs | 4 +- crates/engine/src/cmd/fileio/encode.rs | 1 + crates/engine/src/cmd/fileio/save.rs | 2 +- crates/engine/src/cmd/fileio/screens.rs | 50 ++++++- crates/engine/src/cmd/params.rs | 165 +++++++++++++++++----- crates/engine/src/tests/params.rs | 156 ++++++++++++++++++++ crates/engine/src/tests/save.rs | 2 +- crates/engine/tests/it/param_keys.rs | 21 +++ crates/mcp/src/headless.rs | 8 +- crates/mcp/src/tests/exportas.rs | 32 +++++ crates/ui-egui/src/control.rs | 7 +- crates/ui-egui/src/dialogs/svg_options.rs | 3 +- crates/ui-egui/src/menus/run.rs | 6 + crates/ui-egui/src/tests/automation.rs | 34 +++++ docs/control-protocol.md | 2 +- docs/mcp.md | 31 +++- docs/target-app-parity.md | 1 + 18 files changed, 475 insertions(+), 59 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 0c888d13c..b3dab1d12 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -83,16 +83,17 @@ pub use save::{ remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; +pub(crate) use screens::{ + PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, + check_settings as check_screen_settings, export as export_screens, safe_file_name, store_settings as store_screen_settings, + with_export_preset as with_screen_export_preset, +}; pub use screens::{ PRESET_FILE_EXTS as SCREEN_EXPORT_PRESET_EXTS, PRESET_FILE_FORMAT as SCREEN_EXPORT_PRESET_FORMAT, PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, SHARED_KEYS as SCREEN_SETTINGS_KEYS, ScreenExport, ScreenExportPreset, ScreenSize, builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_list as screen_export_preset_list, preset_rows as screen_preset_rows, }; -pub(crate) use screens::{ - check_settings as check_screen_settings, export as export_screens, safe_file_name, store_settings as store_screen_settings, - with_export_preset as with_screen_export_preset, -}; pub use svg::options_map as svg_options; /// Atomic file writes (a temporary file renamed over the target: a failed write never damages the /// file it replaces), for the apps' own writers too. diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index d698c3aad..7914fc846 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,