From 23dc6ac4594ad4844df19142e2d708137ff952f3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:06:21 -0800 Subject: [PATCH 01/24] i18n: Russian translates 1,811 more status and error messages The Russian catalog had no row for 1,811 of the status and error messages that the untranslated report finds in the sources, so the status bar showed those messages in English. - ru.tsv gains 1,811 `@msg` rows: the errors of command parameters, the script interpreter's errors, and the notes and errors of file import and export. The translations are VectorCraft's own, written from the English meaning with the feature names the catalog already uses. Placeholders, parameter names, values and command ids stay as the English writes them. - Rows for messages that open pull requests add to ru.tsv are left to those pull requests. - Two of the messages have two placeholders in a row. The status bar cannot match such a row, so it does not use those two rows. - The row for `{k} has control characters{_1}` does not match the message for a multi-line File Info field, where the last placeholder is empty. - A test reads nine of the messages back in Russian. - The Russian row of docs/localization-parity.md gives the catalog's current counts. Docs: docs/target-app-parity.md, docs/localization-parity.md. --- crates/ui-egui/src/i18n/ru.tsv | 1820 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 21 + docs/localization-parity.md | 2 +- docs/target-app-parity.md | 1 + 4 files changed, 1843 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f878461da5..13b597880e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7577,3 +7577,1823 @@ shape Open Открытый Grommets Люверсы Grommet Inset Отступ люверсов от края Diameter Диаметр + +# Status and error messages (`@msg`) of commands, scripts, and file import and export, sorted by +# their English text. Messages that begin with a feature name. +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions 'caller', 'callee' и 'arguments' нельзя использовать с функциями строгого режима +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. Профиль поддерживает один эффект «Вращение». Измените параметры существующего эффекта. +@msg Add Points at Crossings: a path went missing Добавить точки на пересечениях: контур пропал +@msg Alignment must be one of the choices Выравнивание должно быть одним из предложенных вариантов +@msg Boolean.prototype.{name} was called on a value that is not a Boolean Boolean.prototype.{name} вызван для значения, которое не является Boolean +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB изображения CMYK, отсчёты которых не удалось прочитать в CMYK, импортированы в RGB +@msg CSS options: {e} Параметры CSS: {e} +@msg CSV has a header but no data rows в CSV есть заголовок, но нет строк данных +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark CSV содержит байты NUL: сохраните его в UTF-8 или в UTF-16 с меткой порядка байтов +@msg CSV is empty CSV пуст +@msg CSV is larger than {MAX_CSV_BYTES} bytes CSV больше {MAX_CSV_BYTES} байт +@msg CSV is not UTF-16 CSV не в кодировке UTF-16 +@msg CSV is not UTF-8 CSV не в кодировке UTF-8 +@msg CSV would be larger than {MAX_CSV_BYTES} bytes CSV был бы больше {MAX_CSV_BYTES} байт +@msg Cannot write to the location of “{name}”; choose where to save it Невозможно записать в расположение «{name}»; выберите, куда сохранить файл +@msg Choose original artwork, not a dimension's generated paths Выберите исходную графику, а не контуры, созданные размером +@msg Contour: select paths or shapes Эквидистанта: выделите контуры или фигуры +@msg Contour: the Offset Path effect is not available Эквидистанта: эффект «Смещение контура» недоступен +@msg Create needs fills, strokes or both Для создания нужны заливки, обводки или и то и другое +@msg DCS files can only be placed linked: use File › Place with Link on Файлы DCS можно помещать только как связанные: используйте «Файл › Поместить» с включённым параметром «Связать» +@msg DXF options: {e} Параметры DXF: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} Масштаб DXF должен быть положительным числом не больше {_1}, а не {scale} +@msg DXF {field} `{s}`: {allowed} DXF, {field} `{s}`: {allowed} +@msg Date.prototype method called on a value that is not a Date object метод Date.prototype вызван для значения, которое не является объектом Date +@msg Dimension is locked or hidden Размер заблокирован или скрыт +@msg Dimension mode does not match its source Режим размера не соответствует его источнику +@msg Draw Inside needs exactly one selected path, compound path or text Для режима «Рисовать внутри» нужен ровно один выделенный контур, составной контур или текст +@msg EPS Save writes all artwork: use document.export for selected-only EPS Сохранение в EPS записывает всю графику: чтобы записать в EPS только выделенное, используйте document.export +@msg EPS flattener: {e} Объединение EPS: {e} +@msg EPS options: {e} Параметры EPS: {e} +@msg EPS {field} `{s}`: {allowed} EPS, {field} `{s}`: {allowed} +@msg Error.prototype.toString was called on a value that is not an object Error.prototype.toString вызван для значения, которое не является объектом +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft «Экспортировать для экранов» записывает png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai или vectorcraft +@msg Flare click coordinates must be finite numbers Координаты щелчка для блика должны быть конечными числами +@msg Function.prototype.toString was called on a value that is not a function Function.prototype.toString вызван для значения, которое не является функцией +@msg HTML Text applies to object slices of type only «Текст HTML» применяется только к фрагментам текстовых объектов +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) Для параметра «Определять объекты по» выбрано XML ID: имя должно быть допустимым XML ID (начинается с буквы, подчёркивания или двоеточия; без пробелов) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) Трассировка изображения не выполнена: внутренняя ошибка: её поток остановился (сообщите об этой ошибке) +@msg Image Trace failed: {e} Трассировка изображения не выполнена: {e} +@msg Image Trace finished, but its document was closed Трассировка изображения завершена, но её документ был закрыт +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Трассировка изображения завершена, но изображение или его настройки за это время изменились: выполните трассировку снова +@msg Import SVG Filter reads SVG files, not SVGZ Импорт фильтра SVG читает файлы SVG, а не SVGZ +@msg Invalid dimension offset Недопустимое смещение размера +@msg Invalid point Недопустимая точка +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (смещение {at}) +@msg JSON.stringify cannot write a structure that contains itself JSON.stringify не может записать структуру, которая содержит саму себя +@msg Join: a subpath went missing Соединить: вложенный контур пропал +@msg Line Gap must be a number Интервал между строками должен быть числом +@msg Lock Workspace is not available in VectorCraft Закрепление рабочей среды недоступно в VectorCraft +@msg Move Panel to New Window is not available: floating panels stay in the main window Перемещение панели в новое окно недоступно: плавающие панели остаются в главном окне +@msg Namespace.prototype.toString needs a Namespace для Namespace.prototype.toString нужен Namespace +@msg New Document profiles need the preferences folder, which this run doesn't have Профилям нового документа нужна папка настроек, которой нет у этого запуска +@msg Number.prototype.{name} was called on a value that is not a number Number.prototype.{name} вызван для значения, которое не является числом +@msg Object.create's prototype must be an object or null прототип для Object.create должен быть объектом или null +@msg Object.{function} was given a value that is not an object в Object.{function} передано значение, которое не является объектом +@msg Offset Path: the path is too complex to offset Смещение контура: контур слишком сложен для смещения +@msg Offset Path: {e} Смещение контура: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported Содержимое PDF неполно или превышает ограничения чтения; документ не импортирован +@msg PDF flattener: {e} Объединение PDF: {e} +@msg PDF options: {e} Параметры PDF: {e} +@msg QName.prototype.toString needs a QName для QName.prototype.toString нужен QName +@msg Recolor Artwork is no longer open Окно «Перекрасить рисунок» больше не открыто +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object RegExp.prototype.{name} вызван для значения, которое не является объектом RegExp +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG не в кодировке UTF-8, UTF-16 или ISO 8859-1 +@msg Save writes one artboard: name one, or export several with document.export Сохранение записывает одну монтажную область: укажите одну или экспортируйте несколько через document.export +@msg Select a dimension first Сначала выделите размер +@msg Select dimensions to update Выделите размеры для обновления +@msg Select only dimensions Выделите только размеры +@msg Select two or more objects, or a group Выделите два или более объекта либо группу +@msg Shaper needs 1–64 source paths Для Shaper нужно от 1 до 64 исходных контуров +@msg Stencil Bridges: the selected art is too large or too detailed to bridge Мостики трафарета: выделенная графика слишком велика или слишком детальна для мостиков +@msg String.prototype.toString was called on a value that is not a string String.prototype.toString вызван для значения, которое не является строкой +@msg Switch x/y is for scatter graphs «Поменять x/y» применяется к точечным диаграммам +@msg Text options: {e} Параметры текста: {e} +@msg The {name} profile could not be read, so the new document has the default settings: {reason} Профиль {name} не удалось прочитать, поэтому у нового документа настройки по умолчанию: {reason} +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time Треппинг: больше {_1} пар объектов расположены достаточно близко для треппинга, или слишком много объектов занимают один горизонтальный диапазон; применяйте треппинг к меньшему числу объектов за раз +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off Рамка и линии выборки: ни одна линия выборки не помещается за пределами графики, а рамка выборки отключена +@msg \\ at end of pattern \\ в конце шаблона +# Messages that begin with a parameter name. +@msg `alignOn` cannot be empty `alignOn` не может быть пустым +@msg `alignOn` must be a string `alignOn` должно быть строкой +@msg `align` is start, center or end `align` принимает start, center или end +@msg `align` must be a string `align` должно быть строкой +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` должно быть массивом пар [subpath, anchor] +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` должно содержать индексы опорных точек (0, 1, 2…), а не {k} +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` должно содержать индексы опорных точек (0, 1, 2…), а не {v} +@msg `answer` for this {kind} dialog must be one of: {_1} `answer` для этого диалогового окна {kind} должно быть одним из: {_1} +@msg `answers` has more than {MAX_ANSWERS} answers в `answers` больше {MAX_ANSWERS} ответов +@msg `answers` is larger than 1 MiB `answers` больше 1 MiB +@msg `answers` must be a list of answers `answers` должно быть списком ответов +@msg `artboard` must be the index of an artboard of the document `artboard` должно быть индексом монтажной области документа +@msg `artboardsAt` must be [x, y] `artboardsAt` должно быть [x, y] +@msg `artboards` must be a list of artboard indices `artboards` должно быть списком индексов монтажных областей +@msg `artboards` names an artboard the document does not have `artboards` указывает монтажную область, которой нет в документе +@msg `artboards` names an artboard twice `artboards` указывает одну монтажную область дважды +@msg `asset` is the asset id (see assets.list) `asset` — идентификатор ресурса (см. assets.list) +@msg `at` must be [x, y] (got {at}) `at` должно быть [x, y] (получено {at}) +@msg `at` must be a byte offset `at` должно быть смещением в байтах +@msg `at` must be a point [x, y] `at` должно быть точкой [x, y] +@msg `autoLeading` must be a number `autoLeading` должно быть числом +@msg `background` must be "", "matte" or #rrggbb `background` должно быть "", "matte" или #rrggbb +@msg `beside` needs `side` для `beside` нужно `side` +@msg `burasagari` must be "none", "standard" or "forced" `burasagari` должно быть "none", "standard" или "forced" +@msg `center` must be [x, y] `center` должно быть [x, y] +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` должно быть "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" или "icfBottom" +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection` должно быть "default", "leftToRight" или "rightToLeft" +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` для `clearStroke` нужны `stroke: false`, `ids: []` и отсутствие `stop` +@msg `clear` must be a list of type object ids, not {v} `clear` должно быть списком идентификаторов текстовых объектов, а не {v} +@msg `clear` must list type objects; {_1} is not one `clear` должно содержать текстовые объекты; {_1} не является текстовым объектом +@msg `clear` must not list `id` `clear` не должно содержать `id` +@msg `code` must be a whole number `code` должно быть целым числом +@msg `colorMode` must be one of rgb, cmyk `colorMode` должно быть одним из: rgb, cmyk +@msg `color` needs a `rule` to make the theme from для `color` нужно `rule`, по которому создаётся тема +@msg `colors` does not apply to the {name} reduction `colors` не применяется к редукции {name} +@msg `colors` is empty `colors` пусто +@msg `colors` must be a whole number from 2 to 256 `colors` должно быть целым числом от 2 до 256 +@msg `colors` must be at least 9 for the uniform reduction `colors` должно быть не меньше 9 для редукции uniform +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors` должно быть от 1 до {MAX_THEME_COLORS} (получено {n}) +@msg `columnType` is vertical, uniform, repeating or sliding `columnType` принимает vertical, uniform, repeating или sliding +@msg `columns` must be a whole number 1..50 `columns` должно быть целым числом 1..50 +@msg `commands` must not be empty `commands` не должно быть пустым +@msg `contents` goes with `to` `contents` указывается вместе с `to` +@msg `corners` must be sharp, round or square, not `{other}` `corners` должно быть sharp, round или square, а не `{other}` +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners` должно содержать индексы опорных точек (0, 1, 2…), а не {v} +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count` должно быть целым числом от 0 до {MAX_COUNT} +@msg `count` must be a whole number from 1 to 100 `count` должно быть целым числом от 1 до 100 +@msg `dataBase64` is larger than the module limit `dataBase64` больше ограничения для модуля +@msg `dataBase64` is not valid base64 `dataBase64` не является допустимым base64 +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly` нельзя сочетать с `ids`, `reverse` или `append` +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` и `childLimit` ограничивают сводку: передайте `summary: true` +@msg `design` is a design name, or null for plain columns `design` — имя дизайна или null для обычных столбцов +@msg `design` is a design name, or null for the default marker `design` — имя дизайна или null для маркера по умолчанию +@msg `destinationFolder` is required when destination is `folder` `destinationFolder` обязательно, когда destination равно `folder` +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination` должно быть `none`, `saveAndClose` или `folder` +@msg `diameter` must be from 0.1 to 2500 pt `diameter` должно быть от 0.1 до 2500 pt +@msg `digits` must be one of {_1} `digits` должно быть одним из: {_1} +@msg `dimImages` is a percentage or false `dimImages` — процент или false +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` должно быть "auto", "leftToRight" или "rightToLeft" +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount` должно быть числом от 0 до 100 +@msg `each` is the value one design stands for, more than 0 `each` — значение, которое обозначает один дизайн, больше 0 +@msg `end` must be "start" or "end" `end` должно быть "start" или "end" +@msg `ends` applies to joining exactly two paths `ends` применяется при соединении ровно двух контуров +@msg `event` must be one of {_1} `event` должно быть одним из: {_1} +@msg `every` must be "all", "odd" or "even", not {v} `every` должно быть "all", "odd" или "even", а не {v} +@msg `expand` must be true or false `expand` должно быть true или false +@msg `fidelity` must be from {lo} to {hi} pt `fidelity` должно быть от {lo} до {hi} pt +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` должно содержать от 1 до 1000 путей к файлам шрифтов, которые нашёл последний поиск text.findFontFiles +@msg `focal` and `radius` cannot be used together `focal` и `radius` нельзя использовать вместе +@msg `focal` must be an array of [subpath, anchor] pairs `focal` должно быть массивом пар [subpath, anchor] +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` указывает [{si}, {ai}], а это не опорная точка контура +@msg `folder` must be an absolute folder path `folder` должно быть абсолютным путём к папке +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` должно быть объектом из тегов осей и значений, например {"wght"} +@msg `format` must be a string `format` должно быть строкой +@msg `format` must be one of {_1} `format` должно быть одним из: {_1} +@msg `fractions` is chop or scale `fractions` принимает chop или scale +@msg `from` and `to` must differ `from` и `to` должны различаться +@msg `from` has an empty frame у `from` пустая рамка +@msg `from` must be a non-negative integer `from` должно быть неотрицательным целым числом +@msg `from` must be area type or type on a path `from` должно быть текстом в области или текстом по контуру +@msg `gid` must be a glyph id, not {v} `gid` должно быть идентификатором глифа, а не {v} +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` относится к одному символу, а не к стилю символов (его задаёт text.setGlyphAlternate) +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate` принимает только null, что отменяет выбор глифа (выбор делает text.setGlyphAlternate) +@msg `group` must be one of {_1} `group` должно быть одним из: {_1} +@msg `group` must be the Objects on Path group of `member` and `target` `group` должно быть группой «Объекты на контуре», в которую входят `member` и `target` +@msg `guide` must be a guide index (guide.list) `guide` должно быть индексом направляющей (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign` должно быть default, left, center или right +@msg `id` is an object id, not {v} `id` — идентификатор объекта, а не {v} +@msg `id` must be a node id `id` должно быть идентификатором узла +@msg `id` must be an object id, not {v} `id` должно быть идентификатором объекта, а не {v} +@msg `ids` must name Objects on Path groups or objects in them `ids` должно указывать группы «Объекты на контуре» или объекты в них +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` принимает не более {MAX_INSPECT_IDS} идентификаторов +@msg `imageMap` must be none, rectangle or polygon `imageMap` должно быть none, rectangle или polygon +@msg `includeArtboardFill` must be true or false `includeArtboardFill` должно быть true или false +@msg `includeSubdirs` must be true or false `includeSubdirs` должно быть true или false +@msg `index` must be a point index `index` должно быть индексом точки +@msg `index` must be a whole number or null `index` должно быть целым числом или null +@msg `indices` must be a non-empty array of item indices `indices` должно быть непустым массивом индексов элементов +@msg `item` needs `index` для `item` нужно `index` +@msg `items` is a list of {kind, item} `items` — список {kind, item} +@msg `items` is empty `items` пусто +@msg `items` must be a list of {id, points} `items` должно быть списком {id, points} +@msg `items` must be a list of {{id, anchors?}}, not {v} `items` должно быть списком {{id, anchors?}}, а не {v} +@msg `items` must be an array of {id, anchors} `items` должно быть массивом {id, anchors} +@msg `items` must be an array of {{id, corners?}}, not {v} `items` должно быть массивом {{id, corners?}}, а не {v} +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` должно быть "none", "short", "medium", "long" или "stylistic" +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore` относится к первому символу строки, а не к стилю символов (его задаёт text.setRangeStyle) +@msg `kind` is one of {_1} `kind` — одно из: {_1} +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` должно быть "round", "invertedRound" или "chamfer", а не {v} +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind` должно быть `text`, `visibility`, `image` или `graph` +@msg `kind` must be a string `kind` должно быть строкой +@msg `kind` must be image, noImage or htmlText `kind` должно быть image, noImage или htmlText +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType` должно быть "pushInFirst", "pushOutFirst" или "pushOutOnly" +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` должно быть "none", "hard" или "soft" +@msg `label` must be a non-empty name of at most 200 characters `label` должно быть непустым именем длиной не более 200 символов +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` должно быть названием или тегом языка, например "Turkish" или "tr" +@msg `leader` must be a string `leader` должно быть строкой +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` должно быть "romanBaseline" или "emBoxTop" +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` должно быть от 0.1 до 5000 или null для значения «Авто» +@msg `length` must be a number of at most 1000000 pt either way `length` должно быть числом не больше 1000000 pt в любую сторону +@msg `line` must be true or false `line` должно быть true или false +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max` должно быть от 1 до {MAX_SUGGESTIONS} +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) не может быть больше `maxAngle` ({max_angle}°) +@msg `mode` must be "handles", "straight" or "smooth" `mode` должно быть "handles", "straight" или "smooth" +@msg `mode` must be "straight", "bezier" or "arc" `mode` должно быть "straight", "bezier" или "arc" +@msg `mode` must be one of {_1} `mode` должно быть одним из: {_1} +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` должно быть "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" или "lineEndHalf" +@msg `name` must be a name that is not empty `name` должно быть непустым именем +@msg `name` must not be empty `name` не должно быть пустым +@msg `names` is a list of preset names `names` — список имён наборов +@msg `names` lists preset names `names` перечисляет имена наборов +@msg `names` must be a list of names `names` должно быть списком имён +@msg `names` must be a list of swatch names `names` должно быть списком имён образцов +@msg `names` must be an array of style names `names` должно быть массивом имён стилей +@msg `new` is a design name, or true `new` — имя дизайна или true +@msg `origin` must be [x, y] in points `origin` должно быть [x, y] в пунктах +@msg `path` must be a folder `path` должно быть папкой +@msg `path` must be a path object `path` должно быть объектом-контуром +@msg `picks` must be an array (got {v}) `picks` должно быть массивом (получено {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` должно содержать от 1 до {MAX_PICKS} выборов (получено {_1}) +@msg `pivot` must be a whole number from 0 to 8 `pivot` должно быть целым числом от 0 до 8 +@msg `points` must be a list of [x, y] points `points` должно быть списком точек [x, y] +@msg `points` must be a list of point indexes `points` должно быть списком индексов точек +@msg `points` must be an array of point indices `points` должно быть массивом индексов точек +@msg `points` must be point indexes `points` должно содержать индексы точек +@msg `points` must hold from 2 to 100000 points `points` должно содержать от 2 до 100000 точек +@msg `port` must be "in" or "out" `port` должно быть "in" или "out" +@msg `position` is above or below `position` принимает above или below +@msg `preset` must be a string `preset` должно быть строкой +@msg `radius` must be 0 or more `radius` должно быть 0 или больше +@msg `ratio` must be from 0.1 to 0.5 `ratio` должно быть от 0.1 до 0.5 +@msg `rect` must be [x, y, width, height] `rect` должно быть [x, y, width, height] +@msg `rect` must be [x, y, width, height] with a positive size `rect` должно быть [x, y, width, height] с положительным размером +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` должно быть [x0, y0, x1, y1] (получено {r}) +@msg `relation` must be overlap, inside or outside `relation` должно быть overlap, inside или outside +@msg `replace` must be a string `replace` должно быть строкой +@msg `reversed` must be true or false `reversed` должно быть true или false +@msg `rotate` must be a finite number `rotate` должно быть конечным числом +@msg `rows` must be lists of numbers or nulls, not {r} `rows` должно состоять из списков чисел или значений null, а не {r} +@msg `rule` must be nonZero or evenOdd `rule` должно быть nonZero или evenOdd +@msg `scale` must be a number from 0.001 to 1000 `scale` должно быть числом от 0.001 до 1000 +@msg `scale` must be a number in (0, 100] `scale` должно быть числом в диапазоне (0, 100] +@msg `scope.{key}` must be a non-empty array `scope.{key}` должно быть непустым массивом +@msg `scope` must be "document", {artboards} or {layers} `scope` должно быть "document", {artboards} или {layers} +@msg `scope` must be "selection" or "document", not {other} `scope` должно быть "selection" или "document", а не {other} +@msg `script` must be a string `script` должно быть строкой +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` — непустой список индексов рядов +@msg `seriesIndexes` needs a `type` or a `valueAxis` для `seriesIndexes` нужно `type` или `valueAxis` +@msg `shift` must be a number of points `shift` должно быть числом в пунктах +@msg `showCenter` must be true or false `showCenter` должно быть true или false +@msg `side` must be "left", "right", "top" or "bottom" `side` должно быть "left", "right", "top" или "bottom" +@msg `side` places the artboard: leave out x, y, width and height `side` определяет положение монтажной области: не указывайте x, y, width и height +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` должно быть "fullJustify", "alignLeft", "alignCenter" или "alignRight" +@msg `size` is a percentage of the base text's size, 1 to 100 `size` — процент от размера основного текста, от 1 до 100 +@msg `source` and `target` are the same object `source` и `target` — один и тот же объект +@msg `source` is the only path in series group `target` `source` — единственный контур в группе серии `target` +@msg `source` must be `folder` or `datasets` `source` должно быть `folder` или `datasets` +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing` даёт больше {MAX_COUNT} линий поперёк рамки +@msg `spacing` must be a number `spacing` должно быть числом +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` должно быть числом в пунктах, 0 или больше, либо "auto" +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing` должно быть больше 0 и не больше {MAX_DISTANCE} pt +@msg `start` must be a finite number `start` должно быть конечным числом +@msg `starts` must be an array of anchor indices (or nulls) `starts` должно быть массивом индексов опорных точек (или значений null) +@msg `status` must be linked, embedded, missing or modified, or a list of them `status` должно быть linked, embedded, missing или modified либо их списком +@msg `stop` must be a stop index `stop` должно быть индексом точки перехода +@msg `story` styles the whole story: give no `start` or `end` `story` задаёт стиль всего материала: не указывайте `start` и `end` +@msg `story` takes the whole story: give no `start` or `end` `story` охватывает весь материал: не указывайте `start` и `end` +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` применяется к обводкам (stroke: true) +@msg `style` must be roman or japanese `style` должно быть roman или japanese +@msg `subpaths` must be two integer indices `subpaths` должно быть двумя целочисленными индексами +@msg `symbol` must be a symbol name `symbol` должно быть именем символа +@msg `target` must be "object" or "contents", not {v} `target` должно быть "object" или "contents", а не {v} +@msg `target` must be an object of the same Objects on Path group as `member` `target` должно быть объектом той же группы «Объекты на контуре», что и `member` +@msg `tint` applies to global and spot colours `tint` применяется к глобальным и плашечным цветам +@msg `tint` goes with a global or spot `swatch` `tint` указывается вместе с глобальным или плашечным `swatch` +@msg `tint` must be a number (%) `tint` должно быть числом (%) +@msg `tint` needs a global or spot colour для `tint` нужен глобальный или плашечный цвет +@msg `to` is already threaded `to` уже входит в связанный текст +@msg `to` is the design's new name `to` — новое имя дизайна +@msg `to` must be [x, y] `to` должно быть [x, y] +@msg `to` must be a closed path, area type or type on a path other than `from` `to` должно быть замкнутым контуром, текстом в области или текстом по контуру, отличным от `from` +@msg `to` must be a whole number `to` должно быть целым числом +@msg `to` takes the rows of one object's stack, not aligned rows `to` принимает строки стопки одного объекта, а не выровненные строки +@msg `tolerance` must be from 0 to 100 pt `tolerance` должно быть от 0 до 100 pt +@msg `useLab` must be true or false `useLab` должно быть true или false +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` должно быть default, top, middle, baseline или bottom +@msg `values` must be a list `values` должно быть списком +@msg `values` must be an object `values` должно быть объектом +@msg `values` must name hex, rgb, cmyk, lab or gray `values` должно указывать hex, rgb, cmyk, lab или gray +@msg `values` names a kind twice `values` указывает один вид дважды +@msg `version` must be 0 or {_1} `version` должно быть 0 или {_1} +@msg `version` must be 0 or {newest} `version` должно быть 0 или {newest} +@msg `warichuAlign` must be one of {_1} `warichuAlign` должно быть одним из: {_1} +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines` должно быть целым числом от {_1} до {_2} +@msg `weight` must be from 0.01 to 1000 pt `weight` должно быть от 0.01 до 1000 pt +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights` должно быть списком чисел по одному на строку, всего чисел: {_1} (получено {v}) +@msg `xml` must be a string `xml` должно быть строкой +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}`, запись {n}: задаёт абзацу с байта {start} объекта {_1} другие отступы, чем предыдущая запись +@msg `{KEY}` entry {n} must be an object `{KEY}`, запись {n}: должна быть объектом +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}`, запись {n}: не задаёт ни `leftIndent`, ни `firstLineIndent` +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}`, запись {n}: `id` должно быть идентификатором объекта +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}`, запись {n}: `start` должно быть смещением в байтах +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}`, запись {n}: `{k}` должно быть числом от -{MAX_PARA} до {MAX_PARA} pt +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}`, запись {n}: ни один абзац объекта {_1} не начинается с байта {start} +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}`, запись {n}: неизвестный ключ `{k}` +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}`, запись {n}: {_1} не является текстовым объектом +@msg `{KEY}` is empty `{KEY}` пусто +@msg `{KEY}` must be a list `{KEY}` должно быть списком +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` принимает не более {MAX_PARAGRAPH_INDENTS} записей (получено {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}` не допускает `{other}` рядом с собой +@msg `{_1}` can't be edited or removed `{_1}` нельзя изменить или удалить +@msg `{_1}` is built in and always listed `{_1}` встроен и всегда есть в списке +@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` не является файлом шрифта, который нашёл последний поиск text.findFontFiles +@msg `{_1}` would be overwritten by the package: choose a different folder or name пакет перезаписал бы `{_1}`: выберите другую папку или имя +@msg `{base}` is larger than {_1} MB `{base}` больше {_1} МБ +@msg `{base}` is not in the DCS file's folder `{base}` нет в папке файла DCS +@msg `{file}` has no art to place в `{file}` нет графики для помещения +@msg `{file}` is over {_1} MB `{file}` больше {_1} МБ +@msg `{f}` belongs to a VectorCraft that is running `{f}` принадлежит запущенному экземпляру VectorCraft +@msg `{f}` is the copy of a document open now `{f}` — копия документа, открытого сейчас +@msg `{id}` is no writable format (see document.formats) `{id}` не является форматом для записи (см. document.formats) +@msg `{id}` isn't a loaded library `{id}` не является загруженной библиотекой +@msg `{key}` has no colours в `{key}` нет цветов +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` содержит больше {MAX_EDIT_WORDS} слов +@msg `{key}` is a design name `{key}` — имя дизайна +@msg `{key}` is a design name or a list of them `{key}` — имя дизайна или список таких имён +@msg `{key}` is longer than {MAX_NAME} characters `{key}` длиннее {MAX_NAME} символов +@msg `{key}` is out of range `{key}` вне диапазона +@msg `{key}` must be 0 or more `{key}` должно быть 0 или больше +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` должно быть [min, desired, max] или {{min?, desired?, max?}}, в процентах +@msg `{key}` must be [x, y] numbers `{key}` должно быть числами [x, y] +@msg `{key}` must be a list of words `{key}` должно быть списком слов +@msg `{key}` must be a list, not {v} `{key}` должно быть списком, а не {v} +@msg `{key}` must be a non-negative integer `{key}` должно быть неотрицательным целым числом +@msg `{key}` must be a number or null, not {v} `{key}` должно быть числом или null, а не {v} +@msg `{key}` must be a number {_1}..{_2} `{key}` должно быть числом {_1}..{_2} +@msg `{key}` must be a whole number of 0 or more `{key}` должно быть целым числом, 0 или больше +@msg `{key}` must be a whole number of at least 0, not {n} `{key}` должно быть целым числом не меньше 0, а не {n} +@msg `{key}` must be an appearance item index (paint order) or null `{key}` должно быть индексом элемента оформления (в порядке отрисовки) или null +@msg `{key}` must be an array of asset ids (see assets.list) `{key}` должно быть массивом идентификаторов ресурсов (см. assets.list) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` должно быть от 0 до 180 градусов, а не {a} +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}` должно быть от 0 до {MAX_DISTANCE} pt +@msg `{key}` must be from 0.01 to 1000 pt `{key}` должно быть от 0.01 до 1000 pt +@msg `{key}` must be from {_1} to {shift_max} `{key}` должно быть от {_1} до {shift_max} +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}` должно быть от {lo} до {hi}, а не {v} +@msg `{key}` must be one of auto, always, never `{key}` должно быть одним из: auto, always, never +@msg `{key}` must be one of miter, round, bevel `{key}` должно быть одним из: miter, round, bevel +@msg `{key}` must be one word of at most {_1} characters `{key}` должно быть одним словом длиной не более {_1} символов +@msg `{key}` must hold single words of at most {_1} characters `{key}` должно содержать отдельные слова длиной не более {_1} символов +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}` должно указывать объект, прикреплённый к контуру группы «Объекты на контуре» +@msg `{key}` names an object that is hidden or locked `{key}` указывает скрытый или заблокированный объект +@msg `{key}`: not a colour: {v} `{key}`: не цвет: {v} +@msg `{k}.family` is required `{k}.family` обязательно +@msg `{k}` is a number 0..1 `{k}` — число 0..1 +@msg `{k}` is true or false `{k}` принимает true или false +@msg `{k}` isn't an export setting: {_1} `{k}` не является параметром экспорта: {_1} +@msg `{k}` must be a byte offset `{k}` должно быть смещением в байтах +@msg `{k}` must be a number `{k}` должно быть числом +@msg `{k}` must be a number of points `{k}` должно быть числом в пунктах +@msg `{k}` must be a string `{k}` должно быть строкой +@msg `{k}` must be a whole number `{k}` должно быть целым числом +@msg `{k}` must be from {lo} to {hi} for {key} `{k}` должно быть от {lo} до {hi} для {key} +@msg `{k}` must be {what}, not {v} `{k}` должно быть {what}, а не {v} +@msg `{l}` isn't a global or spot colour `{l}` не является глобальным или плашечным цветом +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` не является растровым изображением: используйте clipboard.importSvg, importPdf или importText +@msg `{name}` can't be deleted `{name}` нельзя удалить +@msg `{name}` can't be duplicated `{name}` нельзя дублировать +@msg `{name}` can't be edited `{name}` нельзя изменить +@msg `{name}` can't be edited this way `{name}` нельзя изменить таким способом +@msg `{name}` can't be renamed `{name}` нельзя переименовать +@msg `{name}` can't name a folder `{name}` не может быть именем папки +@msg `{name}` has no art to place в `{name}` нет графики для помещения +@msg `{name}` has no text to place в `{name}` нет текста для помещения +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text `{name}` в документе Word не является текстом UTF-8 или UTF-16 +@msg `{name}` in the Word document is not well-formed XML: {e} `{name}` в документе Word не является корректным XML: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep в `{name}` в документе Word элементы вложены глубже {MAX_NESTING} уровней +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` — встроенный набор, доступный только для чтения: сохраните его под другим именем +@msg `{name}` is a built-in preset: save under another name `{name}` — встроенный набор: сохраните под другим именем +@msg `{name}` is a built-in profile and stays `{name}` — встроенный профиль, он не удаляется +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` — отдельный файл, который можно найти только рядом с файлом DCS +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` — оттенок `{base}`: изменяйте `{base}` (образцу оттенка можно задать только новое имя) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` — рабочее пространство: сначала выберите другое через edit.colorSettings +@msg `{name}` is built in and stays `{name}` встроен и не удаляется +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` принимает краску только своего вида (цвет, градиент или узор) +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{n}` can't be deleted `{n}` нельзя удалить +@msg `{n}` can't be merged `{n}` нельзя объединить +@msg `{n}` can't be moved `{n}` нельзя переместить +@msg `{n}` is not a colour swatch `{n}` не является цветовым образцом +@msg `{n}` isn't a solid colour `{n}` не является сплошным цветом +@msg `{n}`: colour groups hold solid colours only `{n}`: цветовые группы содержат только сплошные цвета +@msg `{other}` doesn't trace an image `{other}` не трассирует изображение +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: ссылки шага копируют больше {_1} МБ значений +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` не является тегом оси: тег состоит из четырёх символов, например "wght" +@msg `{to}` is empty or already used `{to}` пусто или уже используется +@msg `{u}` is no Unicode character `{u}` не является символом Unicode +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` длиннее {MAX_TEXT} байт +@msg `{what}` must be text `{what}` должно быть текстом +@msg `{word}` isn't in the custom dictionary `{word}` нет в пользовательском словаре +# Messages that begin with a lowercase word, a to c. +@msg a CMap has too many mappings в CMap слишком много сопоставлений +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard у чертежа DXF одно начало координат: передайте useArtboards: true, чтобы получить по одному чертежу на монтажную область +@msg a PDF presets file with over {_1} presets файл наборов PDF, в котором больше {_1} наборов +@msg a \\u escape needs four hexadecimal digits после \\u нужны четыре шестнадцатеричные цифры +@msg a batch preview cannot switch documents предварительный просмотр пакета не может переключать документы +@msg a brush named `{nn}` exists кисть с именем `{nn}` уже существует +@msg a comma in an XPath expression must separate function arguments запятая в выражении XPath должна разделять аргументы функции +@msg a container of the listed objects is gone контейнер перечисленных объектов больше не существует +@msg a content stream can't be read поток содержимого невозможно прочитать +@msg a content stream isn't a stream поток содержимого не является потоком +@msg a design holds at most {_1} objects дизайн содержит не более {_1} объектов +@msg a design needs a name дизайну нужно имя +@msg a dictionary grew too large словарь стал слишком большим +@msg a document holds at most {_1} SVG filters документ содержит не более {_1} фильтров SVG +@msg a document keeps at most {_1} graph designs документ хранит не более {_1} дизайнов диаграмм +@msg a document keeps at most {_1} saved selections документ хранит не более {_1} сохранённых выделений +@msg a document keeps at most {_1} views документ хранит не более {_1} видов +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files документ связывает не более {MAX_SVG_SCRIPTS} файлов JavaScript +@msg a document with unsaved changes has no recovery copy у документа с несохранёнными изменениями нет копии для восстановления +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes порядок фильтра feConvolveMatrix равен {x}×{y}, что переполняет произведение размеров ядра, которое вычисляет usvg +@msg a font's data is too large данные шрифта слишком велики +@msg a font's subroutines are too large подпрограммы шрифта слишком велики +@msg a function declaration without a name объявление функции без имени +@msg a gradient preset is already called {n} набор градиента с именем {n} уже существует +@msg a graph has at most {_1} series, so it transposes with at most that many categories в диаграмме не более {_1} рядов, поэтому при транспонировании допускается не больше стольких же категорий +@msg a host value has too many entries в значении хоста слишком много записей +@msg a host value is nested too deep значение хоста вложено слишком глубоко +@msg a layer can't be merged into its own sublayer слой нельзя объединить с его собственным подслоем +@msg a layer's form box isn't an array рамка формы слоя не является массивом +@msg a layer's form can't be read форму слоя невозможно прочитать +@msg a layer's form has no box у формы слоя нет рамки +@msg a layer's form has no dictionary у формы слоя нет словаря +@msg a legend draws at most {MAX_CHIPS} swatches легенда рисует не более {MAX_CHIPS} образцов +@msg a name names one asset: give multiple: false, or one object имя обозначает один ресурс: укажите multiple: false или один объект +@msg a namespace declaration uses a reserved prefix or URI объявление пространства имён использует зарезервированный префикс или URI +@msg a namespace qualifier is undefined квалификатор пространства имён не определён +@msg a namespace with an empty URI cannot have a prefix у пространства имён с пустым URI не может быть префикса +@msg a number has no digits в числе нет цифр +@msg a number's exponent has no digits в показателе степени числа нет цифр +@msg a number's fraction has no digits в дробной части числа нет цифр +@msg a page can't be read страницу невозможно прочитать +@msg a page object is used by something else объект страницы используется чем-то ещё +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) миниатюра страницы должна состоять из пикселей RGB, от 1 до {THUMBNAIL_SIZE} по стороне (получено {w} × {h}) +@msg a pattern layer doesn't end what it starts слой узора не завершает то, что начинает +@msg a pattern layer's colors hold something else цвета слоя узора содержат что-то другое +@msg a pattern named `{n}` exists узор с именем `{n}` уже существует +@msg a plug-in with id {_1} is already installed модуль с идентификатором {_1} уже установлен +@msg a preset named `{n}` exists набор с именем `{n}` уже существует +@msg a print presets file with over {_1} presets файл наборов печати, в котором больше {_1} наборов +@msg a profile is already called {n} профиль с именем {n} уже существует +@msg a property descriptor cannot have both a value or writable and a get or set дескриптор свойства не может одновременно содержать value или writable и get или set +@msg a property descriptor must be an object дескриптор свойства должен быть объектом +@msg a property's {name} must be a function or undefined {name} свойства должно быть функцией или undefined +@msg a row can't go into itself строку нельзя поместить в саму себя +@msg a series goes on the left or the right value axis ряд размещается на левой или правой оси значений +@msg a start must be an anchor index ≥ 0 or null начало должно быть индексом опорной точки ≥ 0 или null +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied поток в нём при декодировании занял бы больше 256 МБ, или его параметры декодирования невозможно применить +@msg a string holds a control character строка содержит управляющий символ +@msg a string holds an escape sequence JSON does not have строка содержит escape-последовательность, которой нет в JSON +@msg a string is not closed строка не закрыта +@msg a style named `{name}` already exists стиль с именем `{name}` уже существует +@msg a style named `{to}` already exists стиль с именем `{to}` уже существует +@msg a swatch needs a colour, gradient or pattern образцу нужен цвет, градиент или узор +@msg a symbol named `{n}` already exists символ с именем `{n}` уже существует +@msg a theme has 1 to {THEME_SIZE} colours в теме от 1 до {THEME_SIZE} цветов +@msg a theme is already called {name} (replace: "{name}" overwrites it) тема с именем {name} уже существует (replace: "{name}" перезапишет её) +@msg a toolbar name has at most {MAX_NAME} characters имя панели инструментов содержит не более {MAX_NAME} символов +@msg a view named `{_1}` exists вид с именем `{_1}` уже существует +@msg a view named `{name}` exists вид с именем `{name}` уже существует +@msg a workspace keeps at most {MAX} toolbars рабочая среда хранит не более {MAX} панелей инструментов +@msg a {_1} series can't go in a {_2} graph ряд типа {_1} нельзя поместить в диаграмму типа {_2} +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (совместимый с PDF, по умолчанию) или "7" (устаревший .ai, версия 7) +@msg already editing an opacity mask непрозрачная маска уже редактируется +@msg already editing another object's opacity mask уже редактируется непрозрачная маска другого объекта +@msg already in pattern editing mode режим редактирования узора уже включён +@msg an SVG filter is named by its id: give `xml` with `name` фильтр SVG называется по своему id: укажите `xml` вместе с `name` +@msg an XML element has two attributes with the same name у элемента XML два атрибута с одинаковым именем +@msg an XML value cannot be inserted into itself or its descendants значение XML нельзя вставить в само себя или в его потомков +@msg an XML value's children cannot be assigned by position дочерние элементы значения XML нельзя присваивать по позиции +@msg an XML value's children cannot be deleted by position дочерние элементы значения XML нельзя удалять по позиции +@msg an XMLList converts to XML only when it has one item XMLList преобразуется в XML, только когда содержит один элемент +@msg an XPath node test is missing отсутствует проверка узла XPath +@msg an XPath operand is missing отсутствует операнд XPath +@msg an XPath operator is missing отсутствует оператор XPath +@msg an XPath predicate or step cannot follow here предикат или шаг XPath здесь недопустим +@msg an XPath predicate or step follows a value that is not a node-set предикат или шаг XPath следует за значением, которое не является набором узлов +@msg an XPath step is missing отсутствует шаг XPath +@msg an artboard is listed twice монтажная область указана дважды +@msg an empty artboard has no width or height to scale to у пустой монтажной области нет ширины или высоты для масштабирования +@msg an image couldn't be decoded and is missing from the picture изображение не удалось декодировать, и его нет на рисунке +@msg an image is too large for DXF and was left out изображение слишком велико для DXF и пропущено +@msg an image of {_1} MB, at most {_2} MB изображение размером {_1} МБ при допустимых {_2} МБ +@msg an image of {declared_w} × {declared_h} pixels is too large to draw изображение размером {declared_w} × {declared_h} пикселей слишком велико для отрисовки +@msg an image of {width} × {height} pixels is too large to draw изображение размером {width} × {height} пикселей слишком велико для отрисовки +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) изображение размером {w} × {h} пикселей слишком велико для преобразования (не более {_1} пикселей) +@msg an index must be a whole number индекс должен быть целым числом +@msg an inline graphic has no glyphs у встроенной в строку графики нет глифов +@msg an invisible text form can't be read форму невидимого текста невозможно прочитать +@msg an object is missing объект отсутствует +@msg an object's header is missing отсутствует заголовок объекта +@msg an overprinting form can't be read форму с наложением невозможно прочитать +@msg an unread object has no readable bounds у непрочитанного объекта нет читаемых границ +@msg an unread object holds a frame of threaded type непрочитанный объект содержит рамку связанного текста +@msg an unread object is missing from its layer непрочитанный объект отсутствует в своём слое +@msg anchor `smooth` must be true or false `smooth` опорной точки должно быть true или false +@msg anchor `{key}` must be [x, y] `{key}` опорной точки должно быть [x, y] +@msg anchor `{key}` must be a number `{key}` опорной точки должно быть числом +@msg angle and roundness must be finite angle и roundness должны быть конечными +@msg angles must be a list as long as pins (degrees or null) angles должно быть списком той же длины, что и pins (градусы или null) +@msg appearance must be flat|center|edge appearance должно быть flat|center|edge +@msg apply is "fill" or "stroke", not `{other}` apply принимает "fill" или "stroke", а не `{other}` +@msg apply was called on a value that is not a function apply вызван для значения, которое не является функцией +@msg area must be a closed polygon of 3 to 100000 [x, y] points area должно быть замкнутым многоугольником из 3–100000 точек [x, y] +@msg area type in a thread cannot use Dynamic Text: {frames} текст в области в связанном тексте не может использовать динамический текст: {frames} +@msg area type needs a path that encloses an area тексту в области нужен контур, охватывающий область +@msg area type {_1} is named twice текст в области {_1} указан дважды +@msg array literal too long литерал массива слишком длинный +@msg arrowAlign must be extend|tip, got {o} arrowAlign должно быть extend|tip, получено {o} +@msg arrowScale must be [start, end] arrowScale должно быть [start, end] +@msg artboard is a 0-based index artboard — индекс, отсчитываемый от 0 +@msg artboard must be a 0-based index artboard должно быть индексом, отсчитываемым от 0 +@msg artboard {i} doesn't print монтажная область {i} не печатается +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout должно быть {layout?, columns?, spacing?, rightToLeft?} +@msg artboardLayout.columns must be 1 or more artboardLayout.columns должно быть 1 или больше +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout должно быть gridByRow, gridByColumn, row или column +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft должно быть true или false +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing должно быть 0–{MAX_SPACING} pt +@msg artboards must be a non-empty list artboards должно быть непустым списком +@msg artboards must be a number artboards должно быть числом +@msg asset `{_1}` has no art to export в ресурсе `{_1}` нет графики для экспорта +@msg assignment to a constant binding присваивание константной привязке +@msg at least one row that isn't excluded needs a weight above 0 хотя бы одной неисключённой строке нужен вес больше 0 +@msg at most {MAX_KEYWORDS} keywords не более {MAX_KEYWORDS} ключевых слов +@msg at most {MAX_MATCHES} matches are kept сохраняется не более {MAX_MATCHES} совпадений +@msg at most {MAX_POINTS} points не более {MAX_POINTS} точек +@msg at most {MAX_QUEUE} files не более {MAX_QUEUE} файлов +@msg at must be [x, y] at должно быть [x, y] +@msg at/rect lie outside the canvas at/rect находятся за пределами холста +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute должно быть size|leading|tracking|kerning|baselineShift +@msg attrs must be an object attrs должно быть объектом +@msg automation path rejected: the folder's name isn't valid text путь автоматизации отклонён: имя папки не является допустимым текстом +@msg automation path rejected: {_1} isn't valid text путь автоматизации отклонён: {_1} не является допустимым текстом +@msg automation path rejected: {why}: {path} путь автоматизации отклонён: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis должно быть "vertical", "horizontal" или углом в градусах, а не `{given}` +@msg backgroundContents must be transparent or white backgroundContents должно быть transparent или white +@msg bad AES key недопустимый ключ AES +@msg bad `runs`: {e} недопустимое `runs`: {e} +@msg bad arrowScale недопустимое arrowScale +@msg bad base64 недопустимый base64 +@msg bad color {c} недопустимый цвет {c} +@msg bad colour недопустимый цвет +@msg bad colour in `colors` недопустимый цвет в `colors` +@msg bad colour {v} недопустимый цвет {v} +@msg bad colour {v} in groupColors недопустимый цвет {v} в groupColors +@msg bad cross-reference entry недопустимая запись перекрёстных ссылок +@msg bad cross-reference table недопустимая таблица перекрёстных ссылок +@msg bad current colour {v} недопустимый текущий цвет {v} +@msg bad dataBase64 недопустимое dataBase64 +@msg bad edit недопустимая правка +@msg bad fill {v} недопустимая заливка {v} +@msg bad fit: {e} недопустимое fit: {e} +@msg bad model {other} (keep, new or document) недопустимая модель {other} (keep, new или document) +@msg bad new colour {v} недопустимый новый цвет {v} +@msg bad object недопустимый объект +@msg bad object number недопустимый номер объекта +@msg bad offset недопустимое смещение +@msg bad path data: {e} недопустимые данные контура: {e} +@msg bad point {v}: expected [x, y] недопустимая точка {v}: ожидается [x, y] +@msg bad point {v}: expected [x, y] or [x, y, pressure] недопустимая точка {v}: ожидается [x, y] или [x, y, pressure] +@msg bad reference недопустимая ссылка +@msg bad trailer size недопустимый размер в трейлере +@msg bad {k} colour недопустимый цвет {k} +@msg batches cannot nest пакеты нельзя вкладывать друг в друга +@msg bind something to a variable first сначала привяжите что-нибудь к переменной +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed должно быть 0–{MAX_BLEED} pt: число, [top, bottom, left, right] или {{top, bottom, left, right}} +@msg blend must be a mode name blend должно быть названием режима +@msg both sides of an XPath | must select nodes обе стороны оператора | в XPath должны выбирать узлы +@msg brush must be a name or null brush должно быть именем или null +@msg by must be a number by должно быть числом +@msg can't add page thumbnails: {why} невозможно добавить миниатюры страниц: {why} +@msg can't add the editing data for other apps: {why} невозможно добавить данные редактирования для других приложений: {why} +@msg can't edit the written PDF: {why} невозможно изменить записанный PDF: {why} +@msg can't encrypt the PDF: {why} невозможно зашифровать PDF: {why} +@msg can't linearise the PDF: {why} невозможно линеаризовать PDF: {why} +@msg can't open `{_1}`: {_2} невозможно открыть `{_1}`: {_2} +@msg can't open `{_1}`: {e} невозможно открыть `{_1}`: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) невозможно открыть `{name}`: VectorCraft не читает этот формат (см. document.formats) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) невозможно поместить `{name}`: VectorCraft не читает этот формат (см. document.formats) +@msg can't read back the written PDF: {e} невозможно прочитать записанный PDF: {e} +@msg can't read this Word document: {why} невозможно прочитать этот документ Word: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp невозможно записать изображения .{e}: используйте .png, .jpg, .gif, .webp, .tif или .bmp +@msg can't write PDF layers, overprint or invisible text: {why} невозможно записать слои PDF, наложение или невидимый текст: {why} +@msg can't write the file's copyright: {why} невозможно записать сведения об авторских правах файла: {why} +@msg can't write the file's modification date: {why} невозможно записать дату изменения файла: {why} +@msg can't write the pages' user units: {why} невозможно записать пользовательские единицы страниц: {why} +@msg canceled отменено +@msg cannot convert the object to a primitive value объект невозможно преобразовать в примитивное значение +@msg cannot convert undefined or null to an object undefined или null невозможно преобразовать в объект +@msg cannot distort a zero-size or non-finite bounding box невозможно исказить ограничивающую рамку нулевого или неконечного размера +@msg cannot move an object into itself объект нельзя переместить в самого себя +@msg case must be upper|lower|title|sentence case должно быть upper|lower|title|sentence +@msg category `{c}`: one of {_1} категория `{c}`: одно из: {_1} +@msg center must be [x, y] center должно быть [x, y] +@msg character dimensions cannot be represented with the source text transform размеры символов невозможно представить с трансформацией исходного текста +@msg character dimensions cannot be represented with the target text transform размеры символов невозможно представить с трансформацией целевого текста +@msg choose at least one artboard выберите хотя бы одну монтажную область +@msg choose at least one status выберите хотя бы одно состояние +@msg clip must be true or false clip должно быть true или false +@msg clippingMask must be true or false clippingMask должно быть true или false +@msg closed must be a boolean closed должно быть логическим значением +@msg collapsed must be true or false collapsed должно быть true или false +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: нет профиля RGB или CMYK с именем «{profile}» +@msg colorMode must be rgb or cmyk, not `{m}` colorMode должно быть rgb или cmyk, а не `{m}` +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel должно быть {own} (режим документа), grayscale или bitmap +@msg colour groups can't go into colour groups цветовые группы нельзя помещать в цветовые группы +@msg colour, mode and spot apply to solid-colour swatches only colour, mode и spot применяются только к образцам сплошного цвета +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read столбец {column}: `{cell}` — столбец `%` из файлов данных диаграмм, которые не читаются +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable столбец {column}: `{cell}` указывает `{name}`, а это переменная типа {_1} +@msg column {column}: `{cell}` names no variable столбец {column}: `{cell}` не указывает ни одной переменной +@msg column {column}: the variable name is longer than {MAX_NAME} characters столбец {column}: имя переменной длиннее {MAX_NAME} символов +@msg column: true floats the whole column and takes no onto, above or below column: true делает плавающим весь столбец и не принимает onto, above или below +@msg command must be a shape.* command command должно быть командой shape.* +@msg commands[{i}]: missing `command` commands[{i}]: отсутствует `command` +@msg composer must be "singleLine" or "everyLine" composer должно быть "singleLine" или "everyLine" +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes составные фигуры принимают контуры, составные контуры, группы, составные фигуры, текст, переходы и оболочки +@msg compound {_1} must contain exactly one open path to join составной объект {_1} должен содержать ровно один открытый контур для соединения +@msg connect end must be "start" or "end" end для connect должно быть "start" или "end" +@msg connect needs a path other than the one extended для connect нужен контур, отличный от продлеваемого +@msg connect needs id для connect нужен id +@msg connect path has no open end у контура для connect нет открытого конца +@msg continuation must be an array of path chunks continuation должно быть массивом фрагментов контура +@msg continuation requires points для continuation нужны points +@msg copy needs path: the file to copy into the document's folder для copy нужен path: файл, который копируется в папку документа +@msg copy takes images only: relink placed documents where their files are copy принимает только изображения: восстанавливайте связь помещённых документов там, где находятся их файлы +@msg copying a file isn't available on the web копирование файла недоступно в веб-версии +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus должно быть unknown, copyrighted или publicDomain +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl должно быть URL-адресом (без пробелов, не более 2048 символов) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners должно быть 4 точками [x, y] (TL, TR, BR, BL) +@msg counterPer `{c}`: item or file counterPer `{c}`: item или file +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: рамки art, trim, bleed и media есть только у страниц PDF; используйте "crop" или "bounding" +@msg crop rectangle doesn't overlap the image прямоугольник обрезки не пересекается с изображением +@msg cross-reference and object streams aren't supported потоки перекрёстных ссылок и потоки объектов не поддерживаются +# Messages that begin with a lowercase word, d to m. +@msg damaged compressed data: {e} повреждённые сжатые данные: {e} +@msg dash lengths must be numbers, got {v} длины штрихов должны быть числами, получено {v} +@msg dash must be a list of lengths or null, got {v} dash должно быть списком длин или null, получено {v} +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) ключ данных `{k}`: буквы, цифры, -, _ и . (но не name и не vc-…) +@msg data must be an object of key: value, not {v} data должно быть объектом из пар key: value, а не {v} +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare набор данных `{name}` содержит значение для `{var}`, а библиотека не объявляет эту переменную +@msg data set `{name}` holds two values for `{var}` набор данных `{name}` содержит два значения для `{var}` +@msg data set `{name}` is declared twice набор данных `{name}` объявлен дважды +@msg data takes at most {MAX_DATA} keys at once data принимает не более {MAX_DATA} ключей за раз +@msg dataset `{name}` already exists набор данных `{name}` уже существует +@msg dataset `{to}` already exists набор данных `{to}` уже существует +@msg date `{d}`: YYYY-MM-DD дата `{d}`: YYYY-MM-DD +@msg decoded data is too large декодированные данные слишком велики +@msg define a variable first сначала определите переменную +@msg detect must be true or false detect должно быть true или false +@msg direct-select anchor points of a path выделите прямым выделением опорные точки контура +@msg distance must be > 0 distance должно быть > 0 +@msg dx and dy give the arrow's direction dx и dy задают направление стрелки +@msg dx and dy must be numbers dx и dy должны быть числами +@msg dxf fitTo must be [width, height], positive dxf fitTo должно быть [width, height] с положительными значениями +@msg dxf must be an object of DXF import options dxf должно быть объектом с параметрами импорта DXF +@msg dxf scale must be a positive number up to {_1}, not {s} dxf scale должно быть положительным числом не больше {_1}, а не {s} +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: единица, например mm, cm, in или pt +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} каждый формат — объект {format, scale?, suffix?} +@msg each item needs an id каждому элементу нужен id +@msg each match needs `kind` каждому совпадению нужно `kind` +@msg each match needs a `name` каждому совпадению нужно `name` +@msg each of `fonts` is {family, style?}; 1 to 1000 of them каждый элемент `fonts` — {family, style?}; от 1 до 1000 элементов +@msg each of `items` is {kind, item} каждый элемент `items` — {kind, item} +@msg each of `items` is {{id, corners?}} with an object id, not {it} каждый элемент `items` — {{id, corners?}} с идентификатором объекта, а не {it} +@msg each path chunk must contain 1 to 4096 points каждый фрагмент контура должен содержать от 1 до 4096 точек +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) каждый выбор — {{at: [x, y]}} или {{rect: [x0, y0, x1, y1]}} (получено {v}) +@msg each row needs `from` каждой строке нужно `from` +@msg each stop needs a position каждой точке перехода нужно положение +@msg each subpath needs an anchors array каждому вложенному контуру нужен массив anchors +@msg each weight must be a number of 0 or more (got {v}) каждый вес должен быть числом, 0 или больше (получено {v}) +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType должно быть easeIn, easeOut, easeInOut или easeOutIn +@msg editingData must be true or false editingData должно быть true или false +@msg editingData must be true, false or "always" editingData должно быть true, false или "always" +@msg edits overlap правки перекрываются +@msg effects of objects whose appearances differ can't be moved together эффекты объектов с разным оформлением нельзя перемещать вместе +@msg empty header in column {_1} пустой заголовок в столбце {_1} +@msg encoding `{_1}`: utf8 or utf16 кодировка `{_1}`: utf8 или utf16 +@msg ends must be two of "first" and "last" ends должно быть двумя значениями из "first" и "last" +@msg every numbered name for the result is taken все нумерованные имена для результата заняты +@msg expanded SVG styles exceed the import size limit развёрнутые стили SVG превышают ограничение размера импорта +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes файл наборов настроек экспорта больше {MAX_PRESET_FILE_BYTES} байт +@msg exportText must be editable or appearance exportText должно быть editable или appearance +@msg exportType must be movieClip or graphic, not `{s}` exportType должно быть movieClip или graphic, а не `{s}` +@msg extend needs id для extend нужен id +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges содержит не более {MAX_FEATURE_CHANGES} тегов +@msg featureChanges must be a list of tags featureChanges должно быть списком тегов +@msg features must be a list of tags features должно быть списком тегов +@msg fidelity must be 0..100 fidelity должно быть 0..100 +@msg files must be {name, dataBase64} files должно быть {name, dataBase64} +@msg finally table {t} out of range таблица finally {t} вне диапазона +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit должно быть none|autoHeight|shrinkText|block, получено {id} +@msg flags cannot be given with a RegExp object флаги нельзя указывать вместе с объектом RegExp +@msg flattener: {e} объединение: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low или сохранённый набор (см. flattener.presets.list) +@msg flipProfile must be along|across, got {o} flipProfile должно быть along|across, получено {o} +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item или itemFormat +@msg folders deeper than {MAX_BATCH_DEPTH} levels папки глубже {MAX_BATCH_DEPTH} уровней +@msg folders deeper than {MAX_DEPTH} levels папки глубже {MAX_DEPTH} уровней +@msg font family `{family}` is not available семейство шрифтов `{family}` недоступно +@msg font family `{tf}` is not available семейство шрифтов `{tf}` недоступно +@msg format `{f}`: pdf or postscript формат `{f}`: pdf или postscript +@msg format `{f}`: text or json формат `{f}`: text или json +@msg format is `{format}` формат: `{format}` +@msg formats must be an array of rows, not {v} formats должно быть массивом строк, а не {v} +@msg frequency must be between 1 and 300 lines per inch frequency должно быть от 1 до 300 линий на дюйм +@msg from and to must be finite from и to должны быть конечными +@msg from must be [x0, y0, x1, y1] from должно быть [x0, y0, x1, y1] +@msg function declaration index out of range индекс объявления функции вне диапазона +@msg give `before` or `after`, not both укажите `before` или `after`, но не оба +@msg give `char`, `text`, `code` or `unicode` укажите `char`, `text`, `code` или `unicode` +@msg give `colors`, or `color` and `rule` укажите `colors` либо `color` и `rule` +@msg give `count` or `spacing`, not both укажите `count` или `spacing`, но не оба +@msg give `event`, such as onclick укажите `event`, например onclick +@msg give `file`, the JavaScript file's path or URL укажите `file` (путь или URL файла JavaScript) +@msg give `fill` and/or `stroke` укажите `fill` и/или `stroke` +@msg give `fill`, `stroke` or both укажите `fill`, `stroke` или оба +@msg give `folder` укажите `folder` +@msg give `fonts`, or open a document укажите `fonts` или откройте документ +@msg give `from` or `indices`, not both укажите `from` или `indices`, но не оба +@msg give `id` (no text is being edited) укажите `id` (текст сейчас не редактируется) +@msg give `id` and `anchors`, or `items`, not both укажите `id` и `anchors` либо `items`, но не то и другое +@msg give `id` or `guide`, not both укажите `id` или `guide`, но не оба +@msg give `length` or `to` укажите `length` или `to` +@msg give `matches`: [{kind, name, target}] укажите `matches`: [{kind, name, target}] +@msg give `name` (or edit a pattern) укажите `name` (или редактируйте узор) +@msg give `name` or `names` укажите `name` или `names` +@msg give `name`, an SVG filter's name укажите `name` (имя фильтра SVG) +@msg give `path` (a .wasm file) or `dataBase64` (the module) укажите `path` (файл .wasm) или `dataBase64` (модуль) +@msg give `path`, `data` or `dataBase64` укажите `path`, `data` или `dataBase64` +@msg give `path`, `data`, `dataBase64` or `csv` укажите `path`, `data`, `dataBase64` или `csv` +@msg give `path`, `xml` or `dataBase64` укажите `path`, `xml` или `dataBase64` +@msg give `path`, an SVG file, or `svg`, SVG text укажите `path` (файл SVG) или `svg` (текст SVG) +@msg give `script`, the JavaScript the event runs ("" removes the event) укажите `script` (код JavaScript, который выполняет событие; "" удаляет событие) +@msg give `text` (the reading) or `remove` укажите `text` (чтение) или `remove` +@msg give `x` and `y` (and a `tolerance` of 0 or more) укажите `x` и `y` (и `tolerance`, 0 или больше) +@msg give `xml`, one SVG element укажите `xml` (один элемент SVG ) +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments укажите режим или коррекции r/g/b, c/m/y/k, gray или tint +@msg give a name, or paint the active proxy with a saved preset укажите имя или закрасьте активный индикатор сохранённым набором +@msg give a name, or select a stroke with a saved profile укажите имя или выделите обводку с сохранённым профилем +@msg give a pattern name or select an object painted with a pattern укажите имя узора или выделите объект, закрашенный узором +@msg give at least one of clip, invert, disabled, linked укажите хотя бы одно из: clip, invert, disabled, linked +@msg give color, none, swatch or gradient укажите color, none, swatch или gradient +@msg give dataBase64 укажите dataBase64 +@msg give either at (a click) or area (a path drawn around the overlaps) укажите либо at (щелчок), либо area (контур, нарисованный вокруг перекрытий) +@msg give exactly one of `at`, `rect` and `points` укажите ровно одно из: `at`, `rect` и `points` +@msg give exactly one of `path`, `data` and `dataBase64` укажите ровно одно из: `path`, `data` и `dataBase64` +@msg give formats or a preset, not both укажите formats или набор, но не оба +@msg give index or indexes, not both укажите index или indexes, но не оба +@msg give newName or delete укажите newName или delete +@msg give offset or by укажите offset или by +@msg give one element укажите один элемент +@msg give one of `to`, `rect` or `at` укажите одно из: `to`, `rect` или `at` +@msg give one of assets, layers, selection or fullDocument укажите одно из: assets, layers, selection или fullDocument +@msg give only one of onto, above, below, icon and a column place укажите только одно из: onto, above, below, icon и место в столбце +@msg give opacity or blend укажите opacity или blend +@msg give origin: [x, y], or reset: true укажите origin: [x, y] или reset: true +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note укажите overprintFill, overprintStroke, showCenter, imageMap, url или note +@msg give path (a file) or folder укажите path (файл) или folder +@msg give path, or name and dataBase64 укажите path либо name и dataBase64 +@msg give paths or files укажите paths или files +@msg give rows, rowHeight, columns or columnWidth укажите rows, rowHeight, columns или columnWidth +@msg give svg, or dataBase64 of an SVG or SVGZ file укажите svg или dataBase64 файла SVG или SVGZ +@msg give text укажите text +@msg give the asset a name дайте ресурсу имя +@msg give the preset a `name` укажите `name` набора +@msg give the preset a name дайте набору имя +@msg give the preset a name (1–64 characters) дайте набору имя (от 1 до 64 символов) +@msg give the settings to change, e.g. {"formats"}]} укажите изменяемые настройки, например {"formats"}]} +@msg give the style a name дайте стилю имя +@msg give two or more style `names` укажите в `names` два или более стиля +@msg give two or more swatches укажите два или более образца +@msg give x, y, or a handle's length or angle укажите x, y либо длину или угол маркера +@msg give {key, value} or {values} укажите {key, value} или {values} +@msg global must be true or false global должно быть true или false +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) глиф {_1} не рисуется для {ch} при стиле этого символа (например, «Все прописные», «Капитель» или система счисления) +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} глиф {g} не является альтернативой для {ch} в {_1} {_2} +@msg gradient must be "objects" or "mesh" gradient должно быть "objects" или "mesh" +@msg grays must be "profile" or "black", not `{g}` grays должно быть "profile" или "black", а не `{g}` +@msg gridColors must be a preset name or two colours gridColors должно быть именем набора или двумя цветами +@msg gridSize must be small, medium or large gridSize должно быть small, medium или large +@msg guide {g} is not selected направляющая {g} не выделена +@msg handle must be 0..3 handle должно быть 0..3 +@msg handler at {_1} expects {_2} values but its range starts with {d} обработчик по адресу {_1} ожидает значений: {_2}, но его диапазон начинается с {d} +@msg handler depth too large глубина обработчика слишком велика +@msg handler range {_1}..{_2} out of range диапазон обработчика {_1}..{_2} вне допустимых пределов +@msg hiddenLayers must be true or false hiddenLayers должно быть true или false +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (по умолчанию), "omit" или "visible" +@msg highlight a layer to merge into выделите строку слоя, в который нужно объединить +@msg highlight two or more rows in one layer or group выделите две или более строки в одном слое или группе +@msg highlight two or more rows to merge выделите две или более строки для объединения +@msg hvcC hvcC +@msg icon docks a panel in the icon column: use window.panel.dock icon прикрепляет панель в столбце значков: используйте window.panel.dock +@msg id must be a dimension id id должно быть идентификатором размера +@msg image has no bounds у изображения нет границ +@msg image {_1} is linked already изображение {_1} уже связано +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client или server +@msg images in a spot ink whose samples couldn't be read were imported in RGB изображения в плашечной краске, отсчёты которых не удалось прочитать, импортированы в RGB +@msg images past the most pixels one file may hold were left out изображения сверх наибольшего числа пикселей, допустимого для одного файла, пропущены +@msg images too large to read were left out изображения, слишком большие для чтения, пропущены +@msg index out of range индекс вне диапазона +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes может содержать не более {MAX_GUIDE_INDEXES} записей +@msg indexes must be an array of guide indexes indexes должно быть массивом индексов направляющих +@msg instances must be 1..1000 instances должно быть 1..1000 +@msg instruction out of range инструкция вне диапазона +@msg instruction {pc} ({op}) pops below the stack инструкция {pc} ({op}) извлекает значения из пустого стека +@msg instruction {pc} reached with stack depths {seen} and {d} инструкция {pc} достигается с глубинами стека {seen} и {d} +@msg invalid `color` недопустимое `color` +@msg invalid anchor index {v}: expected a non-negative integer недопустимый индекс опорной точки {v}: ожидается неотрицательное целое число +@msg invalid anchor reference {v}: expected [subpath, anchor] недопустимая ссылка на опорную точку {v}: ожидается [subpath, anchor] +@msg invalid angle недопустимый угол +@msg invalid array length недопустимая длина массива +@msg invalid brush: {e} недопустимая кисть: {e} +@msg invalid color недопустимый цвет +@msg invalid colour {c} недопустимый цвет {c} +@msg invalid delta недопустимое приращение +@msg invalid group недопустимая группа +@msg invalid object id {v}: expected a non-negative integer недопустимый идентификатор объекта {v}: ожидается неотрицательное целое число +@msg invalid point недопустимая точка +@msg invalid regular expression flag '{_1}' недопустимый флаг регулярного выражения '{_1}' +@msg invalid regular expression: {m} недопустимое регулярное выражение: {m} +@msg invalid size недопустимый размер +@msg invalid t недопустимое t +@msg invalid time value недопустимое значение времени +@msg invalid tolerance недопустимый допуск +@msg is a confirm's answer, whose `value` must be true or false является ответом для confirm, `value` которого должно быть true или false +@msg is a prompt's answer, whose `value` must be a string or null является ответом для prompt, `value` которого должно быть строкой или null +@msg is an alert's answer, whose `value` must be "ok" является ответом для alert, `value` которого должно быть "ok" +@msg is longer than 16 KiB длиннее 16 KiB +@msg it draws its image through a clipping path он рисует своё изображение через контур обрезки +@msg it draws no image он не рисует изображения +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes он больше {MAX_DICTIONARY_FILE_BYTES} байт +@msg it isn't a file это не файл +@msg it names {_1} plates, and at most {MAX_PLATES} are read в нём указано пластин: {_1}, а читается не более {MAX_PLATES} +@msg its art is too long or too detailed for a head его графика слишком длинна или слишком детальна для наконечника +@msg its art paints nothing его графика ничего не закрашивает +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) все его монтажные области находятся за пределами холста (дальше {_1} pt от начала координат) +@msg its clipping masks are nested too deeply его маски обрезки вложены слишком глубоко +@msg its image is too large to read его изображение слишком велико для чтения +@msg its metadata has no description в его метаданных нет описания +@msg its metadata has no direct length у его метаданных нет прямо заданной длины +@msg its plates are too large: more than {MAX_SAMPLES} pixels together его пластины слишком велики: вместе больше {MAX_SAMPLES} пикселей +@msg its plates are too large: {_1} plates of {w} × {h} pixels его пластины слишком велики: пластин {_1}, размер каждой {w} × {h} пикселей +@msg its plates can't be kept together его пластины невозможно сохранить вместе +@msg its shapes could not be combined into one его фигуры не удалось объединить в одну +@msg join two different paths соедините два разных контура +@msg jump to {pc}, past the end переход к {pc}, за конец кода +@msg justify must be auto|left|center|right|justifyAll justify должно быть auto|left|center|right|justifyAll +@msg kbps must be a positive number, not {kbps} kbps должно быть положительным числом, а не {kbps} +@msg keywords must be a list of words or a comma-separated string keywords должно быть списком слов или строкой со словами через запятую +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters каждое слово в keywords должно быть не длиннее {MAX_KEYWORD} символов и без управляющих символов +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind должно быть 1, 2 или 3, либо укажите `name` набора (см. perspective.presets.list) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind должно быть graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle или paraStyle +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout должно быть "on", "off", "neutral" или логическим значением, а не {v} +@msg layer `{name}` has no art to export в слое `{name}` нет графики для экспорта +@msg layer must name a layer or sublayer layer должно указывать слой или подслой +@msg layers can't be intertwined: select the objects in them слои нельзя переплести: выделите объекты в них +@msg layers go only at the top level or in layers, objects in layers and groups слои могут находиться только на верхнем уровне или в слоях, а объекты — в слоях и группах +@msg layers must be true or false layers должно быть true или false +@msg layout must be gridByRow, gridByColumn, row or column layout должно быть gridByRow, gridByColumn, row или column +@msg leading must be a number or "auto" leading должно быть числом или "auto" +@msg left/right must be non-negative widths left/right должны быть неотрицательными значениями ширины +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding должно быть одним из: {_1} +@msg level `{t}`: 2 or 3 уровень `{t}`: 2 или 3 +@msg line {line}: more than {MAX_CSV_COLS} columns строка {line}: больше {MAX_CSV_COLS} столбцов +@msg line {line}: unclosed quote строка {line}: незакрытая кавычка +@msg line {line}: value for `{var}` must match its variable kind строка {line}: значение `{var}` должно соответствовать типу переменной +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf или crlf +@msg loading profiles from disk isn't available in the browser загрузка профилей с диска недоступна в браузере +@msg localize: the first argument must be a localization object or a string localize: первый аргумент должен быть объектом локализации или строкой +@msg margin: a {m} pt margin leaves no imageable area on the paper margin: поле {m} pt не оставляет на бумаге области печати +@msg matrix must be finite and invertible, depth finite matrix должно быть конечной и обратимой матрицей, а depth — конечным +@msg matte: {e} matte: {e} +@msg member is for order front, forward, backward or back member используется для order front, forward, backward или back +@msg missing `artboards` отсутствует `artboards` +@msg missing `at` [x, y] отсутствует `at` [x, y] +@msg missing `attrs` отсутствует `attrs` +@msg missing `color` or `opacity` отсутствует `color` или `opacity` +@msg missing `colors` отсутствует `colors` +@msg missing `commands` отсутствует `commands` +@msg missing `destinationFolder` отсутствует `destinationFolder` +@msg missing `effect` id отсутствует идентификатор `effect` +@msg missing `family` отсутствует `family` +@msg missing `find` отсутствует `find` +@msg missing `gid` (a glyph id, or null for the default glyph) отсутствует `gid` (идентификатор глифа или null для глифа по умолчанию) +@msg missing `group` отсутствует `group` +@msg missing `id` отсутствует `id` +@msg missing `id`: give one, or select one type object отсутствует `id`: укажите его или выделите один текстовый объект +@msg missing `index` (a point index, or null to clear) отсутствует `index` (индекс точки или null для сброса) +@msg missing `index` (a stop index, or null to clear) отсутствует `index` (индекс точки перехода или null для сброса) +@msg missing `index` (an item index or null) отсутствует `index` (индекс элемента или null) +@msg missing `item` отсутствует `item` +@msg missing `library` отсутствует `library` +@msg missing `library` (see graphicStyle.libraries) отсутствует `library` (см. graphicStyle.libraries) +@msg missing `library` (see swatch.library.list) отсутствует `library` (см. swatch.library.list) +@msg missing `map` (rows [{from, to}] or {current}) отсутствует `map` (строки [{from, to}] или {current}) +@msg missing `matrix` [a,b,c,d,e,f] отсутствует `matrix` [a,b,c,d,e,f] +@msg missing `name` (see gradient.presets.list) отсутствует `name` (см. gradient.presets.list) +@msg missing `offset` (a distance in points) отсутствует `offset` (расстояние в пунктах) +@msg missing `path` отсутствует `path` +@msg missing `path` (a path object id) отсутствует `path` (идентификатор объекта-контура) +@msg missing `picks` отсутствует `picks` +@msg missing `rule` отсутствует `rule` +@msg missing `source` id отсутствует идентификатор `source` +@msg missing `start` (the byte offset of a character) отсутствует `start` (смещение символа в байтах) +@msg missing `status`: linked, embedded, missing or modified отсутствует `status`: linked, embedded, missing или modified +@msg missing `target` id отсутствует идентификатор `target` +@msg missing `text` отсутствует `text` +@msg missing `to` отсутствует `to` +@msg missing `to` (an index) отсутствует `to` (индекс) +@msg missing `to`: a non-negative integer отсутствует `to`: неотрицательное целое число +@msg missing `type` отсутствует `type` +@msg missing `value` отсутствует `value` +@msg missing `word` отсутствует `word` +@msg missing `{key}` отсутствует `{key}` +@msg missing `{k}` отсутствует `{k}` +@msg missing anchor отсутствует anchor +@msg missing anchors отсутствует anchors +@msg missing anchors [[subpath, anchor]…] отсутствует anchors [[subpath, anchor]…] +@msg missing array `{key}` отсутствует массив `{key}` +@msg missing case отсутствует case +@msg missing color отсутствует color +@msg missing commands отсутствует commands +@msg missing destination folder отсутствует папка назначения +@msg missing from отсутствует from +@msg missing group отсутствует group +@msg missing id or ids отсутствует id или ids +@msg missing ids отсутствует ids +@msg missing indexes отсутствует indexes +@msg missing matrix [a,b,c,d,e,f] отсутствует matrix [a,b,c,d,e,f] +@msg missing name отсутствует name +@msg missing op отсутствует op +@msg missing or invalid `color` `color` отсутствует или недопустимо +@msg missing or unknown `tool` `tool` отсутствует или неизвестно +@msg missing point отсутствует point +@msg missing point [x,y] отсутствует point [x,y] +@msg missing point `{key}` ([x, y]) отсутствует точка `{key}` ([x, y]) +@msg missing points отсутствует points +@msg missing points [[x,y]…] отсутствует points [[x,y]…] +@msg missing segment отсутствует segment +@msg missing stops отсутствует stops +@msg missing subpaths отсутствует subpaths +@msg missing target отсутствует target +@msg missing to отсутствует to +@msg missing x, y отсутствуют x, y +@msg mode is set, add or toggle, not `{other}` mode принимает set, add или toggle, а не `{other}` +@msg mode must be "cmyk" or "rgb" mode должно быть "cmyk" или "rgb" +@msg mode must be area|onPath, got {o} mode должно быть area|onPath, получено {o} +@msg more than 10000 linked files: package dependencies in smaller parts больше 10000 связанных файлов: упаковывайте зависимости меньшими частями +@msg more than 256 commands больше 256 команд +@msg more than {MAX_BATCH_FILES} data sets больше {MAX_BATCH_FILES} наборов данных +@msg more than {MAX_BATCH_FILES} files больше {MAX_BATCH_FILES} файлов +@msg more than {MAX_CSV_ROWS} data rows больше {MAX_CSV_ROWS} строк данных +@msg more than {MAX_FILES} images больше {MAX_FILES} изображений +@msg more than {MAX_IMPORT} presets больше {MAX_IMPORT} наборов +@msg moved must be [[x,y]…] moved должно быть [[x,y]…] +@msg must be an object with `kind` and `value` должен быть объектом с `kind` и `value` +@msg must have `kind` alert, confirm or prompt должен иметь `kind` alert, confirm или prompt +@msg must have exactly `kind` and `value` должен содержать только `kind` и `value` +# Messages that begin with a lowercase word, n to s. +@msg name (a toolbar's name) is required требуется name (имя панели инструментов) +@msg name (a toolbar's name) must be a string name (имя панели инструментов) должно быть строкой +@msg name must be a toolbar's name name должно быть именем панели инструментов +@msg names is empty names пусто +@msg names must be an array of symbol names names должно быть массивом имён символов +@msg need `anchor` or `segment` + `t` нужно `anchor` или `segment` + `t` +@msg need at least one anchor нужна хотя бы одна опорная точка +@msg need at least one point нужна хотя бы одна точка +@msg need at least two points нужны хотя бы две точки +@msg needs a closing quote after the name нужна закрывающая кавычка после имени +@msg needs a name нужно имя +@msg needs a name of at most 64 bytes нужно имя длиной не более 64 байт +@msg needs a name of letters, digits and _, or a quoted name нужно имя из букв, цифр и _ либо имя в кавычках +@msg needs a name without control characters нужно имя без управляющих символов +@msg needs points нужны points +@msg nesting too deep слишком глубокая вложенность +@msg nineSlice must be true or false nineSlice должно быть true или false +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides должно быть [left, top, right, bottom], каждое значение -16..16, при этом left + right < 1 и top + bottom < 1 +@msg no HP-GL format нет формата HP-GL +@msg no New Document profile called `{shown}` ({_1}) нет профиля нового документа с именем `{shown}` ({_1}) +@msg no PDF preset named `{n}` нет набора PDF с именем `{n}` +@msg no SVG filter at index {k} нет фильтра SVG с индексом {k} +@msg no action set is current нет текущего набора действий +@msg no active data set нет активного набора данных +@msg no appearance item {index} нет элемента оформления {index} +@msg no arrowheads file here здесь нет файла стрелок +@msg no artboard {_1} нет монтажной области {_1} +@msg no artboard {artboard} (0-based; the document has {_1}) нет монтажной области {artboard} (отсчёт от 0; всего в документе: {_1}) +@msg no asset {id} нет ресурса {id} +@msg no asset {id} (see assets.list) нет ресурса {id} (см. assets.list) +@msg no assets to export нет ресурсов для экспорта +@msg no assets yet: collect art for export first (assets.add) ресурсов пока нет: сначала соберите графику для экспорта (assets.add) +@msg no brush named `{n}` нет кисти с именем `{n}` +@msg no character at byte {start} нет символа по байту {start} +@msg no character has code {code} нет символа с кодом {code} +@msg no colour нет цвета +@msg no colour group `{group}` нет цветовой группы `{group}` +@msg no colour group `{name}` нет цветовой группы `{name}` +@msg no custom arrowhead {name} (see stroke.arrowheads.list) нет пользовательской стрелки {name} (см. stroke.arrowheads.list) +@msg no data sets нет наборов данных +@msg no dataset named `{from}` нет набора данных с именем `{from}` +@msg no dataset named `{name}` нет набора данных с именем `{name}` +@msg no datasets нет наборов данных +@msg no design named `{name}` нет дизайна с именем `{name}` +@msg no design named `{n}` нет дизайна с именем `{n}` +@msg no design named `{old}` нет дизайна с именем `{old}` +@msg no encoder for {_1} yet кодировщика для {_1} пока нет +@msg no export preset `{name}` (see document.exportPresets.list) нет набора настроек экспорта `{name}` (см. document.exportPresets.list) +@msg no file to open нет файла для открытия +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder нет папки: передайте `path` или задайте «Настройки › Модули & Рабочие диски › Папка дополнительных модулей» +@msg no fonts to look for: they are all available нет шрифтов для поиска: все они доступны +@msg no formats to export нет форматов для экспорта +@msg no gradient preset `{name}` (see gradient.presets.list) нет набора градиента `{name}` (см. gradient.presets.list) +@msg no graphic style `{n}` in `{_1}` нет графического стиля `{n}` в `{_1}` +@msg no graphic style library `{key}` (see graphicStyle.libraries) нет библиотеки графических стилей `{key}` (см. graphicStyle.libraries) +@msg no guide selected направляющая не выделена +@msg no guide selected (or give an index) направляющая не выделена (или укажите индекс) +@msg no guide {i} нет направляющей {i} +@msg no images in the folder в папке нет изображений +@msg no instructions нет инструкций +@msg no item at index {_1} нет элемента с индексом {_1} +@msg no item at index {from} нет элемента с индексом {from} +@msg no item at one of the indices {idx} нет элемента по одному из индексов {idx} +@msg no layer нет слоя +@msg no layer to rename нет слоя для переименования +@msg no layer {id} (see layer.list) нет слоя {id} (см. layer.list) +@msg no layers to export нет слоёв для экспорта +@msg no library `{key}` нет библиотеки `{key}` +@msg no metafile format нет формата метафайла +@msg no object {_1} нет объекта {_1} +@msg no objects нет объектов +@msg no parent layer нет родительского слоя +@msg no perspective grid preset named `{b}` (see perspective.presets.list) нет набора сетки перспективы с именем `{b}` (см. perspective.presets.list) +@msg no perspective grid preset named `{n}` нет набора сетки перспективы с именем `{n}` +@msg no point selected (give `index`) точка не выделена (укажите `index`) +@msg no preset called `{from}` (see file.newPresets) нет набора с именем `{from}` (см. file.newPresets) +@msg no preset called `{name}` (see file.newPresets) нет набора с именем `{name}` (см. file.newPresets) +@msg no preset named `{n}` нет набора с именем `{n}` +@msg no print preset named `{b}` (see print.presets.list) нет набора печати с именем `{b}` (см. print.presets.list) +@msg no print preset named `{name}` (presets: {_1}) нет набора печати с именем `{name}` (наборы: {_1}) +@msg no print preset named `{n}` нет набора печати с именем `{n}` +@msg no profile named `{name}` нет профиля с именем `{name}` +@msg no random numbers ({e}) нет случайных чисел ({e}) +@msg no readable files in the folder в папке нет читаемых файлов +@msg no recovery copy `{f}` (see file.recovery.list) нет копии восстановления `{f}` (см. file.recovery.list) +@msg no rows нет строк +@msg no saved PDF preset named `{name}` нет сохранённого набора PDF с именем `{name}` +@msg no saved export preset `{name}` нет сохранённого набора настроек экспорта `{name}` +@msg no saved gradient preset called {name} нет сохранённого набора градиента с именем {name} +@msg no saved gradient presets нет сохранённых наборов градиента +@msg no saved preset called `{name}` нет сохранённого набора с именем `{name}` +@msg no saved presets to export (name [Default] in `names` to export it) нет сохранённых наборов для экспорта (укажите [Default] в `names`, чтобы экспортировать его) +@msg no saved presets to export (name built-in ones in `names`) нет сохранённых наборов для экспорта (встроенные укажите в `names`) +@msg no saved print preset named `{name}` нет сохранённого набора печати с именем `{name}` +@msg no saved profile called {name} нет сохранённого профиля с именем {name} +@msg no saved selection `{name}` нет сохранённого выделения `{name}` +@msg no saved theme called {name} (see colorTheme.list) нет сохранённой темы с именем {name} (см. colorTheme.list) +@msg no saved views нет сохранённых видов +@msg no selected or given object has a visible 3D effect ни у одного выделенного или указанного объекта нет видимого 3D-эффекта +@msg no series {i} нет ряда {i} +@msg no slice {n} in the image (see slice.list) в изображении нет фрагмента {n} (см. slice.list) +@msg no source art нет исходной графики +@msg no stop {i} (the gradient has {n}) нет точки перехода {i} (всего в градиенте: {n}) +@msg no style `{name}` нет стиля `{name}` +@msg no such artboard {v} монтажная область {v} не существует +@msg no such assets (see assets.list) таких ресурсов нет (см. assets.list) +@msg no such frame anchor, or the frame would reach past the canvas такой опорной точки рамки нет, или рамка вышла бы за пределы холста +@msg no such objects таких объектов нет +@msg no such path anchor, or the path would reach past the canvas такой опорной точки контура нет, или контур вышел бы за пределы холста +@msg no such rows таких строк нет +@msg no swatch `{keep}` нет образца `{keep}` +@msg no swatch `{n}` нет образца `{n}` +@msg no swatch `{n}` (swatches and colour groups move separately) нет образца `{n}` (образцы и цветовые группы перемещаются отдельно) +@msg no swatch library `{key}` (see swatch.library.list) нет библиотеки образцов `{key}` (см. swatch.library.list) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) нет библиотеки образцов `{key}` (см. swatch.library.list; "document": образцы документа) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) нет библиотеки образцов `{key}` (см. swatch.library.list; "{DOCUMENT_SWATCHES}": образцы документа) +@msg no swatch named `{n}` нет образца с именем `{n}` +@msg no swatch or colour group `{n}` нет образца или цветовой группы `{n}` +@msg no swatch or colour group `{n}` in `{_1}` нет образца или цветовой группы `{n}` в `{_1}` +@msg no symbol named `{_1}` нет символа с именем `{_1}` +@msg no symbol named `{name}` нет символа с именем `{name}` +@msg no text objects selected текстовые объекты не выделены +@msg no toolbar named `{_1}` нет панели инструментов с именем `{_1}` +@msg no toolbar named `{name}` нет панели инструментов с именем `{name}` +@msg no tracing preset `{preset}` (see imageTrace.presets) нет набора трассировки `{preset}` (см. imageTrace.presets) +@msg no user library folder here (save with a path, or without one to get the data) здесь нет папки пользовательских библиотек (сохраните с путём или без пути, чтобы получить данные) +@msg no variable named `{from}` нет переменной с именем `{from}` +@msg no variable named `{name}` нет переменной с именем `{name}` +@msg no variable named `{var}` нет переменной с именем `{var}` +@msg no variable named `{v}` нет переменной с именем `{v}` +@msg no view named `{name}` нет вида с именем `{name}` +@msg node {_1} already has the text variable `{_2}` у узла {_1} уже есть текстовая переменная `{_2}` +@msg node {_1} is not a graph узел {_1} не является диаграммой +@msg node {_1} is not an image узел {_1} не является изображением +@msg node {_1} is not type узел {_1} не является текстом +@msg not a DCS file не файл DCS +@msg not a PDF presets file (format `{_1}`) не файл наборов PDF (формат `{_1}`) +@msg not a PDF presets file: {e} не файл наборов PDF: {e} +@msg not a RegExp object не объект RegExp +@msg not a Word document: its WordDocument stream doesn't start with a file information block не документ Word: его поток WordDocument не начинается с блока сведений о файле +@msg not a Word document: its compound file has no WordDocument stream не документ Word: в его составном файле нет потока WordDocument +@msg not a Word document: its main part is not a WordprocessingML document не документ Word: его основная часть не является документом WordprocessingML +@msg not a Word document: its package has no main document part не документ Word: в его пакете нет основной части документа +@msg not a closed path не замкнутый контур +@msg not a flattener presets file (format `{_1}`) не файл наборов объединения (формат `{_1}`) +@msg not a flattener presets file: {e} не файл наборов объединения: {e} +@msg not a layer or group не слой и не группа +@msg not a library file: {e} не файл библиотеки: {e} +@msg not a mesh envelope point не точка сетки оболочки +@msg not a perspective grid presets file (format `{_1}`) не файл наборов сетки перспективы (формат `{_1}`) +@msg not a perspective grid presets file: {e} не файл наборов сетки перспективы: {e} +@msg not a print presets file (format `{_1}`) не файл наборов печати (формат `{_1}`) +@msg not a print presets file: {e} не файл наборов печати: {e} +@msg not a regular expression не регулярное выражение +@msg not a stored or deflated central record центральная запись не сохранена без сжатия и не сжата методом deflate +@msg not a valid SVGZ file: {e} недопустимый файл SVGZ: {e} +@msg not an action set: the text doesn't start with /version and a number не набор действий: текст не начинается с /version и числа +@msg not an image VectorCraft reads (see document.formats) не изображение, которое читает VectorCraft (см. document.formats) +@msg not area type (text in a frame) outside perspective не текст в области (текст в рамке) вне перспективы +@msg not editing an opacity mask непрозрачная маска не редактируется +@msg not type on a path outside perspective не текст по контуру вне перспективы +@msg nothing prints ничего не печатается +@msg nothing to bind нечего привязывать +@msg nothing to change нечего изменять +@msg nothing to change: give dx, dy, hScale, vScale or rotation нечего изменять: укажите dx, dy, hScale, vScale или rotation +@msg nothing to crop нечего обрезать +@msg nothing to export нечего экспортировать +@msg nothing to export: the document has no visible art нечего экспортировать: в документе нет видимой графики +@msg nothing to mark нечего отмечать +@msg nothing to print: every artboard picked is blank нечего печатать: все выбранные монтажные области пусты +@msg nothing to print: the document has no art that prints нечего печатать: в документе нет печатаемой графики +@msg nothing to repeat нечего повторять +@msg numbers out of order in {_1} quantifier числа в квантификаторе {_1} идут в неверном порядке +@msg object is not a path объект не является контуром +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object прозрачность объекта нельзя перенести на выделенные символы; отключите «Прозрачность» в окне «Параметры пипетки» или выделите объект целиком +@msg object {_1} can't be set as point type объект {_1} нельзя преобразовать в текст из точки +@msg object {_1} has lost its file's contents: relink it объект {_1} потерял содержимое своего файла: восстановите связь +@msg object {_1} has no path у объекта {_1} нет контура +@msg object {_1} has no size у объекта {_1} нет размера +@msg object {_1} is hidden or locked, or on a locked artboard объект {_1} скрыт или заблокирован либо находится на заблокированной монтажной области +@msg object {_1} is not a placed document объект {_1} не является помещённым документом +@msg object {_1} is not an Intertwine group объект {_1} не является группой переплетения +@msg object {_1} is not an image объект {_1} не является изображением +@msg object {_1} is not an image or a linked file объект {_1} не является изображением или связанным файлом +@msg object {_1} is not an image or a placed document объект {_1} не является изображением или помещённым документом +@msg object {_1} is not in this Intertwine group объект {_1} не входит в эту группу переплетения +@msg object {_1} needs a 3D effect объекту {_1} нужен 3D-эффект +@msg object {_1} needs a 3D effect at index {index} объекту {_1} нужен 3D-эффект с индексом {index} +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic объект {_1}: у полого эффекта «Выдавливание и фаска» нет торцов: включите «Окончание», чтобы наложить графику +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front объект {_1}: при глубине 0 у объекта одна грань: наложите его графику на переднюю сторону +@msg object {_1}: {_2} объект {_1}: {_2} +@msg object {_1}: {e} объект {_1}: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams объект {_1}: у {name} с индексом {index} нет наборов освещения; задайте источники света через effect.setParams +@msg object {id} has anchors 0–{_1} for `corners`, not {past} у объекта {id} для `corners` есть опорные точки 0–{_1}, а не {past} +@msg object {id} has {n} anchors, so no anchor {past} у объекта {id} опорных точек: {n}, поэтому опорной точки {past} нет +@msg objects must be none, move or copy objects должно быть none, move или copy +@msg objects were left out объекты пропущены +@msg offset out of range смещение вне диапазона +@msg on must be true or false on должно быть true или false +@msg only groups, layers, type and images have a Contents (Characters) row строка «Содержимое» («Символы») есть только у групп, слоёв, текста и изображений +@msg only interior mesh lines can be deleted удалять можно только внутренние линии сетки +@msg only solid colours can be added (not gradients or patterns) добавлять можно только сплошные цвета (не градиенты и не узоры) +@msg only solid-colour swatches can be spot colours плашечными цветами могут быть только образцы сплошного цвета +@msg only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first удалось прочитать только встроенный предпросмотр этого файла Affinity: используйте «Файл › Открыть», чтобы увидеть его с предупреждением, или сначала экспортируйте SVG или PDF из Affinity +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning удалось прочитать только встроенный предпросмотр этого файла Affinity; используйте «Файл › Открыть», чтобы увидеть его с предупреждением +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon и место в столбце относятся к window.panel.float и window.panel.dock +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto, above, below, icon и место в столбце не сочетаются с anchor, side, zone и before +@msg onto, above, below, icon and a column place take no x, y, width or height onto, above, below, icon и место в столбце не сочетаются с x, y, width и height +@msg opacity must be a number opacity должно быть числом +@msg open a document to look for its linked files откройте документ, чтобы искать его связанные файлы +@msg operand too large операнд слишком велик +@msg options must be an object (see file.formatOptions) options должно быть объектом (см. file.formatOptions) +@msg order is a whole number from 0 to {others}, not `{v}` order — целое число от 0 до {others}, а не `{v}` +@msg order must be leftToRight or rightToLeft order должно быть leftToRight или rightToLeft +@msg orientation must be portrait or landscape, not `{o}` orientation должно быть portrait или landscape, а не `{o}` +@msg outlineText {v}: true or false outlineText {v}: true или false +@msg output intent profile: {e} профиль назначения вывода: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: нет профиля RGB или CMYK с именем «{name}» +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: файлы {_1} используют CMYK, а «{name}» — профиль RGB +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: ни одна краска не назначена для печати +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: нет профиля с именем «{name}», а назначение вывода {std} внедряет свой профиль +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: нет профиля с именем «{name}»: назначение вывода {std} внедряет профиль или указывает зарегистрированное условие печати (outputConditionId и registry) +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: файлы {std} печатаются в CMYK, а «{name}» — профиль RGB +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: «{name}» — профиль оттенков серого, он не может быть назначением вывода {std} +@msg overlap: {o} pt is half a tile or more overlap: {o} pt составляет половину плитки или больше +@msg overlapping Shaper paths must be in the same layer or group перекрывающиеся контуры Shaper должны находиться в одном слое или группе +@msg overlapping edits перекрывающиеся правки +@msg page art conflicts with image data for {key} графика страницы конфликтует с данными изображения для {key} +@msg page art conflicts with pattern {name} графика страницы конфликтует с узором {name} +@msg page art conflicts with swatch {name} графика страницы конфликтует с образцом {name} +@msg page art is missing image data for {key} в графике страницы отсутствуют данные изображения для {key} +@msg page art is missing pattern {name} в графике страницы отсутствует узор {name} +@msg page art is missing swatch {name} в графике страницы отсутствует образец {name} +@msg page must be a whole number from 1 page должно быть целым числом от 1 +@msg page thumbnail: {e} миниатюра страницы: {e} +@msg page {page} of the PDF has no objects на странице {page} файла PDF нет объектов +@msg page {page}: `{file}` has {n} {artboards} страница {page}: в `{file}` {n} {artboards} +@msg page {page}: `{name}` has {n} {pages} страница {page}: в `{name}` {n} {pages} +@msg pages must be a range such as "2-3" or a page number, not {v} pages должно быть диапазоном, например "2-3", или номером страницы, а не {v} +@msg pages: {e} pages: {e} +@msg panel position must be finite положение панели должно быть конечным +@msg parameters must be an object параметры должны быть объектом +@msg params must be an object params должно быть объектом +@msg passwords can have up to {MAX_PASSWORD} bytes пароли могут содержать до {MAX_PASSWORD} байт +@msg path chunks must share their boundary point фрагменты контура должны иметь общую граничную точку +@msg path has no open end у контура нет открытого конца +@msg paths must be strings paths должно состоять из строк +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false требует все монтажные области, чтобы сохранить редактируемую графику +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false когда useArtboards равно false, pdfCompatible: false требует монтажные области, чтобы сохранить редактируемую графику +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single или multiple +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom принимает "artwork" или "swatches", а не {v} +@msg pins and moved must be non-empty lists of the same length pins и moved должны быть непустыми списками одинаковой длины +@msg pins must be [[x,y]…] pins должно быть [[x,y]…] +@msg pins must be finite pins должны быть конечными +@msg place is above, below or inside, not `{other}` place принимает above, below или inside, а не `{other}` +@msg plane must be left|right|ground plane должно быть left|right|ground +@msg plane must be left|right|ground|none plane должно быть left|right|ground|none +@msg point is outside the mesh точка находится за пределами сетки +@msg point x must be a number x точки должно быть числом +@msg point y must be a number y точки должно быть числом +@msg points must be [t, left, right] with non-negative widths points должно быть [t, left, right] с неотрицательной шириной +@msg points must be [x,y] pairs points должно состоять из пар [x,y] +@msg points must be a non-empty [[x,y,pressure?]…] list points должно быть непустым списком [[x,y,pressure?]…] +@msg points must be a non-empty list of finite [x,y] points должно быть непустым списком конечных [x,y] +@msg points must be an array or null points должно быть массивом или null +@msg points must be finite points должны быть конечными +@msg points must be finite [x,y] pairs points должно состоять из конечных пар [x,y] +@msg points must be finite canvas points points должно состоять из конечных точек холста +@msg points must contain 1–4096 coordinates points должно содержать от 1 до 4096 координат +@msg position must be normal, superscript or subscript position должно быть normal, superscript или subscript +@msg position must be topLeft, topRight, bottomLeft or bottomRight position должно быть topLeft, topRight, bottomLeft или bottomRight +@msg positions must be between 0 and 100000 pt positions должны быть от 0 до 100000 pt +@msg ppi must be a positive number, not {ppi} ppi должно быть положительным числом, а не {ppi} +@msg ppi must be between 1 and 2400 ppi должно быть от 1 до 2400 +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}` не является одним из transforms|bounds|fileDimensions|fit|fill +@msg preserveShape must be clippingMask|transparency preserveShape должно быть clippingMask|transparency +@msg preset `{id}`: mobile or density набор `{id}`: mobile или density +@msg preset must be standard, diffuse, topLeft or right preset должно быть standard, diffuse, topLeft или right +@msg preset must be the name of a print preset preset должно быть именем набора печати +@msg previewMode must be default, pixel or overprint previewMode должно быть default, pixel или overprint +@msg print settings: {e} параметры печати: {e} +@msg procedures nest too deeply процедуры вложены слишком глубоко +@msg procedures nested too deeply вложенность процедур слишком глубока +@msg quotes must be {double?, single?} quotes должно быть {double?, single?} +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} должно быть открывающей и закрывающей кавычками, например "“”" +@msg range out of order in character class диапазон в классе символов задан в неверном порядке +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi должно быть 1–2400 (72 для экрана, 150 для среднего, 300 для высокого) +@msg rating must be a whole number from 0 to {MAX_RATING} rating должно быть целым числом от 0 до {MAX_RATING} +@msg rect and {k} contradict each other: give the area or the artboards rect и {k} противоречат друг другу: укажите область или монтажные области +@msg rect applies to raster formats, not {_1} rect применяется к растровым форматам, а не к {_1} +@msg rect is [x, y, width, height] in points rect — это [x, y, width, height] в пунктах +@msg rect must be [x, y, width, height] rect должно быть [x, y, width, height] +@msg rect must be [x, y, width, height] with a positive width and height rect должно быть [x, y, width, height] с положительными шириной и высотой +@msg rect needs finite numbers and a positive width and height для rect нужны конечные числа и положительные ширина и высота +@msg redraw needs id для redraw нужен id +@msg redraw within must be a finite distance of 0 or more within для redraw должно быть конечным расстоянием, 0 или больше +@msg reduce of an empty array with no initial value reduce для пустого массива без начального значения +@msg registration must be 0..8 registration должно быть 0..8 +@msg regular expression flag '{_1}' given twice флаг регулярного выражения '{_1}' указан дважды +@msg release a layer or a group распределять по слоям можно слой или группу +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace сохраняет положение и трансформацию заменяемого объекта: уберите template, at и rect +@msg replace: select the one object to replace replace: выделите один объект для замены +@msg resolution must be a number or screen|medium|high resolution должно быть числом или screen|medium|high +@msg resolution must be between 1 and 2400 ppi resolution должно быть от 1 до 2400 ppi +@msg rotation must be a number of degrees rotation должно быть числом в градусах +@msg rotation must be finite rotation должно быть конечным +@msg rows and cols must be 1..50 rows и cols должны быть 1..50 +@msg rows and columns must be 1..500 rows и columns должны быть 1..500 +@msg rows of objects whose appearances differ can't be reordered together строки объектов с разным оформлением нельзя переупорядочивать вместе +@msg sampled text would exceed the canvas (coordinates out of range) текст с перенесёнными атрибутами превысил бы холст (координаты вне диапазона) +@msg save the document first: Package collects a saved document сначала сохраните документ: «Упаковать» собирает сохранённый документ +@msg save the document first: copy puts the file in its folder сначала сохраните документ: copy помещает файл в его папку +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: коэффициент, например 2x, ширина (100w), высота (100h) или разрешение (72ppi) +@msg scale must be a number above 0, at most 64 scale должно быть числом больше 0 и не больше 64 +@msg scale must be non-zero scale не должно быть нулём +@msg scope must be selection or all (got {other}) scope должно быть selection или all (получено {other}) +@msg scribble is too long каракули слишком длинные +@msg segment does not exist сегмент не существует +@msg select a Shaper group выделите группу Shaper +@msg select a blend to set easing or color shift выделите переход, чтобы задать сглаживание или сдвиг цвета +@msg select a graph выделите диаграмму +@msg select a group or layer with visible, unlocked members выделите группу или слой с видимыми незаблокированными элементами +@msg select a layer выделите слой +@msg select a text object выделите текстовый объект +@msg select an SVG filter выделите фильтр SVG +@msg select an image (or an Image Trace group) выделите изображение (или группу трассировки изображения) +@msg select an image, or pass id выделите изображение или передайте id +@msg select an object (or pass `id`) выделите объект (или передайте `id`) +@msg select an object with a 3D effect выделите объект с 3D-эффектом +@msg select an object with a 3D effect, or give its id in ids выделите объект с 3D-эффектом или укажите его идентификатор в ids +@msg select an object with a freeform gradient выделите объект с произвольным градиентом +@msg select an object with an opacity mask выделите объект с непрозрачной маской +@msg select area type выделите текст в области +@msg select area type (text in a frame) выделите текст в области (текст в рамке) +@msg select area type and closed paths (at least one area type object) выделите текст в области и замкнутые контуры (хотя бы один объект текста в области) +@msg select area type whose first line can fit its frame выделите текст в области, первая строка которого может поместиться в рамку +@msg select artwork to add its colours выделите рисунок, чтобы добавить его цвета +@msg select artwork to make a group of its colours выделите рисунок, чтобы создать группу из его цветов +@msg select artwork to recolour, or give a colour `group` выделите рисунок для перекрашивания или укажите цветовую группу в `group` +@msg select exactly two path ends to join выделите ровно два конца контуров для соединения +@msg select objects or give ids выделите объекты или укажите ids +@msg select objects to align to the guide выделите объекты для выравнивания по направляющей +@msg select one Intertwine group выделите одну группу переплетения +@msg select one image or pass id выделите одно изображение или передайте id +@msg select one layer, group or object to rename выделите один слой, группу или объект для переименования +@msg select paths выделите контуры +@msg select paths or compound paths выделите контуры или составные контуры +@msg select rows or objects to collect выделите строки или объекты для сбора +@msg select something to export выделите что-нибудь для экспорта +@msg select something to export (or turn off selectedOnly) выделите что-нибудь для экспорта (или отключите selectedOnly) +@msg select text to redefine the style from выделите текст, по которому нужно переопределить стиль +@msg select the art and, on top of it, the mask object выделите графику и расположенный поверх неё объект маски +@msg select the art for the design выделите графику для дизайна +@msg select the art for the pattern tile выделите графику для плитки узора +@msg select the art to add first сначала выделите добавляемую графику +@msg select the art to collect (or give its ids) выделите графику для сбора (или укажите её ids) +@msg select the art to weed выделите графику для выборки +@msg select the banner, or mark every artboard выделите баннер или разметьте все монтажные области +@msg select the content and a path on top выделите содержимое и контур поверх него +@msg select the images to relink, or pass ids выделите изображения для восстановления связи или передайте ids +@msg select the images, or pass ids выделите изображения или передайте ids +@msg select the linked images or placed documents, or pass ids выделите связанные изображения или помещённые документы либо передайте ids +@msg select the object to copy from выделите объект, с которого нужно копировать +@msg select the objects (or give their ids) выделите объекты (или укажите их ids) +@msg select the objects to attach to the path выделите объекты для прикрепления к контуру +@msg select the objects to bind выделите объекты для привязки +@msg select two or more objects to intertwine выделите два или более объекта для переплетения +@msg select two or more slices to combine выделите два или более фрагмента для объединения +@msg select type on a path выделите текст по контуру +@msg select type on a path or area type выделите текст по контуру или текст в области +@msg selection `{m}`: one or each selection `{m}`: one или each +@msg settings is an object of document.exportForScreens params settings — объект с параметрами document.exportForScreens +@msg settings must be an object settings должно быть объектом +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all, missing, modified или embedded +@msg show must be true or false show должно быть true или false +@msg side is left or right, not `{v}` side принимает left или right, а не `{v}` +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size должно быть "small", "medium", "large" или числом в пунктах больше 0 и не больше {_1} +@msg size must be positive size должно быть положительным +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode принимает "fixed" или "pressure", а не `{other}` +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode принимает "fixed" или "pressure", а не {other} +@msg sizeVariation must be 0 or more sizeVariation должно быть 0 или больше +@msg smallCaps must be true or false smallCaps должно быть true или false +@msg sort `{x}`: name, kind or status sort `{x}`: name, kind или status +@msg source paths are too large for Shaper исходные контуры слишком велики для Shaper +@msg source text transform is collapsed or unrepresentable трансформация исходного текста вырождена или непредставима +@msg spot, mode and group apply to solid colours only spot, mode и group применяются только к сплошным цветам +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked, embedded, missing или modified +@msg steps must be 1..1000 steps должно быть 1..1000 +@msg steps must be 1..{MAX_STEPS} steps должно быть 1..{MAX_STEPS} +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) точка перехода {i}: `plates` должно быть четырьмя числами от 0 до 1 (C, M, Y, K) +@msg stopped before the archive was finished остановлено до завершения архива +@msg strokeMode must be within|along|across, got {m} strokeMode должно быть within|along|across, получено {m} +@msg strokeOptions must be an object of stroke.set options strokeOptions должно быть объектом с параметрами stroke.set +@msg strokeWidth must be a positive number of pixels strokeWidth должно быть положительным числом пикселей +@msg subpath {si} is not an open subpath вложенный контур {si} не является открытым +@msg subpaths required when joining the same path при соединении одного и того же контура требуется subpaths +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict должно быть "merge" или "add", а не {v} +# Messages that begin with a lowercase word, t to z. +@msg table must be true or false table должно быть true или false +@msg takes one name принимает одно имя +@msg target text transform is collapsed or unrepresentable трансформация целевого текста вырождена или непредставима +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text должно быть {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} +@msg text transform is collapsed or unrepresentable трансформация текста вырождена или непредставима +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces должно быть целым числом от 2 до 100 +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: используйте "{_1}" или "{_2}" +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: укажите {path} или {name, dataBase64} файла изображения либо текстуру в том виде, в каком её выдаёт effect.list +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices так получится больше {MAX_PIECES} фрагментов +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer так получится слишком много частей (не более 100000) или шагов штриха: упростите шаблон штрихов или удлините штрихи +@msg that object is not in the overlap этого объекта нет в перекрытии +@msg the Affinity document has no page with a size в документе Affinity нет страницы с заданным размером +@msg the CMYK image couldn't be written изображение CMYK не удалось записать +@msg the Layers panel is not shown панель «Слои» не показана +@msg the Layers panel's search, filter or Show Layers Only hides its row поиск, фильтр или параметр «Показывать только слои» в панели «Слои» скрывает его строку +@msg the Objects on Path group is hidden or locked группа «Объекты на контуре» скрыта или заблокирована +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected параметры безопасности PDF сохранены без паролей: снова укажите openPassword или permissionsPassword либо security без passwordRequired, чтобы экспортировать без защиты +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) в файле RTF слишком много абзацев или изменений форматирования, чтобы поместить его как текст (не более {MAX_PARAGRAPHS} абзацев и {MAX_RUNS} фрагментов форматирования) +@msg the RTF file is too large to place as type (at most {_1} MB) файл RTF слишком велик, чтобы поместить его как текст (не более {_1} МБ) +@msg the SVG expands to more than {0} nested SVG images SVG разворачивается более чем в {0} вложенных изображений SVG +@msg the SVG expands to more than {0} objects SVG разворачивается более чем в {0} объектов +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes файл SVG больше {MAX_IMPORT_BYTES} байт +@msg the SVG has no drawable objects в SVG нет отображаемых объектов +@msg the SVGZ file unpacks to more than {_1} MB файл SVGZ распаковывается более чем в {_1} МБ +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place в документе Word больше {MAX_PAGES} страниц свойств, это слишком много для помещения +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place в документе Word больше {MAX_RUNS} фрагментов свойств символов или абзацев, это слишком много для помещения +@msg the Word document has more than {_1} MB of formatting properties, too many to place в документе Word больше {_1} МБ свойств форматирования, это слишком много для помещения +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) в документе Word слишком много текста или форматирования, чтобы поместить его как текст (не более {MAX_PARAGRAPHS} абзацев, {MAX_RUNS} фрагментов форматирования и {_1} МБ текста) +@msg the Word document has too much text to place as type (at most {_1} MB) в документе Word слишком много текста, чтобы поместить его как текст (не более {_1} МБ) +@msg the Word document is encrypted or protected with a password; save it without one to place it документ Word зашифрован или защищён паролем; сохраните его без пароля, чтобы поместить +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) документ Word слишком велик для помещения (его поток {name} содержит больше {_1} МБ) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off документ Word последний раз сохранён с быстрым сохранением, которое VectorCraft не читает; сохраните его снова, отключив быстрое сохранение +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document документ Word сохранён в Word 6.0 или Word 95, а этот формат VectorCraft не читает; сохраните его как документ Word 97-2003 +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place текст документа Word состоит более чем из {MAX_PIECES} частей, это слишком много для помещения +@msg the XML operation needs an XML or XMLList value для операции XML нужно значение XML или XMLList +@msg the XPath expression does not evaluate to a node-set выражение XPath не даёт набор узлов +@msg the XPath expression ends early выражение XPath обрывается +@msg the XPath expression has an unmatched ) в выражении XPath есть непарная ) +@msg the XPath expression has an unmatched ] в выражении XPath есть непарная ] +@msg the XPath expression has an unmatched closing bracket в выражении XPath есть непарная закрывающая скобка +@msg the XPath expression selects a namespace node, which is not an XML value выражение XPath выбирает узел пространства имён, который не является значением XML +@msg the XPath expression selects the root node, which is not an XML value выражение XPath выбирает корневой узел, который не является значением XML +@msg the XPath function {name} needs a node-set функции XPath {name} нужен набор узлов +@msg the XPath function {name} takes the wrong number of arguments функции XPath {name} передано неверное число аргументов +@msg the XPath name functions need a node-set функциям имён XPath нужен набор узлов +@msg the XPath prefix {shown} is not bound to a namespace префикс XPath {shown} не привязан к пространству имён +@msg the XPath variable ${shown} is not bound переменная XPath ${shown} не привязана +@msg the action set ids are not a list идентификаторы наборов действий не являются списком +@msg the active paint is not a gradient активная краска не является градиентом +@msg the argument list is not an object список аргументов не является объектом +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size монтажные области не помещаются на холсте: уменьшите их число, интервал или размер +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out графика слишком велика для EPS: всё, что следует после 256 МБ PostScript, пропущено +@msg the banner has no width or height у баннера нет ширины или высоты +@msg the bitmaps hold more pixels than one file may растровые изображения содержат больше пикселей, чем допустимо для одного файла +@msg the character's size can't be measured размер символа невозможно измерить +@msg the code runs past its last instruction код выполняется дальше своей последней инструкции +@msg the compound shape is too complex to expand составная фигура слишком сложна, чтобы её разобрать +@msg the container of the listed objects is gone контейнер перечисленных объектов больше не существует +@msg the copy `{user_id}` can't be read as a library копию `{user_id}` невозможно прочитать как библиотеку +@msg the copy would leave the canvas копия вышла бы за пределы холста +@msg the curve tolerance must be from 0.01 to 10 points допуск кривой должен быть от 0.01 до 10 пунктов +@msg the custom dictionary couldn't be saved to {_1}: {e} пользовательский словарь не удалось сохранить в {_1}: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is пользовательский словарь {_1} невозможно прочитать ({why}), поэтому он оставлен без изменений +@msg the cut cannot be made on this art: {e} разрез невозможно выполнить на этой графике: {e} +@msg the data isn't an EMF or WMF picture данные не являются рисунком EMF или WMF +@msg the data isn't an EPS or PostScript picture данные не являются рисунком EPS или PostScript +@msg the deferred paste is no longer available отложенная вставка больше недоступна +@msg the design is too big to draw at every data point of this graph дизайн слишком велик, чтобы рисовать его в каждой точке данных этой диаграммы +@msg the design is too big to draw in every column of this graph дизайн слишком велик, чтобы рисовать его в каждом столбце этой диаграммы +@msg the dialog it was opened over is no longer open диалоговое окно, поверх которого оно было открыто, больше не открыто +@msg the dictionary stack overflowed стек словарей переполнен +@msg the document already has an SVG filter named {name} в документе уже есть фильтр SVG с именем {name} +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it в документе есть другой фильтр SVG с именем {name}: используйте svgFilter.apply или, чтобы изменить его, svgFilter.define с `replace` +@msg the document has no SVG filter named {name} в документе нет фильтра SVG с именем {name} +@msg the document has no SVG filter named {r} в документе нет фильтра SVG с именем {r} +@msg the document has no color swatches в документе нет цветовых образцов +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 образцы документа содержат цветов: {_1}; редукция document принимает не более 255 +@msg the editing data's hash has no place для хеша данных редактирования нет места +@msg the entities the document declares expand to too much text сущности, объявленные в документе, разворачиваются в слишком большой текст +@msg the envelope's content has no bounds у содержимого оболочки нет границ +@msg the envelope's mesh is malformed сетка оболочки повреждена +@msg the file holds no `variableSets` element в файле нет элемента `variableSets` +@msg the file is larger than {_1} MB файл больше {_1} МБ +@msg the file is too large файл слишком велик +@msg the file isn't valid SVG: {e} файл не является допустимым SVG: {e} +@msg the file names no plate файл не указывает ни одной пластины +@msg the file was updated or encrypted already файл уже обновлён или зашифрован +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) перед первым символом строки нет символа для кернинга: он перемещается только вверх и вниз (dy) +@msg the font {_1} is not available шрифт {_1} недоступен +@msg the format has no file extension у формата нет расширения файла +@msg the function's prototype is not an object прототип функции не является объектом +@msg the graphic can't be read: {e} графику невозможно прочитать: {e} +@msg the graphic is empty графика пуста +@msg the graphic's data is damaged данные графики повреждены +@msg the graphic's document has no artboard в документе графики нет монтажной области +@msg the grommet inset is too large for the banner отступ люверсов слишком велик для баннера +@msg the guide is hidden or locked направляющая скрыта или заблокирована +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them заголовок `{cell}` для `{name}` при чтении дал бы переменную `{other}`: переименуйте одну из них +@msg the hem is too deep for the banner подгиб слишком широк для баннера +@msg the image `{path}` isn't followed: {r} переход к изображению `{path}` не выполняется: {r} +@msg the image has no embedded pixels у изображения нет встроенных пикселей +@msg the image is empty изображение пусто +@msg the image is fully transparent изображение полностью прозрачно +@msg the image is not on an artboard; pass rect изображение не находится на монтажной области; передайте rect +@msg the image's pixels are missing пиксели изображения отсутствуют +@msg the image's pixels aren't in the document пикселей изображения нет в документе +@msg the image's pixels can't be read: {e} пиксели изображения невозможно прочитать: {e} +@msg the image's transparency doesn't fit it прозрачность изображения не соответствует ему +@msg the job would print more than {MAX_PAGES} pages задание напечатало бы больше {MAX_PAGES} страниц +@msg the job would print more than {_1} pages задание напечатало бы больше {_1} страниц +@msg the layers hold no art to mask в слоях нет графики для маски +@msg the layout moved расположение изменилось +@msg the library has a group `{name}` already в библиотеке уже есть группа `{name}` +@msg the library has no group `{name}` в библиотеке нет группы `{name}` +@msg the library has no item `{item}` в библиотеке нет элемента `{item}` +@msg the library has no item `{other}` в библиотеке нет элемента `{other}` +@msg the library holds {MAX_GROUPS} groups already в библиотеке уже наибольшее число групп: {MAX_GROUPS} +@msg the library holds {MAX_ITEMS} items of this kind already в библиотеке уже наибольшее число элементов этого вида: {MAX_ITEMS} +@msg the library's file is outside the read roots файл библиотеки находится вне папок, разрешённых для чтения +@msg the line is too long, or the paths it passes too many, to check линия слишком длинна или пересекает слишком много контуров для проверки +@msg the method needs an XML value методу нужно значение XML +@msg the name is empty имя пусто +@msg the object has no fill, stroke or effect у объекта нет заливки, обводки или эффекта +@msg the object has no graphic style (pass `name`) у объекта нет графического стиля (передайте `name`) +@msg the objects don't match the plan объекты не соответствуют плану +@msg the open and permissions passwords must differ пароль для открытия и пароль прав доступа должны различаться +@msg the operand stack overflowed стек операндов переполнен +@msg the page has no area у страницы нет площади +@msg the page's type in an unread object's box isn't in its page art текста страницы в рамке непрочитанного объекта нет в графике этой страницы +@msg the paint behind the proxy is not a gradient краска за индикатором не является градиентом +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) краска не является произвольным градиентом (примените его через paint.editGradient {kind}) +@msg the parameters or body do not form one function параметры или тело не образуют одну функцию +@msg the parent must be a layer or sublayer родитель должен быть слоем или подслоем +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it вставленный фильтр SVG {name} рисует иначе, чем фильтр документа с тем же именем, и свободного имени для него не нашлось +@msg the path can't be extended контур невозможно продлить +@msg the path can't be inside one of the objects to attach контур не может находиться внутри одного из прикрепляемых объектов +@msg the path has no segments to measure у контура нет сегментов для измерения +@msg the path is empty контур пуст +@msg the path is too short to shorten that much контур слишком короток, чтобы укоротить его настолько +@msg the paths are too many or too tangled to find where they cross контуров слишком много, или они слишком запутаны, чтобы найти их пересечения +@msg the pen table holds more than 255 pens таблица перьев содержит больше 255 перьев +@msg the picture has no objects в рисунке нет объектов +@msg the plates can't be kept пластины невозможно сохранить +@msg the position table does not match the instructions таблица позиций не соответствует инструкциям +@msg the program runs too long программа выполняется слишком долго +@msg the program uses too much memory программа использует слишком много памяти +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes текст запроса длиннее {MAX_PROMPT_BYTES} байт +@msg the radix must be between 2 and 36 основание системы счисления должно быть от 2 до 36 +@msg the rectangle must have a width and a height у прямоугольника должны быть ширина и высота +@msg the rectangle needs a size and must lie on the canvas прямоугольнику нужен размер, и он должен находиться на холсте +@msg the rectangle or line reaches too many paths to check прямоугольник или линия затрагивает слишком много контуров для проверки +@msg the right side of 'in' is not an object правая часть 'in' не является объектом +@msg the right side of 'instanceof' is not a function правая часть 'instanceof' не является функцией +@msg the selected art has no width у выделенной графики нет ширины +@msg the selected art is nested more than {MAX_DEPTH} levels deep выделенная графика вложена глубже {MAX_DEPTH} уровней +@msg the selected art is too large to measure выделенная графика слишком велика для измерения +@msg the selection draws nothing выделение ничего не рисует +@msg the selection has no area у выделения нет площади +@msg the selection has no art to export в выделении нет графики для экспорта +@msg the selection has no bounds у выделения нет границ +@msg the selection has no graphic style у выделения нет графического стиля +@msg the selection has no outlines to weed around в выделении нет контуров, вокруг которых делается выборка +@msg the slice would leave the canvas фрагмент вышел бы за пределы холста +@msg the spacing makes more than {MAX_GROMMETS} grommets при таком интервале получается больше {MAX_GROMMETS} люверсов +@msg the spot colour couldn't be added плашечный цвет не удалось добавить +@msg the subpath has no ends to extend (it is closed or a single point) у вложенного контура нет концов для продления (он замкнут или состоит из одной точки) +@msg the symbols are nested too deeply to expand their instances символы вложены слишком глубоко, чтобы разобрать их экземпляры +@msg the text file is too large to place as type (at most {_1} MB) текстовый файл слишком велик, чтобы поместить его как текст (не более {_1} МБ) +@msg the text is empty текст пуст +@msg the text is over {_1} MB текст больше {_1} МБ +@msg the top object must be a path верхний объект должен быть контуром +@msg the trace was cancelled трассировка отменена +@msg the trailer has no size в трейлере нет размера +@msg the type object is flattened to nothing после сведения от текстового объекта ничего не остаётся +@msg the value bind was called on is not a function значение, для которого вызван bind, не является функцией +@msg the value is not a constructor значение не является конструктором +@msg the value is not a function значение не является функцией +@msg the value is not a valid XML name значение не является допустимым именем XML +@msg the value is not an XML attribute name значение не является именем атрибута XML +@msg the value is not an XML name значение не является именем XML +@msg the value of axis `{tag}` must be a number or null значение оси `{tag}` должно быть числом или null +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets библиотека переменных объявляет больше {MAX_LIBRARY_DATASETS} наборов данных +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities библиотека переменных объявляет больше {MAX_LIBRARY_ENTITIES} сущностей +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables библиотека переменных объявляет больше {MAX_LIBRARY_VARIABLES} переменных +@msg the variable library declares no variables библиотека переменных не объявляет ни одной переменной +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes библиотека переменных больше {MAX_LIBRARY_BYTES} байт +@msg the variable library is not UTF-8 библиотека переменных не в кодировке UTF-8 +@msg the variable library is not well-formed XML: {e} библиотека переменных не является корректным XML: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep в библиотеке переменных элементы вложены глубже {MAX_LIBRARY_NESTING} уровней +@msg the variable library's document type declaration holds something other than entities of plain text объявление типа документа библиотеки переменных содержит что-то кроме сущностей с простым текстом +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes сущности библиотеки переменных разворачиваются более чем в {MAX_LIBRARY_BYTES} байт +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines линии выборки пересекают контуры графики слишком много раз для измерения; используйте меньше линий +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap линии выборки проходят слишком близко к слишком большому числу сегментов контуров для измерения; используйте меньше линий или меньший зазор +@msg the weeding lines would be more than {max_pieces} pieces линии выборки состояли бы более чем из {max_pieces} частей +@msg the window has no dock у окна нет области прикрепления +@msg the written PDF has no catalog в записанном PDF нет каталога +@msg the written PDF has no cross-reference table в записанном PDF нет таблицы перекрёстных ссылок +@msg the written PDF's metadata can't be edited метаданные записанного PDF невозможно изменить +@msg the {_1} plate lies outside the file пластина {_1} находится за пределами файла +@msg there are no colour swatches to draw нет цветовых образцов для отрисовки +@msg there is already a design named `{name}` дизайн с именем `{name}` уже существует +@msg there is already a design named `{to}` дизайн с именем `{to}` уже существует +@msg there is already a toolbar named `{name}` панель инструментов с именем `{name}` уже существует +@msg there is already a toolbar named `{to}` панель инструментов с именем `{to}` уже существует +@msg there is no banner to mark нет баннера для разметки +@msg there is no library: create one first библиотеки нет: сначала создайте её +@msg these paths are too degenerate for Shaper эти контуры слишком вырождены для Shaper +@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that это двоичный файл DXF, который невозможно прочитать: сохраните его из САПР как ASCII DXF и откройте полученный файл +@msg this warp's points can't be edited: use Reset with Mesh first точки этой деформации нельзя редактировать: сначала используйте «Сбросить с сеткой» +@msg thumbnail: no page {v} (the {what} has {count}) thumbnail: страница {v} не существует ({what}, всего страниц: {count}) +@msg tileRange of {what}: {e} tileRange для {what}: {e} +@msg title can't be empty title не может быть пустым +@msg to (the new name) is required требуется to (новое имя) +@msg to must be fill or stroke to должно быть fill или stroke +@msg to must be smooth or corner to должно быть smooth или corner +@msg toExponential() digits must be between 0 and 20 число цифр для toExponential() должно быть от 0 до 20 +@msg toFixed() digits must be between 0 and 20 число цифр для toFixed() должно быть от 0 до 20 +@msg toISOString is not a function toISOString не является функцией +@msg toPrecision() argument must be between 1 and 21 аргумент toPrecision() должен быть от 1 до 21 +@msg toSource: a getter cannot be written as source toSource: геттер невозможно записать как исходный код +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: массив со свойствами помимо элементов, с неперечислимым свойством или с другим прототипом невозможно записать как исходный код +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: объект с неперечислимым свойством или с прототипом, отличным от Object.prototype, невозможно записать как исходный код +@msg toSource: the source is too long toSource: исходный код слишком длинный +@msg toSource: the value contains itself toSource: значение содержит само себя +@msg toSource: the value has too many entries toSource: в значении слишком много записей +@msg toSource: the value is nested too deep toSource: значение вложено слишком глубоко +@msg toString is not a function toString не является функцией +@msg too large for a zip archive: package to a folder слишком велико для архива zip: упакуйте в папку +@msg too many Shaper edits слишком много правок Shaper +@msg too many `points` (at most 100000) слишком много `points` (не более 100000) +@msg too many arguments слишком много аргументов +@msg too many characters: at most {MAX_CHARS} слишком много символов: не более {MAX_CHARS} +@msg too many dots for this size: lower the frequency слишком много точек для этого размера: уменьшите частоту +@msg too many filters over one file слишком много фильтров для одного файла +@msg too many overlapping paths for Shaper слишком много перекрывающихся контуров для Shaper +@msg too many quantifiers слишком много квантификаторов +@msg too many saved clips слишком много сохранённых областей обрезки +@msg too many saved graphics states слишком много сохранённых графических состояний +@msg toolbar (a toolbar's name) must be a string toolbar (имя панели инструментов) должно быть строкой +@msg toolbar name can't be empty имя панели инструментов не может быть пустым +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (по умолчанию) или "omit" +@msg turn on at least one side включите хотя бы одну сторону +@msg two saved selections would be named `{_1}` два сохранённых выделения получили бы имя `{_1}` +@msg type dimension cannot be represented with this text transform размер текста невозможно представить с этой трансформацией текста +@msg type dimension must be finite размер текста должен быть конечным +@msg unexpected resources непредвиденные ресурсы +@msg unknown CMYK profile `{name}` неизвестный профиль CMYK `{name}` +@msg unknown File Info field `{k}` неизвестное поле сведений о файле `{k}` +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) неизвестная функция OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; префикс - отключает функцию; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom и aalt принимают номер альтернативы в виде `tag=N`, где N от 0 до {_1}, например `salt=2`) +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) неизвестная функция OpenType `{t}` в featureChanges (допустимы теги, которые принимает `features`) +@msg unknown PDF preset `{name}` (presets: {_1}) неизвестный набор PDF `{name}` (наборы: {_1}) +@msg unknown Warp style неизвестный стиль деформации +@msg unknown alignToPath `{a}` неизвестное значение alignToPath `{a}` +@msg unknown arrowhead {v} неизвестная стрелка {v} +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) неизвестный ключ artboardLayout `{k}` (layout, columns, spacing, rightToLeft) +@msg unknown attribute `{attribute}` неизвестный атрибут `{attribute}` +@msg unknown attribute `{k}` неизвестный атрибут `{k}` +@msg unknown axis `{other}` (vertical|horizontal|free) неизвестная ось `{other}` (vertical|horizontal|free) +@msg unknown blend mode `{b}` неизвестный режим наложения `{b}` +@msg unknown blend mode {b} неизвестный режим наложения {b} +@msg unknown brush type `{o}` неизвестный тип кисти `{o}` +@msg unknown category `{c}` неизвестная категория `{c}` +@msg unknown category `{c}` ({_1}) неизвестная категория `{c}` ({_1}) +@msg unknown character `{c}` неизвестный символ `{c}` +@msg unknown character style number field неизвестное числовое поле стиля символов +@msg unknown dither `{d}` (diffusion, pattern, noise or none) неизвестный дизеринг `{d}` (diffusion, pattern, noise или none) +@msg unknown effect `{e}` неизвестный эффект `{e}` +@msg unknown end {e} неизвестное значение end {e} +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) неизвестный flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution или сохранённый набор: см. flattener.presets.list) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) неизвестный формат `{f}` (vcswatches, gpl, ase или css) +@msg unknown from {f} неизвестное значение from {f} +@msg unknown graph type `{t}` неизвестный тип диаграммы `{t}` +@msg unknown grid colours `{name}` (one of {_1}) неизвестные цвета сетки `{name}` (допустимо одно из: {_1}) +@msg unknown gridType `{t}` неизвестное значение gridType `{t}` +@msg unknown highlight `{v}` неизвестное значение highlight `{v}` +@msg unknown intent `{i}` неизвестное значение intent `{i}` +@msg unknown issue kind {id} неизвестный вид проблемы {id} +@msg unknown issue kind {v} неизвестный вид проблемы {v} +@msg unknown knockout state {k} неизвестное состояние вырезания {k} +@msg unknown language `{language}` (one of {_1}) неизвестный язык `{language}` (допустимо одно из: {_1}) +@msg unknown language `{l}` (one of {_1}) неизвестный язык `{l}` (допустимо одно из: {_1}) +@msg unknown method `{m}` ({_1}) неизвестный метод `{m}` ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) неизвестный режим `{other}` (gray, rgb, hsb, lab, cmyk или web) +@msg unknown mode `{other}` (mono|cmyk) неизвестный режим `{other}` (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) неизвестный режим `{other}` (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) неизвестный режим `{other}` (set, add, subtract) +@msg unknown mode `{o}` неизвестный режим `{o}` +@msg unknown mode {m} неизвестный режим {m} +@msg unknown model `{other}` неизвестная модель `{other}` +@msg unknown model `{other}` (keep, new or document) неизвестная модель `{other}` (keep, new или document) +@msg unknown op {o} неизвестная операция {o} +@msg unknown order `{o}`: toggle, front, forward, backward or back неизвестный порядок `{o}`: toggle, front, forward, backward или back +@msg unknown overprints `{v}` (preserve, simulate or discard) неизвестное значение overprints `{v}` (preserve, simulate или discard) +@msg unknown panel неизвестная панель +@msg unknown panel `{panel}` неизвестная панель `{panel}` +@msg unknown parameter `{key}` неизвестный параметр `{key}` +@msg unknown parameter `{within}settings.{format}`; {why} неизвестный параметр `{within}settings.{format}`; {why} +@msg unknown parameter `{within}{key}`; {why} неизвестный параметр `{within}{key}`; {why} +@msg unknown pieLegend `{v}` (none, standard or wedges) неизвестное значение pieLegend `{v}` (none, standard или wedges) +@msg unknown piePosition `{v}` (even, ratio or stacked) неизвестное значение piePosition `{v}` (even, ratio или stacked) +@msg unknown pieSort `{v}` (none, all or first) неизвестное значение pieSort `{v}` (none, all или first) +@msg unknown plate `{n}` неизвестная пластина `{n}` +@msg unknown preference `{k}` неизвестная настройка `{k}` +@msg unknown profile {id} (see stroke.widthProfile.list) неизвестный профиль {id} (см. stroke.widthProfile.list) +@msg unknown proof target `{t}` неизвестная цель цветопробы `{t}` +@msg unknown quotes key `{k}` (double, single) неизвестный ключ quotes `{k}` (double, single) +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) неизвестная редукция `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform или document) +@msg unknown resolution `{v}` неизвестное разрешение `{v}` +@msg unknown rule `{rule}` ({_1}) неизвестное правило `{rule}` ({_1}) +@msg unknown rule `{r}` (see color.harmony) неизвестное правило `{r}` (см. color.harmony) +@msg unknown setting `{k}` неизвестный параметр настройки `{k}` +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) неизвестная фигура `{v}` (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) неизвестный интервал `{o}` (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) неизвестное значение tileType `{t}` ({_1}) +@msg unknown units неизвестные единицы +@msg unknown units `{u}` неизвестные единицы `{u}` +@msg unknown value axis `{v}` (left, right or both) неизвестная ось значений `{v}` (left, right или both) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) неизвестный вариант `{v}` (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` неизвестный стиль деформации `{s}` +@msg unknown {key} `{v}` (none, short or full) неизвестное значение {key} `{v}` (none, short или full) +@msg unknown {key} profile `{n}` неизвестный профиль {key} `{n}` +@msg unknown {k} key `{key}` (size, position) неизвестный ключ {k} `{key}` (size, position) +@msg unmatched ')' непарная ')' +@msg unreadable object нечитаемый объект +@msg unsafe destination file name небезопасное имя файла назначения +@msg unterminated character class незавершённый класс символов +@msg unterminated group незавершённая группа +@msg use paths or compound paths используйте контуры или составные контуры +@msg useArtboards must be true or false, not {v} useArtboards должно быть true или false, а не {v} +@msg value for `{var}` must match its variable kind значение `{var}` должно соответствовать типу переменной +@msg variable `{_1}` is already defined переменная `{_1}` уже определена +@msg variable `{name}` has no `trait` у переменной `{name}` нет `trait` +@msg variable `{name}` is already defined переменная `{name}` уже определена +@msg variable `{name}` is declared twice переменная `{name}` объявлена дважды +@msg variable `{to}` already exists переменная `{to}` уже существует +@msg view is required требуется view +@msg view must be one of {_1} view должно быть одним из: {_1} +@msg warpStyle can only edit an applied Warp effect warpStyle может изменять только применённый эффект «Деформация» +@msg width and height must be more than 0 and at most {limit} width и height должны быть больше 0 и не больше {limit} +@msg width factors must be finite коэффициенты ширины должны быть конечными +@msg width must be positive width должно быть положительным +@msg width out of range ширина вне диапазона +@msg width/height must be > 0 width/height должны быть > 0 +@msg with no hem and no grommets there is nothing to mark без подгиба и люверсов размечать нечего +@msg wordGap and lineGap need area type in the selection для wordGap и lineGap в выделении нужен текст в области +@msg x and y must be finite numbers x и y должны быть конечными числами +@msg x and y must be given together x и y нужно указывать вместе +@msg xpath needs its variables as an object для xpath переменные нужны в виде объекта +@msg xpath needs the expression as a string для xpath выражение нужно в виде строки +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) для zone нужен anchor: прикрепите рядом с панелью через anchor или в новом столбце прикрепления через side (Start или End) +@msg zoom must be positive zoom должно быть положительным +# Messages that begin with a placeholder. +@msg {C}: no effect at index {list} {C}: нет эффекта с индексом {list} +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} защищён и не удаляется +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} защищён: сохраните настройки под другим именем +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}, а его настройки и кэш невозможно переместить обратно из {_1} в {_2}: {e} +@msg {LABEL} produced an empty result {LABEL}: получен пустой результат +@msg {_1} MB of document, at most {_2} MB документ размером {_1} МБ при допустимых {_2} МБ +@msg {_1} MB of images, at most {_2} MB изображения размером {_1} МБ при допустимых {_2} МБ +@msg {_1} bytes of RGBA data for a {width} × {height} image данных RGBA: {_1} байт для изображения {width} × {height} +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1} невозможно прочитать, или файл изменился после чтения: сначала восстановите связь или обновите его +@msg {_1} can't go into {into} {_1} нельзя поместить в {into} +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1} не может ограничить печать низким разрешением: выберите PDF 1.4 или новее либо разрешите печать с высоким разрешением +@msg {_1} files can't be opened yet файлы {_1} пока нельзя открыть +@msg {_1} files can't be password-protected: remove the passwords or choose no standard файлы {_1} нельзя защитить паролем: уберите пароли или не выбирайте стандарт +@msg {_1} files can't be {_2} файлы {_1} не могут быть {_2} +@msg {_1} files can't carry editing data: turn off preserveEditing файлы {_1} не могут содержать данные редактирования: отключите preserveEditing +@msg {_1} files can't have PDF layers: turn off createLayers файлы {_1} не могут содержать слои PDF: отключите createLayers +@msg {_1} files need a creation date, and this platform has no clock to give one файлам {_1} нужна дата создания, а на этой платформе нет часов, чтобы её получить +@msg {_1} is not a folder: {e} {_1} не является папкой: {e} +@msg {_1} isn't a path {_1} не является контуром +@msg {_1} options: {e} параметры {_1}: {e} +@msg {_1} paints nothing to rasterize {_1} не рисует ничего, что можно растеризовать +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) пароли {_1} могут содержать до {MAX_LEGACY_PASSWORD} символов ASCII (PDF 1.7 и новее принимают любые) +@msg {_1} with linked images or an image map writes several files: use document.export {_1} со связанными изображениями или картой изображения записывает несколько файлов: используйте document.export +@msg {_1} writes one file per artboard here: use document.export, or name one artboard здесь {_1} записывает по одному файлу на монтажную область: используйте document.export или укажите одну монтажную область +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} пикселей слишком много для {_3} (не более {max} пикселей по стороне): уменьшите масштаб +@msg {_1}: belongs to another account {_1}: принадлежит другой учётной записи +@msg {_1}: not a folder of its own {_1}: не отдельная папка +@msg {_1}: not a plain file {_1}: не обычный файл +@msg {_1}: {_2} and {_3} are in the way {_1}: мешают {_2} и {_3} +@msg {code}: {0} {code}: {0} +@msg {dest}: {e} {dest}: {e} +@msg {dir}: {e} {dir}: {e} +@msg {doc} has no folder у {doc} нет папки +@msg {file} isn't linked {file} не связан +@msg {file} names more than one linked JavaScript file {file} обозначает больше одного связанного файла JavaScript +@msg {folder} is not a package folder {folder} не является папкой пакета +@msg {folder}: more than {max} files and folders {folder}: больше {max} файлов и папок +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group {g} не является группой быстрой заливки +@msg {id} is not a slice {id} не является фрагментом +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key} должно быть 0–{MAX_ADD_AROUND} pt +@msg {key} must be [x,y] {key} должно быть [x,y] +@msg {key} must be a percentage {min}..100 {key} должно быть процентом {min}..100 +@msg {key} must be a positive number, not {n} {key} должно быть положительным числом, а не {n} +@msg {key} must be a positive number, not {v} {key} должно быть положительным числом, а не {v} +@msg {key} must be one of {_1} {key} должно быть одним из: {_1} +@msg {key} must be {_1}, got {s} {key} должно быть {_1}, получено {s} +@msg {key} must name a floating panel {key} должно указывать плавающую панель +@msg {key} must name a panel in another group {key} должно указывать панель в другой группе +@msg {key} must name a panel that shows {key} должно указывать показанную панель +@msg {key} must name a registered panel {key} должно указывать зарегистрированную панель +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) в файлах {kind} нет прозрачности, а в файле оказалось бы {what}: сначала объедините прозрачность (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) изображения {kind} невозможно открыть (см. document.formats) +@msg {k} `{v}` is not one of the choices {k} `{v}` не входит в число допустимых вариантов +@msg {k} has control characters{_1} {k} содержит управляющие символы{_1} +@msg {k} is too long (at most {MAX_TEXT} characters) {k} слишком длинно (не более {MAX_TEXT} символов) +@msg {k} must be > 0 {k} должно быть > 0 +@msg {k} must be a number (pt) or a length such as "210 mm" {k} должно быть числом (pt) или длиной, например "210 mm" +@msg {k} must be a number from {min} to {max} {k} должно быть числом от {min} до {max} +@msg {k} must be a number or "auto" {k} должно быть числом или "auto" +@msg {k} must be a string {k} должно быть строкой +@msg {k} must be text {k} должно быть текстом +@msg {k} must be true or false {k} должно быть true или false +@msg {k} must be {{size?, position?}} {k} должно быть {{size?, position?}} +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: контуру пользовательской стрелки нужна область для заливки, не более {most} опорных точек и {most} вложенных контуров, причём каждая опорная точка должна быть не дальше {far} единиц наконечника от его острия +@msg {k}: bad custom arrowhead path: {e} {k}: недопустимый контур пользовательской стрелки: {e} +@msg {k}: bad custom arrowhead: {e} {k}: недопустимая пользовательская стрелка: {e} +@msg {k}: can't read `{s}` as a length {k}: `{s}` невозможно прочитать как длину +@msg {k}: custom must be a name {k}: custom должно быть именем +@msg {label} must be a number at most {max} {label} должно быть числом не больше {max} +@msg {label} must be a number from -1296 to 1296 {label} должно быть числом от -1296 до 1296 +@msg {label} must be a number from 1 to 10000 {label} должно быть числом от 1 до 10000 +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name} встроен, его нельзя удалить +@msg {name} must be a number {name} должно быть числом +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name} должно быть {lo}–{hi}{unit} (получено {v}) +@msg {name}: CRC or size mismatch {name}: несовпадение CRC или размера +@msg {name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing {name}: удалось прочитать только встроенный предпросмотр этого файла Affinity; используйте «Файл › Открыть», чтобы увидеть его с предупреждением, или перед помещением экспортируйте SVG или PDF из Affinity +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}; его встроенный предпросмотр тоже невозможно прочитать ({e}) +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy принимает файл DCS, только когда его пластины находятся внутри него; восстановите связь с ним там, где находятся файлы его пластин +@msg {path}: larger than {_1} MB {path}: больше {_1} МБ +@msg {saving} failed: {e} {saving}: ошибка: {e} +@msg {shown} is not an XPath 1.0 function {shown} не является функцией XPath 1.0 +@msg {std} files are CMYK, and the file would have colours in {_1} файлы {std} используют CMYK, а в файле оказались бы цвета в {_1} +@msg {std} files embed their fonts, and the file would have one that isn't файлы {std} внедряют свои шрифты, а в файле оказался бы невнедрённый шрифт +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target} — файл, из которого прочитан этот документ, и при чтении часть содержимого была пропущена ({what}): при записи поверх него она была бы потеряна навсегда. Запишите другой файл или передайте acknowledgeLoss: true, чтобы всё равно заменить его +@msg {ty} brushes need selected art (or params.{k}) кистям {ty} нужна выделенная графика (или params.{k}) +@msg {v} is not an asset id {v} не является идентификатором ресурса +@msg {v} names no layer or sublayer {v} не указывает ни слой, ни подслой +@msg {what} can't be intertwined {what} нельзя переплести +@msg {what} doesn't fit the paper with its bleed and marks {what} не помещается на бумаге вместе с вылетом и метками +@msg {what} is not a function {what} не является функцией +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up для {what} на листе печаталось бы больше {MAX_COPIES_PER_SHEET} копий: увеличьте масштаб +@msg {what} would print on more than {MAX_TILES} tiles: scale it down для {what} потребовалось бы больше {MAX_TILES} плиток: уменьшите масштаб +@msg {what} {i} out of range {what} {i} вне диапазона diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 6c9deb97d0..12cbbb07e0 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -3505,6 +3505,27 @@ fn dataset_import_cell_errors_are_translated() { } } +/// Russian shows command, script and file messages in Russian: a whole message, a message that +/// begins with a feature name, messages with a name, a number or a command id filled in, and the +/// two messages whose catalog rows escape a backslash. +#[test] +fn russian_translates_command_script_and_file_messages() { + let ru = Lang::from_code("ru").expect("registered"); + for (english, russian) in [ + ("CSV is empty", "CSV пуст"), + ("Offset Path: the path is too complex to offset", "Смещение контура: контур слишком сложен для смещения"), + ("no swatch named `Gold`", "нет образца с именем `Gold`"), + ("object 12 is not an Intertwine group", "объект 12 не является группой переплетения"), + ("no slice 4 in the image (see slice.list)", "в изображении нет фрагмента 4 (см. slice.list)"), + ("give origin: [x, y], or reset: true", "укажите origin: [x, y] или reset: true"), + ("`kinsoku` must be \"none\", \"hard\" or \"soft\"", "`kinsoku` должно быть \"none\", \"hard\" или \"soft\""), + ("\\ at end of pattern", "\\ в конце шаблона"), + ("a \\u escape needs four hexadecimal digits", "после \\u нужны четыре шестнадцатеричные цифры"), + ] { + assert_eq!(message(ru, english), russian, "{english}"); + } +} + /// The errors of the parameter checks that many commands share (a number given as text, a missing /// number or index, a fraction for a whole number, `ids` that aren't a list of object ids, an /// `ids` entry or an `id` that is negative, an object id that names no object, a date that isn't diff --git a/docs/localization-parity.md b/docs/localization-parity.md index cd8dc33f30..81d4183b07 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -53,7 +53,7 @@ plus review. Native-speaker review is human work and isn't in the hours. | Italian | `it` | 4,011 / 4,020 | full | | Dutch | `nl` | 7,299 rows, 1,609 of them `@msg` and 47 `@msgplural` (counted 2026-11-13 on `main` at `9a1a158a3`): in `COMPLETE_MESSAGES`, and the untranslated report lists the same missing messages for it as for German; one/other plurals | full | | Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full | -| Russian | `ru` | 4,009 / 4,020 | full | +| Russian | `ru` | 9,259 rows, 3,444 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): in `COMPLETE_MESSAGES`; the untranslated report lists 46 menu strings, 21 `tl!` literals and 11 undo labels as missing | full | | Ukrainian | `uk` | 4,009 / 4,020 (one/few/many plurals) | full | | Hungarian | `hu` | 5,253 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | full | | Polish | `pl` | 5,130 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/few/many plurals | full; native review pending | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 26681e5b7f..66899f2898 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1355,6 +1355,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Selection tool, the Tabs panel shows the ruler and stops of the first paragraph of the first selected text object, also when the text is inside a selected group or a shape is selected before it; a selection without text shows "No text selected". A stop set on that ruler is set on every selected text object and the text in selected groups, and other objects are left as they were. This is VectorCraft's own rule. Tests `panels::tabs::tests::the_ruler_shows_text_selected_after_an_object_that_is_not_text`, `the_ruler_shows_text_inside_a_selected_group`, `a_stop_added_on_a_mixed_selection_sets_every_selected_text` and `a_selection_without_text_shows_no_ruler`. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Finnish and Dutch also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). + The Russian catalog (`crates/ui-egui/src/i18n/ru.tsv`) has 1,811 more `@msg` rows, for status and error messages of commands, scripts and file import and export, 3,444 `@msg` rows in all counted on 2026-11-13 (`russian_translates_command_script_and_file_messages` in `crates/ui-egui/src/i18n/tests.rs`). Scores and estimates are unchanged. With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before. In a frame that is not a rectangle, or with text wrap, a line's first word is measured as it is set (an opening bracket at its start flush, its last character narrowed) when layout looks for a line wide enough for it, and it ends only where the line may break, so kinsoku keeps an opening bracket with the character after it and a No Break run counts whole (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. The Paragraph panel's Mojikumi Set lists the five predefined sets the help page names (None, YakumonoHankaku, GyomatsuYakumonoHankaku, GyomatsuYakumonoZenkaku and YakumonoZenkaku), then Line-end Punctuation Half Width, and `text.setFormat {mojikumi}` takes each of them. YakumonoHankaku sets every bracket, comma and full stop half width and takes a quarter em off each side of a middle dot; GyomatsuYakumonoHankaku keeps punctuation full width inside a line, sets an opening bracket flush at a line's start and a closing mark half width at its end, and takes the quarter em after a middle dot off at a line's end; GyomatsuYakumonoZenkaku keeps punctuation full width and adds a quarter em between Japanese and Latin text; YakumonoZenkaku sets characters as None does. These widths are VectorCraft's own rules, from the help page's one-line description of each set. Both composers and vertical type measure each line as it is set, the sets save with the document and paragraph styles, and a `.ai` save reports the new sets as left out (`crates/text/src/tests/vertical.rs`, `crates/ui-egui/src/panels/paragraph/tests.rs`). Scores and estimates are unchanged. Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). From 9f7e74ece0ceea9da57c64c6d2a7cbef74f274c0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 14:45:00 -0800 Subject: [PATCH 02/24] Color Settings: RGB and CMYK policies keep, take off or convert a document's profiles on open edit.colorSettings takes rgbPolicy (off, preserveEmbedded, convert) and cmykPolicy (the same and preserveNumbers), kept in Color Settings.json. document.open, File > New from Template and File > Revert apply the policy of each model to a profile the file is tagged with that is not the working space: Preserve Embedded Profiles keeps it, Off and Preserve Numbers take it off and keep the numbers, and Convert to Working Space converts that model's colors into the working space and takes it off. The replies list a warning per profile, and a changed document opens with unsaved changes. The Separations panel's Color Settings section has the two lists. A colorMode asked for on open converts after the policy, through the document's own profiles. Document Color Mode also converts the points of freeform gradient swatches. --- crates/color/src/cms.rs | 100 ++++++++++++++- crates/color/src/cms/icc.rs | 42 ++++++ crates/color/src/cms/tests.rs | 36 ++++++ crates/color/src/cms/tests_icc.rs | 15 +++ crates/engine/src/cmd/colormgmt.rs | 132 ++++++++++++++++++- crates/engine/src/cmd/fileio/load.rs | 58 +++++++-- crates/engine/src/cmd/fileio/save.rs | 13 +- crates/engine/tests/color_settings.rs | 156 ++++++++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/gu.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/panels/separations.rs | 33 ++++- docs/mcp.md | 15 ++- docs/target-app-parity.md | 1 + 39 files changed, 717 insertions(+), 24 deletions(-) diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index 7fd8707a99..eb7ddf9b7c 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -94,6 +94,50 @@ impl Intent { } } +/// A color management policy (Edit → Color Settings): what opening a document does with a profile +/// it is tagged with that is not the working space of its model. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Policy { + /// The document loses the profile; its color numbers stay as they are. + Off, + /// The document keeps the profile. + #[default] + PreserveEmbedded, + /// The colors are converted from the profile into the working space, which keeps their + /// appearance, and the document loses the profile. + Convert, + /// CMYK only: the document loses the profile; its color numbers stay as they are. + PreserveNumbers, +} + +impl Policy { + /// The RGB policies, in menu order. + pub const RGB: [Policy; 3] = [Policy::Off, Policy::PreserveEmbedded, Policy::Convert]; + /// The CMYK policies, in menu order. + pub const CMYK: [Policy; 4] = [Policy::Off, Policy::PreserveEmbedded, Policy::Convert, Policy::PreserveNumbers]; + /// Parse an id of [`Policy::id`]. + pub fn parse(s: &str) -> Option { + [Policy::Off, Policy::PreserveEmbedded, Policy::Convert, Policy::PreserveNumbers].into_iter().find(|p| p.id() == s) + } + pub fn id(&self) -> &'static str { + match self { + Policy::Off => "off", + Policy::PreserveEmbedded => "preserveEmbedded", + Policy::Convert => "convert", + Policy::PreserveNumbers => "preserveNumbers", + } + } + pub fn label(&self) -> &'static str { + match self { + Policy::Off => "Off", + Policy::PreserveEmbedded => "Preserve Embedded Profiles", + Policy::Convert => "Convert to Working Space", + Policy::PreserveNumbers => "Preserve Numbers", + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProfileKind { @@ -134,11 +178,25 @@ pub struct ColorSettings { pub intent: Intent, /// Black-point compensation. pub bpc: bool, + /// The color management policy for RGB profiles (one of [`Policy::RGB`]). Settings saved + /// without one take the default. + #[serde(default)] + pub rgb_policy: Policy, + /// The color management policy for CMYK profiles. Settings saved without one take the default. + #[serde(default)] + pub cmyk_policy: Policy, } impl Default for ColorSettings { fn default() -> Self { - Self { rgb: SRGB.into(), cmyk: GENERIC_CMYK.into(), intent: Intent::RelativeColorimetric, bpc: true } + Self { + rgb: SRGB.into(), + cmyk: GENERIC_CMYK.into(), + intent: Intent::RelativeColorimetric, + bpc: true, + rgb_policy: Policy::default(), + cmyk_policy: Policy::default(), + } } } @@ -518,6 +576,41 @@ impl Cms { Color::Rgb { r, g, b } } + /// An RGB or CMYK colour of this context's space as the colour of the same model in `dest`'s + /// space that looks the same, converted with `intent` (Edit → Color Settings › Convert to + /// Working Space). RGB converts directly from profile to profile, with this context's + /// black-point compensation. CMYK converts between two loaded profiles through ProPhoto RGB + /// ([`IccProfile::to_same_model_of`]), and through Lab when either space is built in. Grey and + /// Lab colours stay as they are. + pub fn convert_into(&self, dest: &Cms, c: &Color, intent: Intent) -> Color { + match *c { + Color::Rgb { r, g, b } => { + let icc = |s: &RgbSpace| match s { + RgbSpace::Icc(p) => Some(p.clone()), + RgbSpace::Srgb => builtin_rgb_cache(SRGB), + }; + let [r, g, b] = icc(&self.rgb) + .zip(icc(&dest.rgb)) + .and_then(|(s, d)| s.to_rgb_of(&d, &[r, g, b], intent, self.settings.bpc)) + .unwrap_or_else(|| dest.srgb_to_rgb(self.rgb_to_srgb([r, g, b]))); + Color::Rgb { r, g, b } + } + Color::Cmyk { c, m, y, k } => { + let inks = [c, m, y, k].map(|v| v.clamp(0.0, 1.0)); + let direct = match (&self.cmyk, &dest.cmyk) { + (CmykSpace::Icc(s), CmykSpace::Icc(d)) => s.to_same_model_of(d, &inks, intent), + _ => None, + }; + let [c, m, y, k] = match direct.as_deref() { + Some(&[c, m, y, k]) => [c, m, y, k], + _ => dest.lab_to_cmyk(self.cmyk_to_lab(inks), intent), + }; + Color::Cmyk { c, m, y, k } + } + Color::Gray { .. } | Color::Lab { .. } => *c, + } + } + /// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours). pub fn gamut_error(&self, c: &Color) -> f32 { match c { @@ -966,8 +1059,11 @@ pub fn working_settings() -> ColorSettings { } /// Replace the working settings (Edit → Color Settings) and make them the active settings (fails, -/// leaving both unchanged, if a profile is unknown). +/// leaving both unchanged, if a profile is unknown or the RGB policy is Preserve Numbers). pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> { + if !Policy::RGB.contains(&settings.rgb_policy) { + return Err(CmsError::Unsupported(format!("the RGB policy can't be {}", settings.rgb_policy.id()))); + } let cms = Arc::new(Cms::new(settings)?); *WORKING.write().unwrap_or_else(|e| e.into_inner()) = Some(cms.clone()); install(cms); diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 0b09062b84..2ee6293c9e 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -34,6 +34,9 @@ type Xf = Option>; /// generation only lets the cache drop what reloads left behind. type DirectKey = (u64, u64, usize, bool); +/// The two transforms of [`IccProfile::to_same_model_of`]. +type SameModel = (Arc, Arc); + /// A parsed ICC profile plus lazily-built transforms to/from sRGB. pub struct IccProfile { pub name: String, @@ -49,6 +52,10 @@ pub struct IccProfile { /// Direct transforms into other RGB profiles ([`Self::to_rgb_of`]), by destination, intent /// and black-point compensation. direct: Mutex>)>>, + /// Transforms into other profiles of the same model ([`Self::to_same_model_of`]), keyed as + /// [`Self::direct`] is (black-point compensation always off): into ProPhoto RGB, and from it + /// into the destination. + same_model: Mutex)>>, } /// A transform from one profile into an RGB profile without passing through sRGB: into the @@ -99,6 +106,7 @@ impl IccProfile { to_srgb: Default::default(), from_srgb: Default::default(), direct: Mutex::new(Vec::new()), + same_model: Mutex::new(Vec::new()), }) } @@ -249,6 +257,40 @@ impl IccProfile { Some(Direct { to_linear, to_dest: Some(to_dest), black }) } + /// Device values of this profile → device values of `dest`, a profile of the same model, + /// converted with `intent` through ProPhoto RGB rather than sRGB, so colours outside sRGB + /// keep their place. `None` when `dest` is of another model or no transform can be built. + pub fn to_same_model_of(&self, dest: &IccProfile, v: &[f32], intent: Intent) -> Option> { + if dest.kind != self.kind || v.len() != self.channels() { + return None; + } + let generation = profiles_generation(); + let key = (dest.id, generation, intent_index(intent), false); + let (into, out_of) = { + let mut cache = self.same_model.lock().unwrap_or_else(|e| e.into_inner()); + cache.retain(|(k, _)| k.1 == generation); + match cache.iter().find(|(k, _)| *k == key) { + Some((_, xf)) => xf.clone(), + None => { + let wide = rgb_space(super::PROPHOTO_RGB); + let xf = wide.and_then(|w| { + let into = self.profile.create_transform_f32(self.layout(), &w, Layout::Rgb, Self::opts(intent)).ok()?; + let out_of = w.create_transform_f32(Layout::Rgb, &dest.profile, dest.layout(), Self::opts(intent)).ok()?; + Some((into, out_of)) + }); + cache.push((key, xf.clone())); + xf + } + } + }?; + let mut rgb = [0.0f32; 3]; + into.transform(v, &mut rgb).ok()?; + let rgb = rgb.map(|x| x.clamp(0.0, 1.0)); + let mut out = vec![0.0f32; dest.channels()]; + out_of.transform(&rgb, &mut out).ok()?; + Some(out.into_iter().map(|x| x.clamp(0.0, 1.0)).collect()) + } + /// Whether transforms can be built for this profile (checked at registration). pub fn usable(&self) -> bool { self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some() diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 7b22163525..95e465b623 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -442,3 +442,39 @@ fn a_document_gets_the_same_context_for_the_same_profiles() { let unknown = for_document(Some("no such profile"), Some(SRGB)); assert_eq!(unknown.settings(), &working_settings(), "an unknown profile or one of the other model leaves the working space"); } + +/// The color management policies parse from their ids, Preserve Numbers is a CMYK policy only, and +/// settings saved before the policies existed read with the default policy. +#[test] +fn policies_parse_and_default_in_saved_settings() { + for p in Policy::CMYK { + assert_eq!(Policy::parse(p.id()), Some(p)); + } + assert_eq!(Policy::parse("bogus"), None); + assert!(!Policy::RGB.contains(&Policy::PreserveNumbers)); + let old: ColorSettings = + serde_json::from_str(r#"{"rgb": "sRGB IEC61966-2.1", "cmyk": "Device CMYK (uncalibrated)", "intent": "perceptual", "bpc": false}"#).unwrap(); + assert_eq!((old.rgb_policy, old.cmyk_policy), (Policy::PreserveEmbedded, Policy::PreserveEmbedded)); + let saved = serde_json::to_value(ColorSettings { cmyk_policy: Policy::PreserveNumbers, ..Default::default() }).unwrap(); + assert_eq!((saved["rgbPolicy"].as_str(), saved["cmykPolicy"].as_str()), (Some("preserveEmbedded"), Some("preserveNumbers"))); +} + +/// Convert to Working Space keeps a color's appearance: an RGB color of a wide space converts into +/// sRGB, and a CMYK color of one CMYK space into another, with numbers that look the same. Grey and +/// Lab colors stay as they are. +#[test] +fn convert_into_keeps_the_appearance() { + let wide = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), cmyk: DEVICE_CMYK.into(), ..Default::default() }).unwrap(); + let working = cms(); + let rgb = Color::Rgb { r: 0.25, g: 0.4, b: 0.6 }; + let moved = wide.convert_into(&working, &rgb, Intent::RelativeColorimetric); + assert_ne!(moved, rgb, "the numbers change"); + assert!(de(wide.display_rgb(&rgb), working.display_rgb(&moved)) < 1.0, "{rgb:?} → {moved:?}"); + let ink = Color::Cmyk { c: 0.2, m: 0.4, y: 0.6, k: 0.1 }; + let moved = wide.convert_into(&working, &ink, Intent::RelativeColorimetric); + assert!(matches!(moved, Color::Cmyk { .. }) && moved != ink, "{ink:?} → {moved:?}"); + assert!(delta_e2000(wide.lab(&ink), working.lab(&moved)) < 3.0, "{ink:?} → {moved:?}"); + for c in [Color::Gray { k: 0.3 }, Color::Lab { l: 50.0, a: 10.0, b: -20.0 }] { + assert_eq!(wide.convert_into(&working, &c, Intent::RelativeColorimetric), c); + } +} diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 1d312c09c3..2b8a82a36e 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -252,3 +252,18 @@ fn settings_built_before_a_reload_dont_leave_the_old_transform_to_the_new_ones() assert!(in_srgb.iter().zip(want).any(|(a, b)| (a - b).abs() > 0.05), "sRGB {in_srgb:?} vs ProPhoto {want:?}"); assert!(in_prophoto.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-4), "after the reload {in_prophoto:?}, want {want:?}"); } + +/// A CMYK profile converts into another CMYK profile, ink for ink, and refuses a profile +/// of another model or inks of the wrong count. +#[test] +fn cmyk_profiles_convert_into_each_other_directly() { + let generic = IccProfile::from_bytes(Some("A".into()), &icc_bytes(GENERIC_CMYK).unwrap()).unwrap(); + let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap(); + let srgb = IccProfile::from_bytes(None, &icc_bytes(SRGB).unwrap()).unwrap(); + let inks = [0.2, 0.4, 0.6, 0.1]; + let out = generic.to_same_model_of(&device, &inks, Intent::RelativeColorimetric).unwrap(); + assert_eq!(out.len(), 4); + assert!(out.iter().zip(inks).any(|(a, b)| (a - b).abs() > 0.01), "{out:?}"); + assert!(generic.to_same_model_of(&srgb, &inks, Intent::RelativeColorimetric).is_none()); + assert!(generic.to_same_model_of(&device, &inks[..3], Intent::RelativeColorimetric).is_none()); +} diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 4e4da57f9c..5f06b5166b 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget}; +use vectorcraft_color::cms::{self, ColorSettings, Intent, Model, Policy, ProofTarget}; use vectorcraft_color::recolor::Neutral; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::ColorMode; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Color Settings…", ["Edit"], Some("Cmd+Shift+K"), - "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", + "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool, rgbPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\", cmykPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\"|\"preserveNumbers\"} set the working spaces and the color management policies → {rgb, cmyk, intent, bpc, rgbPolicy, cmykPolicy, profiles: [{name, kind, builtin}]}. A policy decides what opening a document (document.open, File › Revert) does with an RGB or CMYK profile it is tagged with that is not the working space: preserveEmbedded (default) keeps it; off and preserveNumbers take it off and keep the color numbers; convert converts the colors of that model into the working space with the intent and takes it off. The open reply's warnings name each profile taken off or converted, and a document a policy changed opens with unsaved changes. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", always, color_settings ), @@ -163,12 +163,26 @@ fn on_param(p: &Value, key: &str, cmd: &str) -> Result> { switch_or(p, key, false, cmd).map(Some) } +/// The policy `p` gives for `key`, one of `allowed`, or `None` when `p` leaves it out. +fn policy_param(p: &Value, key: &str, allowed: &[Policy], cmd: &str) -> Result> { + let Some(v) = text_param(p, key, cmd)? else { return Ok(None) }; + match Policy::parse(v).filter(|x| allowed.contains(x)) { + Some(x) => Ok(Some(x)), + None => { + let ids: Vec<&str> = allowed.iter().map(Policy::id).collect(); + Err(bad(cmd, format!("`{key}` must be one of {}", ids.join(", ")))) + } + } +} + fn settings_json(st: &ColorSettings) -> Value { json!({ "rgb": st.rgb, "cmyk": st.cmyk, "intent": st.intent.id(), "bpc": st.bpc, + "rgbPolicy": st.rgb_policy.id(), + "cmykPolicy": st.cmyk_policy.id(), "profiles": cms::profiles().iter().map(|p| json!({"name": p.name, "kind": p.kind, "builtin": p.builtin})).collect::>(), }) } @@ -205,6 +219,12 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { st.intent = i; } st.bpc = switch_or(p, "bpc", st.bpc, C)?; + if let Some(v) = policy_param(p, "rgbPolicy", &Policy::RGB, C)? { + st.rgb_policy = v; + } + if let Some(v) = policy_param(p, "cmykPolicy", &Policy::CMYK, C)? { + st.cmyk_policy = v; + } if st != before { cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; // Saved for the next start when the app attached a settings file (#6335). @@ -530,7 +550,12 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, if !convert { return 0; } - let conv = mode_converter(mode, intent, grays); + convert_colors(d, &mode_converter(mode, intent, grays)) +} + +/// Every color of `d` that [`set_color_mode`] converts, converted with `conv` → how many colors of +/// the art changed, as [`set_color_mode`] counts them. +fn convert_colors(d: &mut vectorcraft_doc::Document, conv: &dyn Fn(&Color) -> Color) -> usize { for sw in d.swatches_iter_mut() { match &mut sw.paint { Paint::Solid { color, .. } => *color = conv(color), @@ -538,6 +563,11 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, for st in &mut g.gradient.stops { st.color = conv(&st.color); } + if let Some(freeform) = &mut g.freeform { + for point in &mut freeform.points { + point.color = conv(&point.color); + } + } } _ => {} } @@ -621,6 +651,102 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, in_art } +/// Apply the color management policies of Edit → Color Settings to `d`, a document being opened: +/// each RGB or CMYK profile it is tagged with that is not the working space of its model is kept +/// (Preserve Embedded Profiles), taken off with the color numbers kept (Off, Preserve Numbers), or +/// taken off after the colors of that model were converted from it into the working space with the +/// working intent (Convert to Working Space). A profile that is not available here is taken off +/// by Off and Preserve Numbers and kept by Convert to Working Space, which has nothing to convert +/// from. → a note for each profile, and whether `d` changed. +pub(crate) fn apply_open_policies(d: &mut vectorcraft_doc::Document) -> (Vec, bool) { + let working = cms::working_settings(); + let (rgb, cmyk) = doc_profiles(d); + let mut notes = Vec::new(); + // What each model's policy does with its profile: `None` keeps it; `Some` takes it off, after + // converting the colors from the profile it holds. + let mut decide = |label: &str, tag: Option, working: &str, policy: Policy, kind: cms::ProfileKind| -> Option> { + let tag = tag.filter(|t| t != working)?; + let available = cms::profile(&tag).is_some_and(|p| p.kind == kind); + match policy { + Policy::PreserveEmbedded => None, + Policy::Off | Policy::PreserveNumbers => { + notes.push(format!( + "the {label} profile “{tag}” the file is tagged with was not used: Color Settings' {label} policy is {}", + policy.label() + )); + Some(None) + } + Policy::Convert if available => { + notes.push(format!( + "the {label} colors were converted from the profile “{tag}” the file is tagged with into the working space “{working}”" + )); + Some(Some(tag)) + } + Policy::Convert => { + notes.push(format!("the {label} profile “{tag}” the file is tagged with is not available, so its colors were not converted")); + None + } + } + }; + let rgb_do = decide("RGB", rgb, &working.rgb, working.rgb_policy, cms::ProfileKind::Rgb); + let cmyk_do = decide("CMYK", cmyk, &working.cmyk, working.cmyk_policy, cms::ProfileKind::Cmyk); + if rgb_do.is_none() && cmyk_do.is_none() { + return (notes, false); + } + let (from_rgb, from_cmyk) = (rgb_do.clone().flatten(), cmyk_do.clone().flatten()); + if from_rgb.is_some() || from_cmyk.is_some() { + let source = cms::for_document(from_rgb.as_deref(), from_cmyk.as_deref()); + let dest = cms::working(); + let intent = working.intent; + let (rgb_on, cmyk_on) = (from_rgb.is_some(), from_cmyk.is_some()); + let conv = |c: &Color| match c { + Color::Rgb { .. } if rgb_on => source.convert_into(&dest, c, intent), + Color::Cmyk { .. } if cmyk_on => source.convert_into(&dest, c, intent), + _ => *c, + }; + convert_colors(d, &conv); + convert_setting_colors(d, &conv); + } + if rgb_do.is_some() { + d.color_profiles.rgb = None; + } + if cmyk_do.is_some() { + d.color_profiles.cmyk = None; + } + (notes, true) +} + +/// The colors of `d` outside its art that [`convert_colors`] leaves, converted with `conv`: each +/// artboard's background, the transparency grid and simulated paper colors of Document Setup, and +/// the fill and stroke of the character and paragraph styles. +fn convert_setting_colors(d: &mut vectorcraft_doc::Document, conv: &dyn Fn(&Color) -> Color) { + for c in d.artboards.iter_mut().filter_map(|a| a.color.as_mut()) { + *c = conv(c); + } + for c in &mut d.setup.grid_colors { + *c = conv(c); + } + // A tint of a global swatch is the converted swatch's color at its tint, which + // [`convert_colors`] already gave it. + let bases = global_bases(d); + let mut f = |c: &Color, link: proof::Link| mode_color(c, link, &bases, conv); + for styles in [&mut d.char_styles, &mut d.para_styles] { + if !styles.iter().any(|s| s.attrs.contains_key("fill") || s.attrs.contains_key("stroke")) { + continue; + } + for def in styles.iter_mut() { + for key in ["fill", "stroke"] { + let Some(value) = def.attrs.get_mut(key) else { continue }; + let Ok(mut paint) = serde_json::from_value::(value.clone()) else { continue }; + map_paint_colors(&mut paint, &mut f); + if let Ok(v) = serde_json::to_value(&paint) { + *value = v; + } + } + } + } +} + /// [`proof::map_node_colors`] over `n`'s subtree, then [`map_more_colors`]. fn map_art_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) { proof::map_node_colors(n, f); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 1a3c4c6e2c..4b28f5ca07 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -658,6 +658,17 @@ pub struct ReadFile { path: Option, /// What became of its linked files. links: Resolved, + /// Whether the color management policies changed the document ([`apply_policies`]). + policy_changed: bool, +} + +/// Edit › Color Settings: the color management policies keep, take off or convert from the +/// profiles the document `loaded` holds is tagged with, and their notes go to its warnings → +/// whether they changed the document. +fn apply_policies(loaded: &mut Loaded) -> bool { + let (notes, changed) = crate::cmd::colormgmt::apply_open_policies(&mut loaded.doc); + loaded.warnings.extend(notes); + changed } #[cfg(test)] @@ -678,19 +689,35 @@ impl ReadFile { /// ([`super::importer`]). pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; - let mut loaded = match super::importer::pick(name, bytes, p)? { - Some(plugin) => super::importer::load(name, bytes, &plugin, p)?, - None => load_with(name, bytes, &opts)?, + let (mut loaded, policy_changed) = match super::importer::pick(name, bytes, p)? { + Some(plugin) => { + let mut loaded = super::importer::load(name, bytes, &plugin, p)?; + let changed = apply_policies(&mut loaded); + (loaded, changed) + } + None => { + // The color mode `p` asks for converts the colors after the policies did, through the + // profiles the document keeps. + let mut loaded = load_with(name, bytes, &LoadOptions { color_mode: None, ..opts.clone() })?; + let changed = apply_policies(&mut loaded); + if let Some(mode) = opts.color_mode.filter(|m| *m != loaded.doc.color_mode) { + let doc = &mut loaded.doc; + vectorcraft_color::cms::with_active(super::super::colormgmt::doc_cms(doc), || { + super::super::colormgmt::set_color_mode(doc, mode, true, None, opts.grays); + }); + } + (loaded, changed) + } }; let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); - Ok(ReadFile { loaded, opts, path, links }) + Ok(ReadFile { loaded, opts, path, links, policy_changed }) } /// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active /// document → `document.open`'s result. pub fn open_read(s: &mut Session, file: ReadFile) -> Result { - open_loaded(s, file.loaded, file.links, file.path, &file.opts, false) + open_loaded(s, file.loaded, file.links, file.path, &file.opts, false, file.policy_changed) } /// File → New from Template: any readable file (at `path`, if it is one: its links are looked for @@ -702,14 +729,24 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st "only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first", )); } + let policy_changed = apply_policies(&mut loaded); let base = crate::cmd::links::LinkBase::file(path); let links = crate::cmd::links::resolve(&mut loaded.doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); - open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true) + open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true, policy_changed) } -/// Make a loaded file the new active document; `links` is what became of its linked files, and -/// `opts` are the options it was read with. -fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option, opts: &LoadOptions, as_template: bool) -> Result { +/// Make a loaded file the new active document; `links` is what became of its linked files, `opts` +/// are the options it was read with, and `policy_changed` says whether the color management +/// policies changed it (it then opens with unsaved changes). +fn open_loaded( + s: &mut Session, + loaded: Loaded, + links: Resolved, + path: Option, + opts: &LoadOptions, + as_template: bool, + policy_changed: bool, +) -> Result { let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); // EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing @@ -750,6 +787,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option Result { let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?; let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); doc.template = false; + // The color management policies apply as when the file is opened. + let (notes, policy_changed) = crate::cmd::colormgmt::apply_open_policies(&mut doc); // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; crate::cmd::recovery::forget(s, uid); s.replace_document(index, doc); + if policy_changed { + s.doc_mut()?.mark_unsaved(); + } if let Some(pdf) = native_pdf_compatible { s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf)); } - Ok(json!({ "path": path })) + let mut reply = json!({ "path": path }); + if !notes.is_empty() { + reply["warnings"] = json!(notes); + } + Ok(reply) } /// `file.formatOptions`: a writable format's options with the values Save would use. @@ -1352,7 +1361,7 @@ pub(super) fn specs() -> Vec { "Revert", ["File"], Some("F12"), - "{confirmed?: bool (the app's own: it asks first, then runs the command with it)} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path}. The app asks first (a confirm dialog) unless confirmed: true", + "{confirmed?: bool (the app's own: it asks first, then runs the command with it)} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path, warnings?: the notes of the color management policies of edit.colorSettings, which apply as at document.open}. The app asks first (a confirm dialog) unless confirmed: true", can_revert, revert ), diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index b02e34690b..988278b211 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -823,7 +823,13 @@ fn color_settings_come_back_in_the_next_session() { a.execute("edit.colorSettings", &json!({})).unwrap(); assert!(!file.exists(), "nothing to keep before the settings change"); a.execute("edit.colorSettings", &json!({"rgb": loaded, "cmyk": cms::DEVICE_CMYK, "intent": "perceptual", "bpc": false})).unwrap(); - let chosen = cms::ColorSettings { rgb: loaded.into(), cmyk: cms::DEVICE_CMYK.into(), intent: cms::Intent::Perceptual, bpc: false }; + let chosen = cms::ColorSettings { + rgb: loaded.into(), + cmyk: cms::DEVICE_CMYK.into(), + intent: cms::Intent::Perceptual, + bpc: false, + ..cms::ColorSettings::default() + }; assert_eq!(cms::active_settings(), chosen); // The next start begins with the defaults and without the loaded profile, which the profile @@ -865,3 +871,151 @@ fn saved_color_settings_that_cannot_be_used_leave_the_defaults() { assert!(!saved.bpc && saved.cmyk == cms::GENERIC_CMYK, "written over by the next change: {saved:?}"); } } + +/// Edit › Color Settings' color management policies decide what opening a document tagged with +/// profiles other than the working spaces does with them: Preserve Embedded Profiles keeps them, +/// Off and Preserve Numbers take them off and keep the color numbers, and Convert to Working Space +/// converts the colors into the working spaces, keeping how they look, and takes them off. A +/// document a policy changed opens with unsaved changes, and the open reply names each profile. +/// A profile that is the working space stays, and File › Revert applies the policies too. +#[test] +fn color_management_policies_keep_take_off_or_convert_a_tagged_profile() { + let _g = settings(); + let dir = fresh_dir("policies"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + std::fs::create_dir_all(&dir).unwrap(); + let tagged = dir.join("tagged.vectorcraft"); + let mut s = session(); + let (blue, ink) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 0.0, 10.0, 10.0)); + exec(&mut s, "paint.setFill", json!({"ids": [blue.0], "color": "#3366cc"})); + exec(&mut s, "paint.setFill", json!({"ids": [ink.0], "color": {"c": 20, "m": 40, "y": 60, "k": 10}})); + exec(&mut s, "edit.assignProfile", json!({"rgb": cms::WIDE_GAMUT_RGB, "cmyk": cms::DEVICE_CMYK})); + // An artboard background, a character style's fill and a freeform gradient swatch, colors + // outside the art. + let paper = vectorcraft_color::Color::Rgb { r: 0.9, g: 0.8, b: 0.6 }; + let free = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + select(&mut s, &[free]); + exec(&mut s, "paint.setFill", json!({"gradient": {"kind": "freeform"}})); + exec(&mut s, "paint.freeform.setPoint", json!({"index": 0, "color": "#e6cc99", "stroke": false})); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.artboards[0].color = Some(paper); + let mut style = vectorcraft_doc::TextStyleDef { name: "Inked".into(), attrs: Default::default() }; + style.attrs.insert("fill".into(), serde_json::to_value(vectorcraft_color::Paint::solid(paper)).unwrap()); + d.char_styles.push(style); + let paint = d.node(free).unwrap().appearance.fill().unwrap().paint.clone(); + d.swatches.push(vectorcraft_color::Swatch { name: "Free".into(), paint, global: false, spot: false }); + } + exec(&mut s, "file.saveAs", json!({"path": tagged.to_str().unwrap()})); + let settings_colors = |s: &Session| { + let d = &s.doc().unwrap().doc; + let style = serde_json::from_value::(d.char_styles[0].attrs["fill"].clone()).unwrap(); + (d.artboards[0].color.unwrap(), style.color().unwrap()) + }; + // The first point of the freeform swatch and of the freeform fill. + let free_points = |s: &Session| { + let d = &s.doc().unwrap().doc; + let first = |p: &vectorcraft_color::Paint| match p { + vectorcraft_color::Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points[0].color), + _ => None, + }; + let swatch = d.swatches.iter().find(|w| w.name == "Free").and_then(|w| first(&w.paint)).unwrap(); + (swatch, first(&d.node(free).unwrap().appearance.fill().unwrap().paint).unwrap()) + }; + let (free0, _) = free_points(&s); + let fill = |s: &Session, id| s.doc().unwrap().doc.node(id).and_then(|n| n.appearance.fill()).and_then(|f| f.paint.color()).unwrap(); + let (blue0, ink0) = (fill(&s, blue), fill(&s, ink)); + let source = cms::for_document(Some(cms::WIDE_GAMUT_RGB), Some(cms::DEVICE_CMYK)); + let notes = |r: &serde_json::Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("profile")).count(); + // A session that opened the file under the policies, and the open reply. + let open = |rgb: &str, cmyk: &str| { + let mut s = session(); + exec(&mut s, "edit.colorSettings", json!({"rgbPolicy": rgb, "cmykPolicy": cmyk})); + let r = exec(&mut s, "document.open", json!({"path": tagged.to_str().unwrap()})); + (r, s) + }; + + let (r, kept) = open("preserveEmbedded", "preserveEmbedded"); + assert_eq!(doc_profiles(&kept.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), Some(cms::DEVICE_CMYK.into()))); + assert_eq!((fill(&kept, blue), fill(&kept, ink), notes(&r)), (blue0, ink0, 0)); + assert!(!kept.doc().unwrap().is_dirty()); + + for (rgb, cmyk) in [("off", "off"), ("off", "preserveNumbers")] { + let (r, off) = open(rgb, cmyk); + assert_eq!(doc_profiles(&off.doc().unwrap().doc), (None, None), "{cmyk}"); + assert_eq!((fill(&off, blue), fill(&off, ink), notes(&r)), (blue0, ink0, 2), "{cmyk}: the numbers stay"); + assert!(off.doc().unwrap().is_dirty(), "{cmyk}: the document changed"); + } + + let (r, converted) = open("convert", "convert"); + assert_eq!(doc_profiles(&converted.doc().unwrap().doc), (None, None)); + assert_eq!(notes(&r), 2); + let working = cms::working(); + let (blue1, ink1) = (fill(&converted, blue), fill(&converted, ink)); + assert!(blue1 != blue0 && ink1 != ink0, "the numbers change: {blue1:?} {ink1:?}"); + assert!(cms::delta_e2000(source.lab(&blue0), working.lab(&blue1)) < 1.0, "{blue0:?} → {blue1:?}"); + assert!(cms::delta_e2000(source.lab(&ink0), working.lab(&ink1)) < 3.0, "{ink0:?} → {ink1:?}"); + assert!(converted.doc().unwrap().is_dirty()); + let (board, styled) = settings_colors(&converted); + assert_eq!(board, styled, "the artboard and the style convert alike"); + assert!(board != paper && cms::delta_e2000(source.lab(&paper), working.lab(&board)) < 1.0, "{paper:?} → {board:?}"); + assert_eq!(settings_colors(&kept), (paper, paper), "kept with the profile"); + let (swatch_point, fill_point) = free_points(&converted); + assert_eq!(swatch_point, fill_point, "the freeform swatch and fill convert alike"); + assert!(swatch_point != free0 && cms::delta_e2000(source.lab(&free0), working.lab(&swatch_point)) < 1.0, "{free0:?} → {swatch_point:?}"); + + // A color mode asked for on open converts the colors the policy converted, through the working + // spaces, also when the document in front draws through another profile. + let mut s2 = session(); + exec(&mut s2, "edit.assignProfile", json!({"rgb": cms::DISPLAY_P3})); + exec(&mut s2, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "convert"})); + exec(&mut s2, "document.open", json!({"path": tagged.to_str().unwrap(), "colorMode": "cmyk"})); + let intent = working.settings().intent; + let want = working.convert(&blue1, cms::Model::Cmyk, intent); + let got = fill(&s2, blue); + assert!(cms::delta_e2000(working.lab(&want), working.lab(&got)) < 0.01, "{want:?} vs {got:?}"); + + // A profile that is the working space is kept whatever the policy. + exec(&mut s, "edit.colorSettings", json!({"rgb": cms::WIDE_GAMUT_RGB})); + let (r, same) = open("off", "off"); + assert_eq!(doc_profiles(&same.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), None)); + assert_eq!(notes(&r), 1); + exec(&mut s, "edit.colorSettings", json!({"rgb": cms::SRGB})); + + // File › Revert reads the file again under the policies. + let (_, mut reverted) = open("preserveEmbedded", "preserveEmbedded"); + exec(&mut reverted, "paint.setFill", json!({"ids": [blue.0], "color": "#ff0000"})); + exec(&mut reverted, "edit.colorSettings", json!({"cmykPolicy": "preserveNumbers"})); + let r = exec(&mut reverted, "file.revert", json!({"confirmed": true})); + assert_eq!(doc_profiles(&reverted.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), None)); + assert_eq!((fill(&reverted, blue), notes(&r)), (blue0, 1)); +} + +/// The policies are checked, come back in the reply and in `Color Settings.json`, and a saved file +/// that gives the RGB policy Preserve Numbers, a CMYK policy only, leaves the defaults. +#[test] +fn color_management_policies_are_checked_and_kept() { + let _g = settings(); + let dir = fresh_dir("policies-kept"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + let file = dir.join("Color Settings.json"); + let mut s = session(); + s.attach_color_settings(file.clone()); + for bad in [json!({"rgbPolicy": "preserveNumbers"}), json!({"cmykPolicy": "keep"}), json!({"rgbPolicy": 1})] { + assert!(s.execute("edit.colorSettings", &bad).is_err(), "{bad}"); + } + assert!(!file.exists(), "a refused change keeps nothing"); + let r = exec(&mut s, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "preserveNumbers"})); + assert_eq!((r["rgbPolicy"].as_str(), r["cmykPolicy"].as_str()), (Some("convert"), Some("preserveNumbers"))); + let saved: cms::ColorSettings = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!((saved.rgb_policy, saved.cmyk_policy), (cms::Policy::Convert, cms::Policy::PreserveNumbers)); + cms::set_active(&cms::ColorSettings::default()).unwrap(); + std::fs::write( + &file, + json!({"rgb": cms::SRGB, "cmyk": cms::GENERIC_CMYK, "intent": "relative", "bpc": true, "rgbPolicy": "preserveNumbers"}).to_string(), + ) + .unwrap(); + let mut next = Session::new(); + next.attach_color_settings(file); + assert_eq!(cms::active_settings(), cms::ColorSettings::default()); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 28e1f4b523..8d2498af3e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4987,6 +4987,11 @@ axis Both И двете @msg Imported library {name} Импортирана библиотека {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Правило за RGB: + CMYK Policy: Правило за CMYK: + Preserve Embedded Profiles Запазване на вградените профили + Convert to Working Space Преобразуване в работното пространство + Preserve Numbers Запазване на числата Texture Текстура Craquelure… Кракелюр… Grain… Зърнистост… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 7cf9916fd0..43cf138605 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -972,6 +972,11 @@ NTSC Colors NTSC boje RGB: RGB: CMYK: CMYK: + RGB Policy: RGB pravilo: + CMYK Policy: CMYK pravilo: + Preserve Embedded Profiles Zadrži ugrađene profile + Convert to Working Space Pretvori u radni prostor + Preserve Numbers Zadrži brojeve CMYK CMYK Texture Tekstura Craquelure… Krakelura… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index c9f18653b4..fdc9056ad7 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -764,6 +764,11 @@ antiAlias Sharp Nítid CMYK Equivalents Equivalents CMYK CMYK profile in effect Perfil CMYK en vigor CMYK: CMYK: + RGB Policy: Política RGB: + CMYK Policy: Política CMYK: + Preserve Embedded Profiles Conserva els perfils incrustats + Convert to Working Space Converteix a l'espai de treball + Preserve Numbers Conserva els números Coarse Dots Punts gruixuts Collapse Panel Group Replega el grup de panells Collapse Panels to Icons Replega els panells a icones diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1ffc7925ff..277336b9bf 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -991,6 +991,11 @@ NTSC Colors Barvy NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Zásada RGB: + CMYK Policy: Zásada CMYK: + Preserve Embedded Profiles Zachovat vložené profily + Convert to Working Space Převést do pracovního prostoru + Preserve Numbers Zachovat čísla CMYK CMYK Texture Textura Craquelure… Krakelování… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 043fb6d76e..7dcb1001f8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -914,6 +914,11 @@ NTSC Colors NTSC-farver RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-politik: + CMYK Policy: CMYK-politik: + Preserve Embedded Profiles Bevar indlejrede profiler + Convert to Working Space Konverter til arbejdsfarverum + Preserve Numbers Bevar tal CMYK CMYK Texture Tekstur Craquelure… Krakelering… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a9a96c0ce7..ba276525f7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5274,6 +5274,11 @@ theme Appearance Erscheinungsbild The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Die Hauptfarbe wird zur Konturfarbe. Die anderen Farben wandern ebenso weit um den Farbkreis wie die Hauptfarbe und behalten ihre Sättigung und Helligkeit. RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-Richtlinie: + CMYK Policy: CMYK-Richtlinie: + Preserve Embedded Profiles Eingebettete Profile beibehalten + Convert to Working Space In Arbeitsfarbraum konvertieren + Preserve Numbers Werte beibehalten # PSD files. @msg not a PSD file keine PSD-Datei @msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode PSD-Dateien im Mehrkanalmodus können nicht gelesen werden: Speichern Sie die Datei im RGB-, CMYK- oder Graustufenmodus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 90b7a0b4e5..ee7fee264c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5239,6 +5239,11 @@ digits Malayalam Μαλαγιάλαμ @msg Imported library {name} Εισήχθη η βιβλιοθήκη {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Πολιτική RGB: + CMYK Policy: Πολιτική CMYK: + Preserve Embedded Profiles Διατήρηση ενσωματωμένων προφίλ + Convert to Working Space Μετατροπή στον χώρο εργασίας + Preserve Numbers Διατήρηση αριθμών System Title Bar Γραμμή τίτλου συστήματος VectorCraft Title Bar on Every Desktop Γραμμή τίτλου του VectorCraft σε κάθε επιφάνεια εργασίας The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Όσο είναι ορισμένη, η μεταβλητή περιβάλλοντος VECTORCRAFT_TITLEBAR επιλέγει τη γραμμή τίτλου. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5fcc1a2f5c..5a61d59367 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5287,6 +5287,11 @@ axis Both Ambos @msg Imported library {name} Biblioteca {name} importada RGB: RGB: CMYK: CMYK: + RGB Policy: Norma RGB: + CMYK Policy: Norma CMYK: + Preserve Embedded Profiles Conservar perfiles incrustados + Convert to Working Space Convertir en espacio de trabajo + Preserve Numbers Conservar números Texture Textura Craquelure… Craquelado… Grain… Granulado… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8b2b690c80..97bf637c52 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -975,6 +975,11 @@ NTSC Colors NTSC-värit RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-käytäntö: + CMYK Policy: CMYK-käytäntö: + Preserve Embedded Profiles Säilytä upotetut profiilit + Convert to Working Space Muunna työväriavaruuteen + Preserve Numbers Säilytä lukuarvot CMYK CMYK Texture Tekstuuri Craquelure… Krakelyyri… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index fd43bff358..b7a1fc6a32 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5294,6 +5294,11 @@ axis Both Les deux @msg Imported library {name} Bibliothèque {name} importée RGB: RVB : CMYK: CMJN : + RGB Policy: Règle RVB : + CMYK Policy: Règle CMJN : + Preserve Embedded Profiles Conserver les profils incorporés + Convert to Working Space Convertir en espace de travail + Preserve Numbers Conserver les valeurs numériques Texture Texture Craquelure… Craquelure… Grain… Grain… diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index ab1ae3d432..667d276bed 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -471,6 +471,11 @@ CMYK Images CMYK ઇમેજ CMYK colours print with their own values; off, they go through the colour settings too. CMYK રંગો પોતાની કિંમતો સાથે છાપે છે; બંધ હોય ત્યારે તે રંગ સેટિંગમાંથી પણ પસાર થાય છે. CMYK: CMYK: + RGB Policy: RGB નીતિ: + CMYK Policy: CMYK નીતિ: + Preserve Embedded Profiles એમ્બેડ કરેલી પ્રોફાઇલ જાળવો + Convert to Working Space કાર્યકારી સ્પેસમાં રૂપાંતરિત કરો + Preserve Numbers સંખ્યાઓ જાળવો CSS Properties CSS ગુણધર્મો Calligraphic Brush કેલિગ્રાફિક બ્રશ Calligraphic Brush Options કેલિગ્રાફિક બ્રશ વિકલ્પો diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index bef5689fa5..aedaf8ddbc 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -884,6 +884,11 @@ antiAlias Sharp शार्प CMYK Images CMYK इमेज CMYK profile in effect इफ़ेक्ट में CMYK प्रोफ़ाइल CMYK: CMYK: + RGB Policy: RGB नीति: + CMYK Policy: CMYK नीति: + Preserve Embedded Profiles एम्बेड की गई प्रोफ़ाइल बनाए रखें + Convert to Working Space वर्किंग स्पेस में बदलें + Preserve Numbers संख्याएँ बनाए रखें Coarse Dots मोटे बिंदु Coincidence: संयोग: Collapse Panel Group पैनल समूह संक्षिप्त करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8f531b4d8a..b044eb38ea 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -695,6 +695,11 @@ font-technology Variable Változó CMYK Equivalents CMYK-megfelelők CMYK profile in effect Érvényes CMYK-profil CMYK: CMYK: + RGB Policy: RGB-házirend: + CMYK Policy: CMYK-házirend: + Preserve Embedded Profiles Beágyazott profilok megtartása + Convert to Working Space Konvertálás a munkaszíntérbe + Preserve Numbers Számértékek megtartása Coarse Dots Durva pontok Collapse Panels to Icons Panelek ikonná csukása Collapse to Icons Ikonná csukás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8abbc41b00..35a927726d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -914,6 +914,11 @@ NTSC Colors Warna NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Kebijakan RGB: + CMYK Policy: Kebijakan CMYK: + Preserve Embedded Profiles Pertahankan Profil Tersemat + Convert to Working Space Konversi ke Ruang Kerja + Preserve Numbers Pertahankan Angka CMYK CMYK Texture Tekstur Craquelure… Craquelure… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ef6e76cce3..4be170b11e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5339,6 +5339,11 @@ axis Both Entrambi @msg Imported library {name} Libreria {name} importata RGB: RGB: CMYK: CMYK: + RGB Policy: Criterio RGB: + CMYK Policy: Criterio CMYK: + Preserve Embedded Profiles Mantieni profili incorporati + Convert to Working Space Converti nello spazio di lavoro + Preserve Numbers Mantieni valori numerici Texture Texture Craquelure… Craquelé… Grain… Granulosità… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4043f024f8..d26441b8ca 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5380,6 +5380,11 @@ swatch Bright Violet ビビッドバイオレット @msg Imported library {name} ライブラリ {name} を読み込みました RGB: RGB: CMYK: CMYK: + RGB Policy: RGB ポリシー: + CMYK Policy: CMYK ポリシー: + Preserve Embedded Profiles 埋め込まれたプロファイルを保持 + Convert to Working Space 作業用スペースに変換 + Preserve Numbers カラー値を保持 Texture テクスチャ Craquelure… クラッキング… Grain… 粒状… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 9b46dcd7ec..1437e564a3 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -880,6 +880,11 @@ NTSC Colors NTSC ფერები RGB: RGB: CMYK: CMYK: + RGB Policy: RGB წესი: + CMYK Policy: CMYK წესი: + Preserve Embedded Profiles ჩაშენებული პროფილების შენარჩუნება + Convert to Working Space სამუშაო სივრცეში გადაყვანა + Preserve Numbers რიცხვების შენარჩუნება CMYK CMYK Texture ტექსტურა Craquelure… კრაკელიური… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 34a56686fa..d2f496280b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5307,6 +5307,11 @@ theme Appearance 외관 @msg Imported library {name} 라이브러리 {name}을(를) 가져왔습니다 RGB: RGB: CMYK: CMYK: + RGB Policy: RGB 정책: + CMYK Policy: CMYK 정책: + Preserve Embedded Profiles 포함된 프로필 유지 + Convert to Working Space 작업 공간으로 변환 + Preserve Numbers 숫자 유지 Texture 텍스처 Craquelure… 크랙… Grain… 그레인… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 67cfffd967..f6a66b5564 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1432,6 +1432,11 @@ NTSC Colors NTSC-kleuren RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-beleid: + CMYK Policy: CMYK-beleid: + Preserve Embedded Profiles Ingesloten profielen behouden + Convert to Working Space Omzetten naar werkruimte + Preserve Numbers Waarden behouden CMYK CMYK Texture Textuur Craquelure… Craquelure… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 191d0146fa..f167c200f0 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -932,6 +932,11 @@ NTSC Colors Kolory NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Zasada RGB: + CMYK Policy: Zasada CMYK: + Preserve Embedded Profiles Zachowaj osadzone profile + Convert to Working Space Konwertuj do przestrzeni roboczej + Preserve Numbers Zachowaj wartości liczbowe CMYK CMYK Texture Tekstura Craquelure… Spękania… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f52d729edd..c4fdce7bd1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3485,6 +3485,11 @@ imageTrace Outline Contorno NTSC Colors Cores NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Política RGB: + CMYK Policy: Política CMYK: + Preserve Embedded Profiles Preservar perfis incorporados + Convert to Working Space Converter para espaço de trabalho + Preserve Numbers Preservar números Texture Textura Craquelure… Craquelê… Grain… Granulação… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11a550881e..b4ddccd833 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5326,6 +5326,11 @@ axis Both Оба @msg Imported library {name} Импортирована библиотека {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Правило RGB: + CMYK Policy: Правило CMYK: + Preserve Embedded Profiles Сохранять встроенные профили + Convert to Working Space Преобразовывать в рабочее пространство + Preserve Numbers Сохранять числа Texture Текстура Craquelure… Кракелюры… Grain… Зерно… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 357609dc89..2389d8ba53 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5198,6 +5198,11 @@ axis Both Båda @msg Imported library {name} Importerade biblioteket {name} RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-princip: + CMYK Policy: CMYK-princip: + Preserve Embedded Profiles Behåll inbäddade profiler + Convert to Working Space Konvertera till arbetsfärgrymd + Preserve Numbers Behåll värden Texture Textur Craquelure… Krakelering… Grain… Korn… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0dac2b58fd..359778502d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5093,6 +5093,11 @@ axis Both Her İkisi @msg Imported library {name} İçe aktarılan kitaplık {name} RGB: RGB: CMYK: CMYK: + RGB Policy: RGB İlkesi: + CMYK Policy: CMYK İlkesi: + Preserve Embedded Profiles Gömülü Profilleri Koru + Convert to Working Space Çalışma Alanına Dönüştür + Preserve Numbers Sayıları Koru Texture Doku Craquelure… Küçük çatlak… Grain… Tahıl… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 459e726af5..ea06f43bcc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5323,6 +5323,11 @@ axis Both Обидві @msg Imported library {name} Імпортовано бібліотеку {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Правило RGB: + CMYK Policy: Правило CMYK: + Preserve Embedded Profiles Зберігати вбудовані профілі + Convert to Working Space Перетворювати в робочий простір + Preserve Numbers Зберігати числа Texture Текстура Craquelure… Кракелюри… Grain… Зерно… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 101174ad05..7400d9b219 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -607,6 +607,11 @@ font-technology Variable Biến thiên CMYK Equivalents Tương đương CMYK CMYK profile in effect Hồ sơ CMYK đang có hiệu lực CMYK: CMYK: + RGB Policy: Chính sách RGB: + CMYK Policy: Chính sách CMYK: + Preserve Embedded Profiles Giữ cấu hình nhúng + Convert to Working Space Chuyển sang không gian làm việc + Preserve Numbers Giữ giá trị số Coarse Dots Chấm thô Collapse Panels to Icons Thu gọn bảng thành biểu tượng Collapse to Icons Thu gọn thành biểu tượng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b3c6a4ddcb..199153051b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5227,6 +5227,11 @@ imageTrace Outline 轮廓 None (Color Preview) 无 (颜色预览) Preserve 保留 CMYK: CMYK: + RGB Policy: RGB 策略: + CMYK Policy: CMYK 策略: + Preserve Embedded Profiles 保留嵌入的配置文件 + Convert to Working Space 转换为工作空间 + Preserve Numbers 保留数值 Art & Illustration 艺术和插画 Branding 品牌 Film & Video 影片和视频 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7f0f53575e..bf2f2c5401 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5457,6 +5457,11 @@ axis Both 兩者 Interpolation 內插 RGB: RGB: CMYK: CMYK: + RGB Policy: RGB 原則: + CMYK Policy: CMYK 原則: + Preserve Embedded Profiles 保留內嵌描述檔 + Convert to Working Space 轉換為工作空間 + Preserve Numbers 保留數值 Texture 紋理 Craquelure… 龜裂紋… Grain… 顆粒… diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 0cb6c726d4..6b7ce6b0e0 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -1,11 +1,12 @@ //! Separations Preview panel: process plates C/M/Y/K plus spot plates with eye toggles (one visible //! plate renders as greyscale ink coverage; Alt-click an eye to show only that plate), and the //! colour-management controls behind Edit → Color Settings… / Assign Profile… and View → Proof -//! Setup (working spaces, intent, black-point compensation, proof target, gamut check). +//! Setup (working spaces, color management policies, intent, black-point compensation, proof +//! target, gamut check). use egui::{Sense, Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_color::cms::{self, Intent, ProfileKind}; +use vectorcraft_color::cms::{self, Intent, Policy, ProfileKind}; use vectorcraft_render::proof; use crate::VectorcraftApp; @@ -100,7 +101,8 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, Load +/// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, the RGB +/// and CMYK color management policies, Load /// Profile… and Gamut Check, and the profile the active document is assigned. fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { let st = cms::working_settings(); @@ -127,6 +129,21 @@ fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::check(ui, tl!("Use Black Point Compensation"), st.bpc, true) { run(app, "edit.colorSettings", json!({"bpc": !st.bpc})); } + // Color management policies: what opening a document does with a profile it is tagged with + // that is not the working space. + let policies: [(&str, &str, Policy, &[Policy]); 2] = + [("rgbPolicy", tl!("RGB Policy:"), st.rgb_policy, &Policy::RGB), ("cmykPolicy", tl!("CMYK Policy:"), st.cmyk_policy, &Policy::CMYK)]; + for (key, label, current, choices) in policies { + ui.horizontal(|ui| { + ui.label(label); + let labels: Vec<&str> = choices.iter().map(|p| policy_label(*p)).collect(); + if let Some(i) = widgets::dropdown_names(ui, key, policy_label(current), &labels, 210.0) + && let Some(p) = choices.get(i) + { + run(app, "edit.colorSettings", json!({ key: p.id() })); + } + }); + } ui.horizontal(|ui| { if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked() { // Its file dialog, shown off the UI thread, loads the profile when it answers. @@ -160,6 +177,16 @@ fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The name of color management policy `p` in the UI language. +fn policy_label(p: Policy) -> &'static str { + match p { + Policy::Off => tl!("Off"), + Policy::PreserveEmbedded => tl!("Preserve Embedded Profiles"), + Policy::Convert => tl!("Convert to Working Space"), + Policy::PreserveNumbers => tl!("Preserve Numbers"), + } +} + /// The status bar's line after Gamut Check finds `n` colors outside the CMYK gamut. pub(crate) fn gamut_check_status(n: usize) -> String { if n == 0 { diff --git a/docs/mcp.md b/docs/mcp.md index 5a5acfd66d..2830bfc44d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1590,10 +1590,17 @@ preferences (the folder the desktop app uses, so both see the same ones; `VECTOR command, so `edit.colorSettings {"cmyk": "…"}` or a preset's output intent can name it in a later run. The copy is what counts: the original may move or be deleted. `color.removeProfile {name}` forgets a profile and its copy (not while it is a working space). -- **Color Settings** (`edit.colorSettings`): the RGB and CMYK working spaces, intent and black point - compensation go into `Color Settings.json` when they change, and are the active settings again at the next - start, after the profiles above are registered (#6335). Saved settings that name a profile that is no longer - there, or a damaged file, leave the defaults (logged), and the next change writes over the file. +- **Color Settings** (`edit.colorSettings`): the RGB and CMYK working spaces, intent, black point + compensation and color management policies go into `Color Settings.json` when they change, and are the active + settings again at the next start, after the profiles above are registered (#6335). Saved settings that name a + profile that is no longer there, or a damaged file, leave the defaults (logged), and the next change writes over + the file; settings saved without policies take the default, `preserveEmbedded`. `rgbPolicy` (`off`, + `preserveEmbedded`, `convert`) and `cmykPolicy` (the same and `preserveNumbers`) decide what `document.open` and + `file.revert` do with an RGB or CMYK profile the file is tagged with that is not the working space: keep it, take + it off and keep the color numbers (`off`, `preserveNumbers`), or convert the colors of that model into the working + space with the intent and take it off (`convert`; a profile that is not available stays). The reply's `warnings` + name each profile, and a document a policy changed opens with unsaved changes. A profile that is the working space + stays whatever the policy. `edit.assignProfile` tags the document only: colors show and convert through the profiles it assigns, and the working spaces and the file stay as they are. The profiles in use are those of the document in front, after `file.new`, `document.open`, `document.activate`, `file.close`, `file.revert`, `edit.undo`, `edit.redo` and a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3e29897763..bd53567172 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -741,6 +741,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. - **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. - **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. With Window › Arrange › Tile or floating document windows, each document behind the front one draws its art, paper and transparency grid through its own assigned profiles, and loading or removing a profile redraws the documents assigned it; placed documents inside it still draw through the front document's profiles. How documents behind the front one draw is VectorCraft's own rule. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. +- **Color management policies:** Edit › Color Settings… (the Separations panel's Color Settings section and `edit.colorSettings`) has an RGB and a CMYK policy: Off, Preserve Embedded Profiles and Convert to Working Space, and Preserve Numbers for CMYK. Opening or reverting a document tagged with a profile other than the working space keeps it, takes it off with the color numbers kept, or converts that model's colors into the working space and takes it off. The policies are kept in `Color Settings.json`. Preserve Embedded Profiles as the default for both, and the per-model policy for a document tagged with both an RGB and a CMYK profile, are VectorCraft's own rules. Public reference: [Color management policy options](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf). Tests in `engine/tests/color_settings.rs` and `color/src/cms/tests.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `profile_errors_name_the_file_by_its_name_outside_the_read_roots`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). From 4f60e14555395ed0cb49363b45c0563049b6e317 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 16:51:33 -0800 Subject: [PATCH 03/24] Selection: symbol instances are hit where their art draws past their box A click on a symbol instance hit only its art's geometric box, and a marquee touched only that box, so art that a clipping path's live geometry effects move past the box could be seen but not selected. - Outside its box, in Preview, a click hits an instance where its placed art hits (dynamic overrides and 9-slice placement included), and a marquee touches the placed art as it touches a group of that art. Outline view and layers with Preview off keep the box alone. - The quick reach of an instance takes in the regions of the clipping paths with live effects in its art, so the groups around it no longer cull those clicks. --- crates/doc/src/hit.rs | 184 ++++++++++++++++++++++++------------ crates/doc/src/hit/tests.rs | 88 +++++++++++++++++ crates/doc/src/node/ops.rs | 30 +++++- crates/doc/src/symbols.rs | 18 +++- docs/target-app-parity.md | 1 + 5 files changed, 256 insertions(+), 65 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 5885a0ff54..04b48634ed 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -504,7 +504,7 @@ fn object_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editab /// [`hit_test`] passing over the nodes `skip` names (and their contents), and with /// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of /// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art -/// ([`Node::geometric_bounds_with`]). +/// ([`Node::geometric_bounds_with`]), and are also hit outside that box where their art paints. fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option { let finish = |mut h: Hit| { h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); @@ -1055,9 +1055,17 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill), }) } - NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => { + NodeKind::Text(_) | NodeKind::PlacedDocument(_) => { n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } + // A symbol instance is hit within its bounds, and outside them where its art paints + // ([`instance_art_hit`]). + NodeKind::SymbolInstance { .. } => { + if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return Some(HitKind::Bounds); + } + instance_art_hit(n, p, opt, symbols) + } // A blend or an envelope is hit within its bounds, and outside them where its evaluated // art paints: a blend's keys and steps on a spine can reach past the keys and the spine, // and the strokes of an envelope's distorted contents past its bounds. @@ -1211,64 +1219,94 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { /// layer, is tested in outline ([`HitOptions::outline`]), as the canvas draws it. `None` when `inst` is not a symbol instance or /// no editable object of its art is under `p`. pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { - struct Find<'a> { - art: &'a InstanceArt<'a>, - p: Point, - opt: HitOptions, - symbols: &'a SymbolBoxes<'a>, - } - impl Find<'_> { - fn hit(&self, n: &Node, whole: Option, opt: HitOptions) -> bool { - self.art.place(n, whole).is_some_and(|n| hit_any(&n, self.p, opt, self.symbols).is_some()) - } - fn skips_guide(&self, n: &Node) -> bool { - self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) - } - /// `outline`: the view is in Outline, or a layer around `n` has Preview off. - fn find(&self, n: &Node, path: &mut Vec, whole: Option, outline: bool) -> bool { - if !n.visible || n.locked || n.is_template() || self.skips_guide(n) { - return false; - } - let outline = outline || previews_off(n); - let opt = HitOptions { outline, ..self.opt }; - let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) }; - if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { - return self.hit(n, whole, opt); - } - let whole = self.art.whole(n, whole); - if n.clips() - && let Some(first) = children.first() - { - let Some(clip) = self.art.place_unclipped(first, whole) else { return false }; - if self.opt.clip_outlines && first.visible && !first.locked && !self.skips_guide(first) && clip_outline(&clip, self.p, opt).is_some() - { - path.push(0); - return true; - } - if !clip_contains(&clip, self.p, outline) { - return false; - } - } - for (i, c) in children.iter().enumerate().rev() { - // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. - if shaper_hides(n, i, None) { - continue; - } - let Ok(i) = u32::try_from(i) else { continue }; - path.push(i); - if self.find(c, path, whole, outline) { - return true; - } - path.pop(); - } - false - } - } let art = InstanceArt::of(doc, inst)?; let symbols = doc.symbol_boxes(); let mut path = vec![]; let outline = opt.outline || below_preview_off(doc, inst, opt.scope); - Find { art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None, outline).then_some(path) + let find = FindPart { art: &art, p, opt, symbols: &symbols, whole_instance: false }; + find.find(&art.art, &mut path, None, outline).map(|_| path) +} + +/// The walk of a symbol instance's placed art that [`symbol_part_at`] and [`instance_art_hit`] make. +struct FindPart<'a> { + art: &'a InstanceArt<'a>, + p: Point, + opt: HitOptions, + symbols: &'a SymbolBoxes<'a>, + /// For [`instance_art_hit`]: locked and template objects of the art count, as the canvas draws + /// them, and clipping paths are not picked by their outline. + whole_instance: bool, +} + +impl FindPart<'_> { + fn hit(&self, n: &Node, whole: Option, opt: HitOptions) -> Option { + self.art.place(n, whole).and_then(|n| hit_any(&n, self.p, opt, self.symbols)) + } + + fn skips_guide(&self, n: &Node) -> bool { + self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) + } + + /// What a click at `p` hits on object `n` of the art, with `path` the child indices that lead + /// to what it hits. `outline`: the view is in Outline, or a layer around `n` has Preview off. + fn find(&self, n: &Node, path: &mut Vec, whole: Option, outline: bool) -> Option { + if !n.visible || !self.whole_instance && (n.locked || n.is_template()) || self.skips_guide(n) { + return None; + } + let outline = outline || previews_off(n); + let opt = HitOptions { outline, ..self.opt }; + let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole, opt) }; + if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { + return self.hit(n, whole, opt); + } + let whole = self.art.whole(n, whole); + if n.clips() + && let Some(first) = children.first() + { + let clip = self.art.place_unclipped(first, whole)?; + if !self.whole_instance + && self.opt.clip_outlines + && first.visible + && !first.locked + && !self.skips_guide(first) + && let Some(kind) = clip_outline(&clip, self.p, opt) + { + path.push(0); + return Some(kind); + } + if !clip_contains(&clip, self.p, outline) { + return None; + } + } + for (i, c) in children.iter().enumerate().rev() { + // A Shaper group's original shapes are not drawn; symbol art has no isolation scope. + if shaper_hides(n, i, None) { + continue; + } + let Ok(i) = u32::try_from(i) else { continue }; + path.push(i); + if let Some(kind) = self.find(c, path, whole, outline) { + return Some(kind); + } + path.pop(); + } + None + } +} + +/// What a click at `p` hits of symbol instance `n`'s art where the canvas draws it (`None` in +/// Outline, [`HitOptions::outline`]): its strokes, and the art that the live geometry effects of a +/// clipping path in it show past the art's box. `None` for an instance of a missing symbol, of a +/// symbol that holds an instance of itself or nests too deep ([`SymbolBoxes::art`]), and of art past +/// the 9-slice object limit ([`InstanceArt::of`]). +fn instance_art_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return None }; + if opt.outline || symbols.art(symbol).is_none() { + return None; + } + let art = InstanceArt::of(symbols.document(), n)?; + let opt = HitOptions { clip_outlines: false, ..opt }; + FindPart { art: &art, p, opt, symbols, whole_instance: true }.find(&art.art, &mut vec![], None, false) } /// Is `n` below a layer whose Preview is off, so that the canvas draws it in outline? As for clicks @@ -1435,7 +1473,8 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving /// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured -/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a +/// by their art ([`Node::geometric_bounds_with`]), and outside that box, except in outline, they are +/// touched where the live geometry effects of a clipping path in their art show it. The art of a clip group or of a layer with a /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then /// only within that region's bounds. In isolation mode the clipping mask of the isolated container, /// or of the parent of the single object isolated, does not limit the marquee, as it does not limit @@ -1573,14 +1612,33 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh } NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds, outline)), NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds, outline)), - // A box with no width or no height (an instance of a symbol that draws a straight - // line) is touched where the marquee crosses it. - _ => n.geometric_bounds_with(s).is_some_and(|b| { - let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; - b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses - }), + // A symbol instance is touched by its box, and outside it where the live geometry + // effects of a clipping path in its art show the art. + NodeKind::SymbolInstance { .. } => box_touches(n, s, r) || !outline && instance_art_touches(n, s, r, whole_compounds), + _ => box_touches(n, s, r), } } + /// Does the marquee `r` touch the box of `n`? A box with no width or no height (an instance of + /// a symbol that draws a straight line) is touched where the marquee crosses it. + fn box_touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool { + n.geometric_bounds_with(s).is_some_and(|b| { + let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; + b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses + }) + } + /// Does the marquee `r` touch the art of symbol instance `n` placed as the canvas draws it, as + /// it touches a group of that art? Only for art whose clipping paths have live geometry effects + /// ([`SymbolBoxes::art`]), and only when `r` meets the instance's reach + /// ([`Node::reach_bounds_with`]): elsewhere the art lies within the instance's box. + fn instance_art_touches(n: &Node, s: &SymbolBoxes, r: Rect, whole_compounds: bool) -> bool { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return false }; + let meets = |b: Rect| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1; + if s.art(symbol).is_none_or(|a| a.reshaped.is_none()) || !n.reach_bounds_with(s).is_some_and(meets) { + return false; + } + let Some(art) = InstanceArt::of(s.document(), n) else { return false }; + art.place(&art.art, None).is_some_and(|placed| placed.visible && touches(&placed, s, r, None, whole_compounds, false)) + } fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool, outline: bool, out: &mut Vec) { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index dde7365424..ffb32d851d 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1852,6 +1852,94 @@ fn a_symbol_part_follows_the_region_its_clipping_layers_effects_give_it() { assert!(symbol_part_outline(&d, n, &[0, 1]).unwrap().elements().is_empty(), "nothing is drawn, so nothing is outlined"); } +/// A symbol instance whose art holds a clip group or a clipping layer with a live effect that moves +/// its clipping path is clicked, and touched by a marquee, where the moved region shows the art past +/// the art's box: inside a group, nested in another symbol's art, moved and scaled. In Outline view, +/// below a layer whose Preview is off, where the art is hidden and where no region is left, only the +/// box counts. +#[test] +fn a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art() { + use vectorcraft_geom::Affine; + set_clip_region(moved_clip_region); + // `effect` on the clipping path of a clip group (a clipping layer with `layer`), whose art is + // shown when `shown`; `xf` places the instance, which with `nest` shows a symbol whose art holds + // an instance of the clipped symbol. The instance is in a group: the group, the instance. + let build = |effect: &str, xf: Affine, nest: bool, shown: bool, layer: bool| { + let mut d = Document::new(600.0, 400.0); + let l = d.layers[0].id; + let (mut mask, mut art) = half_clipped(&mut d); + mask.appearance.effects.push(crate::Effect { id: effect.into(), ..test_move() }); + art.visible = shown; + let mut clip = if layer { + let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![Arc::new(mask), Arc::new(art)]; + } + layer + } else { + Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(art)]) + }; + clip.set_clips(true); + let root = Node::group(d.alloc_id(), vec![Arc::new(clip)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(root) }); + let mut symbol = "Clipped"; + if nest { + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: Affine::IDENTITY }); + let root = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(root) }); + symbol = "Outer"; + } + let (g, inst) = (d.alloc_id(), d.alloc_id()); + let instance = Node::new(inst, NodeKind::SymbolInstance { symbol: symbol.into(), xf }); + d.insert(Some(l), 0, Node::group(g, vec![Arc::new(instance)])).unwrap(); + (d, g, inst) + }; + let opt = HitOptions::default(); + let outline = HitOptions { outline: true, ..opt }; + let at = |d: &Document, x: f64, y: f64, opt: HitOptions| hit_test(d, Point::new(x, y), opt).map(|h| h.leaf); + // Across the art's top edge in the moved region, and past the art's right end. + let (edge, past) = (Rect::new(140.0, -5.0, 160.0, 5.0), Rect::new(240.0, -5.0, 260.0, 5.0)); + for layer in [false, true] { + let (d, g, inst) = build("test.move", Affine::IDENTITY, false, true, layer); + assert_eq!(at(&d, 150.0, 50.0, opt), Some(inst), "in the moved region, layer {layer}"); + assert_eq!(hit_test(&d, Point::new(150.0, 50.0), opt).map(|h| h.top_object(None)), Some(g)); + assert_eq!(at(&d, 50.0, 50.0, opt), Some(inst), "in the box"); + assert_eq!(at(&d, 250.0, 50.0, opt), None, "past the art"); + assert_eq!(at(&d, 150.0, 150.0, opt), None, "below the art"); + assert_eq!(at(&d, 150.0, 50.0, outline), None, "Outline view draws the plain clipping path"); + assert_eq!(marquee(&d, edge, None, false), [g], "layer {layer}"); + assert_eq!(marquee(&d, edge, None, true), [inst], "layer {layer}"); + assert!(marquee(&d, past, None, false).is_empty()); + assert!(marquee_in_view(&d, edge, None, false, true).is_empty(), "Outline view"); + } + // Preview off for the layer the instance is on. + let (mut d, _, _) = build("test.move", Affine::IDENTITY, false, true, false); + if let NodeKind::Layer { preview, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind { + *preview = false; + } + assert_eq!(at(&d, 150.0, 50.0, opt), None, "Preview off"); + assert!(marquee(&d, edge, None, false).is_empty(), "Preview off"); + // Moved, scaled (the region moves 100 pt from the placed clipping path) and nested. + let (d, _, inst) = build("test.move", Affine::translate((10.0, 20.0)), false, true, false); + assert_eq!(at(&d, 160.0, 70.0, opt), Some(inst), "moved"); + let (d, _, inst) = build("test.move", Affine::scale(2.0), false, true, false); + assert_eq!(at(&d, 250.0, 100.0, opt), Some(inst), "scaled"); + assert_eq!(at(&d, 350.0, 100.0, opt), None, "scaled, past the region"); + let (d, g, inst) = build("test.move", Affine::IDENTITY, true, true, false); + assert_eq!(at(&d, 150.0, 50.0, opt), Some(inst), "nested"); + assert_eq!(marquee(&d, edge, None, false), [g], "nested"); + // Hidden art, and an effect that leaves no region: clicks find only the box. The marquee still + // touches the moved clipping path on the edge of its region, as in a clip group, unless no + // region is left. + for (effect, shown) in [("test.move", false), ("test.empty", true)] { + let (d, g, inst) = build(effect, Affine::IDENTITY, false, shown, false); + assert_eq!(at(&d, 150.0, 50.0, opt), None, "{effect}, shown {shown}"); + assert_eq!(at(&d, 50.0, 50.0, opt), Some(inst), "{effect}, shown {shown}: in the box"); + let touched: &[NodeId] = if shown { &[] } else { &[g] }; + assert_eq!(marquee(&d, edge, None, false), touched, "{effect}, shown {shown}"); + } +} + /// In a dynamic symbol's art that placing through its 9-slice guides could make more than /// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the /// canvas draws nothing of it. At the art's natural size the same art is picked. diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 1aad3a27cf..f7c8f87ded 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -228,7 +228,11 @@ impl Node { /// grown by the farthest a stroke in the art can reach. For an instance placed through 9-slice /// guides, that box also holds the art box placed by each cell's map /// ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an instance of a - /// symbol with 9-slice guides at any depth is measured as its art placed by its transform. + /// symbol with 9-slice guides at any depth is measured as its art placed by its transform. An + /// instance whose art holds a clipping path with live geometry effects also takes in the region + /// they give it ([`crate::hit::set_clip_region`]): moved with the instance when it only moves its + /// art, otherwise placed by it and grown by as far as the region reaches past the art, times √2 + /// and the larger of 1 and the Frobenius norm of the transform's linear part. pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option { symbols.quickly(|| self.reach(Some(symbols))) } @@ -268,6 +272,30 @@ impl Node { NodeKind::Layer { children, clip: true, .. } | NodeKind::Group { children, clip: true } => { vectorcraft_geom::union_opt(self.visual(symbols), children.first().and_then(|c| crate::hit::reshaped_clip_box(c))) } + // The live geometry effects of the clipping paths in the art can draw it past its box. + NodeKind::SymbolInstance { symbol, xf } => { + let Some((s, art, region)) = symbols.and_then(|s| { + let a = s.art(symbol)?; + Some((s, a, a.reshaped?)) + }) else { + return self.visual(symbols); + }; + let map = s.document().symbol_slice_map_of(symbol, art.geometric, *xf); + let placed = if moves_only(*xf) && map.is_none() { + xf.transform_rect_bbox(region) + } else { + // An effect can reach a fixed distance past the art or a distance in proportion + // to its size, as `SymbolBoxes::effects_reach` estimates it. + let g = art.geometric; + let all = region.union(g); + let past = [g.x0 - region.x0, region.x1 - g.x1, g.y0 - region.y0, region.y1 - g.y1].into_iter().fold(0.0_f64, f64::max); + let [a, b, c, d, _, _] = xf.as_coeffs(); + let grow = past * std::f64::consts::SQRT_2 * (a * a + b * b + c * c + d * d).sqrt().max(1.0); + map.map_or_else(|| xf.transform_rect_bbox(all), |m| m.placement_bounds(all)).inflate(grow, grow) + }; + let o = self.appearance.outset(); + vectorcraft_geom::union_opt(self.visual(symbols), self.projected(Some(placed)).map(|b| b.inflate(o, o))) + } _ => self.visual(symbols), } } diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 944accfe4b..99a65ae1f7 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -47,6 +47,11 @@ pub(crate) struct ArtBox { /// Whether the art holds an instance of a symbol with 9-slice guides, or of a symbol whose art /// holds one at any depth. pub(crate) sliced: bool, + /// The box of the regions that the live geometry effects of the clipping paths in the art give + /// them ([`crate::hit::set_clip_region`]), and of the instances in the art whose art has such + /// regions, each measured by [`Node::reach_bounds_with`]. `None` when nothing in the art has + /// one. + pub(crate) reshaped: Option, } /// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`], @@ -562,7 +567,18 @@ fn art_box(art: &Arc, symbols: &SymbolBoxes) -> Option { sliced |= symbols.doc.symbol_nine_slice(symbol).is_some() || symbols.art(symbol).is_some_and(|a| a.sliced); } }); - Some(ArtBox { geometric, visual, reach, effects, brush, sliced }) + let mut reshaped = None; + if effects { + art.walk(&mut |n| { + let b = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } if symbols.art(symbol).is_some_and(|a| a.reshaped.is_some()) => n.reach(Some(symbols)), + _ if n.clips() => n.children().and_then(|c| c.first()).and_then(|c| crate::hit::reshaped_clip_box(c)), + _ => None, + }; + reshaped = vectorcraft_geom::union_opt(reshaped, b.filter(finite)); + }); + } + Some(ArtBox { geometric, visual, reach, effects, brush, sliced, reshaped }) } #[cfg(test)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6972468cc..525cac27e0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -645,6 +645,7 @@ since 2026-10-01. - **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. +- **Symbol instances are selected where their art draws past their box:** a click or a marquee selects a symbol instance where a clipping path in its art, moved or reshaped by its live geometry effects, shows the art past the instance's box, as it selects a group of the same art; a click also selects it where a stroke in its art paints past the box ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects.html)). The rest is VectorCraft's own rule: inside the box the instance is selected as before; in Outline view and below a layer whose Preview is off only the box counts; hidden art and the area a raster effect paints are left out. This covers instances in groups, nested in other symbols' art, moved and scaled. Regression test: `a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art` in `crates/doc/src/hit/tests.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit leaves out groups holding type or images:** Select › Start Global Edit on a group that holds type or an image, at any depth, refuses to start, and such a group is not taken as a match, as Global Edit does not take images or text ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test for a group holding type: `global_edit_needs_one_supported_object` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From cb5b3e92fdc7c81e6349da86ee9b951651085a7b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 17:24:52 -0800 Subject: [PATCH 04/24] Legacy .ai: a colorized warped image works out its coverage once Each piece of a colorized image cut by a Warp effect or an envelope decoded the image again to work out how much of the color each pixel shows, although the decoded image was already kept for the pieces that follow one another. The writer now keeps the coverage next to the decoded image, for the same data and type, and works it out again only when the image changes. The file written is unchanged. --- crates/eps/src/legacy_ai/tests.rs | 35 ++++++++++++++++++++++++++++++ crates/eps/src/legacy_ai/writer.rs | 32 ++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index ad27eb4a03..15808c1798 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -790,6 +790,41 @@ fn colorized_images_keep_their_color() { assert_eq!(out.warnings, [IMAGE_ALPHA]); } +/// The pieces of a colorized warped image work out the image's coverage once, and an image with +/// other data works out its own: a black and white image's pieces are written as image masks +/// in its color, and a gray image's pieces with its color in their pixels. +#[test] +fn the_pieces_of_a_colorized_warped_image_work_out_its_coverage_once() { + // GUARDS: each piece of a colorized warped image decoded the image again for its coverage. + let mut doc = Document::new(400.0, 200.0); + doc.layers.clear(); + let layer = doc.add_layer(Some("Art")); + let paint = Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)); + let checks: Vec = (0..16 * 16).map(|i| if (i % 16 + i / 16) % 2 == 0 { 0 } else { 255 }).collect(); + let ramp: Vec = (0..16 * 16).map(|i| (i % 16 * 16) as u8).collect(); + for (gray, x) in [(checks, 10.0), (ramp, 210.0)] { + let photo = colorized_image(&mut doc, (16, 16), gray, &paint, (x, 10.0)); + let NodeKind::Image(im) = &photo.kind else { panic!("{:?}", photo.kind) }; + let bend = move |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 8.0 * ((p.x - x) / 160.0 * std::f64::consts::PI).sin()); + let warped = vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, false); + doc.insert(Some(layer), usize::MAX, warped).unwrap(); + } + super::writer::COVERAGE_RUNS.with(|n| n.set(0)); + let out = write(&doc); + assert_eq!(super::writer::COVERAGE_RUNS.with(std::cell::Cell::get), 2, "once for each image"); + let text = String::from_utf8(out.bytes).unwrap(); + let images = rasters(&text); + assert_eq!(images.len(), 64, "32 pieces of each image"); + let xi = |piece: &Vec<&str>| piece.iter().find(|l| l.ends_with(" XI")).map(|l| l.to_string()).unwrap_or_default(); + assert!(images[..32].iter().all(|p| xi(p).ends_with(" 1 1 0 0 0 1 XI")), "{:?}", images[..32].iter().map(xi).collect::>()); + assert!(images[32..].iter().all(|p| xi(p).ends_with(" 8 3 0 0 0 0 XI")), "{:?}", images[32..].iter().map(xi).collect::>()); + // The first piece holds the whole image: each row of 16 pixels in two bytes, its black pixels 0 bits. + assert!(xi(&images[0]).ends_with(" ] 0 0 16 16 16 16 1 1 0 0 0 1 XI"), "{}", xi(&images[0])); + let data: Vec<&str> = images[0].iter().filter_map(|l| l.strip_prefix('%')).filter(|l| l.bytes().all(|b| b.is_ascii_hexdigit())).collect(); + assert_eq!(data.concat(), "5555AAAA".repeat(8)); + assert_eq!(out.warnings, [IMAGE_ALPHA]); +} + /// An image mask is only for a colorized image the writer draws as the document does: not one /// with an opacity mask turned on, and not one with more pixels than the writer writes. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 0b41c3defa..bd45fa3320 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -11,6 +11,15 @@ use super::{ }; use super::{CONIC, CONIC_WEDGES, DIAMOND, GRAPHS, HUE_STOPS, SPREAD, SPREAD_CAPPED}; +#[cfg(test)] +thread_local! { + /// How many times [`Writer::coverage`] has worked out an image's coverage on this thread. + pub(super) static COVERAGE_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// An image's coverage ([`ImageBlob::coverage`]): its width, its height and one byte a pixel. +type Coverage = (u32, u32, Arc>); + pub(super) struct Writer<'a> { doc: &'a Document, /// The document → art space. @@ -50,6 +59,9 @@ pub(super) struct Writer<'a> { /// The image decoded last (`None` when it can't be): the pieces of a warped image, which follow /// one another, decode it once. last_image: Option<(ImageBlob, Option>)>, + /// The coverage of [`Self::last_image`] once a colorized piece of it asked for it (`Some(None)` + /// when it can't be worked out), so the pieces of a colorized warped image work it out once. + last_coverage: Option>, /// The images written whole so far, by the address of their data (which each entry keeps). whole_images: std::collections::HashMap>>, } @@ -77,6 +89,7 @@ impl<'a> Writer<'a> { kumi: false, clips: vec![], last_image: None, + last_coverage: None, whole_images: std::collections::HashMap::new(), } } @@ -1074,7 +1087,7 @@ impl<'a> Writer<'a> { let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h)); let coverage = match colorize { Some(paint) => { - let Some((_, _, coverage)) = ImageBlob::coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { + let Some((_, _, coverage)) = self.coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { self.warn(UNDECODED); return; }; @@ -1142,9 +1155,26 @@ impl<'a> Writer<'a> { } let img = image::load_from_memory(&blob.bytes).ok().map(Arc::new); self.last_image = Some((blob.clone(), img.clone())); + self.last_coverage = None; img } + /// The coverage of `blob` ([`ImageBlob::coverage`]), worked out once for images that follow one + /// another with the same data, as [`Self::decoded`] decodes them. + fn coverage(&mut self, blob: &ImageBlob) -> Option { + let last = self.last_image.as_ref().is_some_and(|(last, _)| Arc::ptr_eq(&last.bytes, &blob.bytes) && last.mime == blob.mime); + if last && let Some(coverage) = &self.last_coverage { + return coverage.clone(); + } + #[cfg(test)] + COVERAGE_RUNS.with(|n| n.set(n.get() + 1)); + let coverage = ImageBlob::coverage(blob).map(|(w, h, c)| (w, h, Arc::new(c))); + if last { + self.last_coverage = Some(coverage.clone()); + } + coverage + } + /// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]` /// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`] /// digits, `%AI5_EndRaster`, then `F`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6972468cc..70731c1a4f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -633,6 +633,7 @@ since 2026-10-01. - **`.ai` files keep a linked image's Warp live and its link:** a linked image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp, as an embedded one is: its bent pieces are its drawn look, and the image itself follows on `%_` lines as a placed file with its path, its folder relative to the document's and its preview. Before, it was saved as its drawn art and its live Warp was lost. Opening the file reads it as the linked image with its Warp live, its link, pixels, placement, name, opacity, hide and lock kept, under the same drawn-look comparison; a hidden image with a Warp, embedded or linked, is now compared with its look drawn shown, and keeps its Warp too. A linked image with a Warp in an opacity mask's art keeps its Warp and is embedded, with the existing warning for linked images in masks. Linked files stay connected to their source and track it by relative path ([the help page](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/manage-linked-and-embedded-files/links-panel-overview.html)); the placed-file form is the one the `.ai` reader reads (`crates/eps/src/import/ai/read/art.rs` `end_place`). eps `ai_editing::tests::a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live`, `a_large_linked_image_with_a_warp_reads_back_from_its_preview`, `a_hidden_or_locked_image_with_a_warp_keeps_its_state`, `a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live`. Scores and hours unchanged. - **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. - **`.ai` files write a warped image's pieces as the parts they show:** in the `.ai` editing data and in legacy `.ai` files, an embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written with all of its pixels the first time, and every later copy of the same pixels is written as the rectangle of them that the path or mask shows, 2 pixels more on each side, placed where it lies in the image. The pieces of a Warp effect, an envelope or their expanded art, 200 copies of the image for 10 × 10 cells before, now hold about 7 times the image's pixels in all for a 64 × 64 image. A copy whose opacity mask is left out, nested too deep or past the object limit, keeps all of its pixels. Each image's pixels are read once for the pieces that follow one another. An image cut to a rectangle of half its pixels or more, and the first copy written, keep all of their pixels. The `XI` operator's arguments give an image's location and size ([AI file-format specification, §8.1](https://web.archive.org/web/20050126105313/https://partners.adobe.com/public/developer/en/illustrator/sdk/AI7FileFormat.pdf)); the 2-pixel margin and the half-the-pixels limit are VectorCraft's own rules (eps `ai_editing::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `an_image_with_a_live_warp_writes_its_pixels_whole_once`, `a_clipped_image_written_first_keeps_all_of_its_pixels`, `a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels`, `a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels`, `legacy_ai::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`). Scores and hours unchanged. +- **Legacy `.ai` files work out a colorized warped image's coverage once:** for the pieces of a colorized image cut by a Warp effect, an envelope or their expanded art, a legacy `.ai` save works out how much of the color each pixel shows once for the pieces that follow one another, and no longer decodes the image again for each piece. The file written is unchanged (eps `legacy_ai::tests::the_pieces_of_a_colorized_warped_image_work_out_its_coverage_once`). Scores and hours unchanged. - **EPS writes a repeated freeform gradient's image once:** a freeform gradient whose image samples the same on more than one fill (copies of a shape moved with their points, or one shape in a symbol's instances) has its samples written once in the setup, as a repeated image's are, and a fill whose image differs (other colors, another size) gets its own. A freeform fill drawn once is written where it is drawn, as before, and a fill whose placement cannot be drawn, or that arrives when the page is already at the 256 MB limit, is no longer sampled first (eps `tests::a_freeform_gradients_image_drawn_twice_writes_its_samples_once`, `tests::freeform_gradients_sampled_differently_dont_share_an_image`, `tests::a_field_taken_back_at_the_page_limit_leaves_no_image_behind`). Scores and hours unchanged. - **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. From ba567301238412764caaaecf3bf44e732d2b62aa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 17:34:27 -0800 Subject: [PATCH 05/24] .ai editing data: opacity mask art in symbols and pattern tiles keeps live effects Objects in an opacity mask's art inside a symbol definition or a pattern tile are now written with their art styles, as mask art on the layers already is. A symbol or pattern whose mask art has Offset Path opens with the effect live in its definition, and its instances and pattern fills draw as before. Each definition is written with the styled objects of its own masks only, so art placed as copies inside it is not matched to another definition's original. - Mask art linked to a graphic style opens linked to it: the reader's style linking now goes into opacity masks' art. - Links in pattern tiles count toward the lost-link warning, the warning is checked after unused symbols and swatch-only patterns are written, and links in art left out at the object limit are no longer counted as kept. --- crates/eps/src/ai_editing.rs | 11 +- crates/eps/src/ai_editing/tests.rs | 289 ++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 33 ++- crates/eps/src/ai_editing/writer/paints.rs | 12 +- crates/eps/src/ai_editing/writer/styles.rs | 42 ++- crates/eps/src/ai_editing/writer/symbols.rs | 3 + crates/eps/src/import/ai/read/document.rs | 10 +- docs/file-format-parity.md | 6 +- docs/target-app-parity.md | 1 + 9 files changed, 382 insertions(+), 25 deletions(-) diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 3df902b97f..f6196165c2 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -270,6 +270,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re // The objects that may keep their effects in an art style, as they are before baking (not on // a large canvas, whose scale the styles' strokes and effects would need). let styled = writer::styled_objects(doc); + let (symbol_styled, pattern_styled) = writer::styled_definitions(doc); let own_styles = doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles; let links = writer::style_links(doc); // Live geometry effects as their result, as the PDF part draws them. @@ -307,16 +308,14 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re w.reserve_swatch_gradients(); if scale == 1.0 { w.styled = styled; + (w.symbol_styled, w.pattern_styled) = (symbol_styled, pattern_styled); w.panel_styles(); - } else if !styled.is_empty() || own_styles || links > 0 { + } else if !styled.is_empty() || !symbol_styled.is_empty() || !pattern_styled.is_empty() || own_styles || links > 0 { w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string()); } for layer in &doc.layers { w.layer(layer); } - if scale == 1.0 { - w.lost_links(links); - } // The symbols no instance uses too; their art adds definitions. w.symbols(); // The brushes, once the layers have said whether an object is written with one. @@ -324,6 +323,10 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re // The palette first: its gradient and pattern swatches add definitions. let mut palette = String::new(); w.palette(&mut palette); + // Once every definition is written: their opacity masks' art keeps links too. + if scale == 1.0 { + w.lost_links(links); + } let library = w.variable_library(); let first = to_art.transform_rect_bbox(board); let art = doc diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a01f631638..7bbf761927 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -6751,6 +6751,159 @@ fn hidden_and_nested_mask_art_keep_live_offset_path() { } } +/// An image 60 × 40 points at (10, 10) whose opacity mask, of `art`, doesn't clip. +fn masked_image_node(doc: &mut Document, art: Node) -> Node { + let im = image_node(doc, ImageBlob::png(rgb_png()), (3, 2), Affine::new([60.0, 0.0, 0.0, 40.0, 10.0, 10.0]), None); + with_mask(im, art) +} + +/// A red square from (10, 10) to (70, 50) whose opacity mask, of `art`, doesn't clip. +fn masked_square(doc: &mut Document, art: Node) -> Node { + let sq = square(doc, Rect::new(10.0, 10.0, 70.0, 50.0), Color::rgb(1.0, 0.0, 0.0)); + with_mask(sq, art) +} + +/// `n` with an opacity mask of `art` that doesn't clip. +fn with_mask(mut n: Node, art: Node) -> Node { + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true }.into()); + n +} + +/// The opacity mask art of the first object with one in `n`, its masks' art included. +fn mask_art_in(n: &Node) -> Option> { + let mut art = None; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + if art.is_none() + && let Some(mask) = m.extras().mask.as_ref() + { + art = Some(mask.art.clone()); + } + }); + art +} + +/// The Offset Path distance of `n`'s one live effect, when that is all it has. +fn offset_path_of(n: &Node) -> Option { + match n.appearance.effects.as_slice() { + [e] if e.id == "path.offsetPath" => e.params.get("offset").and_then(serde_json::Value::as_f64), + _ => None, + } +} + +/// The opacity mask art of an image in a symbol's art keeps its live effects as an art style: a +/// black path with Offset Path reads back with the effect live on the definition's mask art, when +/// the mask's art root is hidden and when the symbol is used inside another symbol's art too, and +/// both instances draw as they did. +#[test] +fn a_symbols_mask_art_keeps_live_offset_path() { + let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0})); + let made = |effects: Vec, case: &str| { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, effects); + grown.visible = case != "hidden"; + let im = masked_image_node(&mut doc, grown); + doc.symbols.push(Symbol { dynamic: false, name: "Picture".into(), art: Arc::new(im) }); + let mut used = "Picture"; + if case == "nested" { + let inner = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Picture".into(), xf: Affine::translate((5.0, 0.0)) }); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::rgb(1.0, 0.0, 0.0)); + let frame = group(&mut doc, vec![inner, dot], false); + doc.symbols.push(Symbol { dynamic: false, name: "Frame".into(), art: Arc::new(frame) }); + used = "Frame"; + } + for x in [0.0, 100.0] { + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: used.into(), xf: Affine::translate((x, 20.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + } + doc + }; + for case in ["shown", "hidden", "nested"] { + let doc = made(vec![offset.clone()], case); + assert!(pixels_apart(&doc, &made(vec![], case)) > 0, "{case}: the effect draws nothing"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{case}: {:?}", out.warnings); + let back = read_back(&doc); + let picture = back.symbols.iter().find(|s| s.name == "Picture").unwrap_or_else(|| panic!("{case}: {:?}", back.symbols)); + let art = mask_art_in(&picture.art).unwrap_or_else(|| panic!("{case}: no masked image in {:?}", picture.art.kind)); + assert_eq!(offset_path_of(&art), Some(6.0), "{case}: {:?}", art.appearance.effects); + assert_eq!(pixels_apart(&doc, &back), 0, "{case}"); + } +} + +/// The opacity mask art of a square in a pattern's tile keeps its live effects as an art style: the +/// tile's black path with Offset Path reads back with the effect live, and the pattern fill draws as +/// it did. +#[test] +fn a_pattern_tiles_mask_art_keeps_live_offset_path() { + let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0})); + let made = |effects: Vec| { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, effects); + let sq = masked_square(&mut doc, grown); + let tile = Rect::new(0.0, 0.0, 80.0, 60.0); + doc.patterns.push(vectorcraft_doc::PatternDef { tile, ..vectorcraft_doc::PatternDef::new("Masked", vec![Arc::new(sq)]) }); + let fill = Paint::Pattern { pattern: "Masked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + doc + }; + let doc = made(vec![offset]); + assert!(pixels_apart(&doc, &made(vec![])) > 0, "the effect draws nothing"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + let def = back.pattern("Masked").unwrap_or_else(|| panic!("{:?}", back.patterns.iter().map(|p| &p.name).collect::>())); + let art = def.art.iter().find_map(|a| mask_art_in(a)).expect("a masked image in the tile"); + assert_eq!(offset_path_of(&art), Some(6.0), "{:?}", art.appearance.effects); + assert_eq!(pixels_apart(&doc, &back), 0); +} + +/// Each opacity mask's art keeps its own art style: masks on the layers, in a symbol's art and in a +/// pattern's tile with different Offset Path distances read back with their own distances, and a +/// symbol's mask art with an effect no art style holds (Roughen) reads back without a live effect. +#[test] +fn mask_art_styles_stay_with_their_own_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let masked = |doc: &mut Document, effect: vectorcraft_doc::Effect| { + let grown = styled_rect(doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, vec![effect]); + masked_image_node(doc, grown) + }; + let by = |d: f64| fx("path.offsetPath", serde_json::json!({"offset": d})); + let on_layer = masked(&mut doc, by(3.0)); + doc.insert(Some(layer), usize::MAX, on_layer).unwrap(); + let picture = masked(&mut doc, by(6.0)); + doc.symbols.push(Symbol { dynamic: false, name: "Picture".into(), art: Arc::new(picture) }); + let rough = masked(&mut doc, fx("distort.roughen", serde_json::json!({"size": 6.0, "relative": false, "points": "corner"}))); + doc.symbols.push(Symbol { dynamic: false, name: "Rough".into(), art: Arc::new(rough) }); + for (symbol, x) in [("Picture", 100.0), ("Rough", 120.0)] { + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf: Affine::translate((x, 40.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + } + let grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, vec![by(9.0)]); + let tile = masked_square(&mut doc, grown); + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Masked", vec![Arc::new(tile)]) + }); + let fill = Paint::Pattern { pattern: "Masked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 60.0, 80.0, 100.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let back = read_back(&doc); + let symbol_art = |name: &str| { + let s = back.symbols.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("{name}: {:?}", back.symbols)); + mask_art_in(&s.art).unwrap_or_else(|| panic!("{name}: no masked image")) + }; + assert_eq!(offset_path_of(&image_mask_art(&back)), Some(3.0)); + assert_eq!(offset_path_of(&symbol_art("Picture")), Some(6.0)); + let tile = back.pattern("Masked").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("a masked image in the tile"); + assert_eq!(offset_path_of(&tile), Some(9.0)); + let rough = symbol_art("Rough"); + assert!(rough.appearance.effects.is_empty(), "{:?}", rough.appearance.effects); +} + /// A geometry effect on an opacity mask's art that no art style holds (Roughen) is applied to the /// art written, so the mask reads back drawing what it drew, but for pixels on its edge that the /// written points' rounding moves. @@ -6890,6 +7043,142 @@ fn a_filled_even_odd_object_is_written_as_its_drawn_look() { assert_eq!(n.extras().graphic_style, None); } +/// Links to graphic styles in pattern tiles count toward the warning about lost links: a tile's +/// object linked to a style, which is written without its link, warns; and so does an object on the +/// layers that loses its link while a tile's opacity mask art keeps its own. +#[test] +fn pattern_tile_style_links_count_toward_lost_links() { + let made = |linked_tile: bool| { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap.clone()); + linked.extras_mut().graphic_style = Some(7); + let art = if linked_tile { + linked + } else { + // An even-odd path is written without its link (see the test above). + let mut lost = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 140.0, 40.0)), ap); + lost.extras_mut().graphic_style = Some(7); + if let NodeKind::Path { rule, .. } = &mut lost.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, lost); + masked_square(&mut doc, linked) + }; + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Linked", vec![Arc::new(art)]) + }); + let fill = Paint::Pattern { pattern: "Linked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 80.0, 100.0)), Appearance::basic(fill, Paint::None, 0.0)); + put(&mut doc, filled); + doc + }; + for linked_tile in [true, false] { + let doc = made(linked_tile); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{linked_tile}: {:?}", out.warnings); + } + let back = read_back(&made(false)); + let tile = back.pattern("Linked").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("a masked square in the tile"); + assert!(tile.extras().graphic_style.is_some(), "the tile's mask art keeps its link"); +} + +/// An opacity mask's art linked to a graphic style reads back linked to it. +#[test] +fn opacity_mask_art_keeps_its_graphic_style_link() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap); + linked.extras_mut().graphic_style = Some(7); + let sq = masked_square(&mut doc, linked); + put(&mut doc, sq); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let art = mask_art_in(&back.layers[0]).expect("a masked square"); + let id = art.extras().graphic_style.expect("the mask's art keeps its link"); + assert_eq!(back.graphic_style_by_id(id).map(|g| g.name.as_str()), Some("Solid")); +} + +/// A link written in a pattern tile's opacity mask art that the object limit then leaves out with +/// the rest of the tile is not counted as kept: at every limit where the style isn't written, the +/// export warns that the link is lost. +#[test] +fn mask_art_links_left_out_at_the_object_limit_are_reported_lost() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap); + linked.extras_mut().graphic_style = Some(7); + let mut art = vec![Arc::new(masked_square(&mut doc, linked))]; + for i in 0..6 { + let x = 2.0 + 12.0 * f64::from(i); + art.push(Arc::new(square(&mut doc, Rect::new(x, 52.0, x + 8.0, 58.0), Color::rgb(0.0, 0.0, 1.0)))); + } + doc.patterns.push(vectorcraft_doc::PatternDef { tile: Rect::new(0.0, 0.0, 80.0, 60.0), ..vectorcraft_doc::PatternDef::new("Linked", art) }); + let fill = Paint::Pattern { pattern: "Linked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let mut left_out = 0; + for max in 1..40 { + let out = export_within(&doc, &opts(), max).unwrap(); + let text = String::from_utf8_lossy(&out.program).into_owned(); + if !text.contains("(Solid) XW") { + left_out += 1; + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{max}: {:?}", out.warnings); + } + } + assert!(left_out > 0 && left_out < 39, "{left_out}"); +} + +/// The links of opacity masks' art in a symbol no instance uses and in a pattern only the Swatches +/// panel holds are written with those definitions, after the layers, and count as kept: no warning +/// about lost links. +#[test] +fn mask_art_links_in_unused_definitions_are_not_reported_lost() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let linked = |doc: &mut Document| { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap.clone()); + n.extras_mut().graphic_style = Some(7); + masked_square(doc, n) + }; + let unused = linked(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "Unused".into(), art: Arc::new(unused) }); + let tile = linked(&mut doc); + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Swatch only", vec![Arc::new(tile)]) + }); + let swatch = Swatch { + name: "Swatch only".into(), + paint: Paint::Pattern { pattern: "Swatch only".into(), xf: Affine::IDENTITY }, + global: false, + spot: false, + }; + swatches_plus(&mut doc, vec![swatch]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let symbol = back.symbols.iter().find(|s| s.name == "Unused").and_then(|s| mask_art_in(&s.art)).expect("the unused symbol's mask art"); + assert!(symbol.extras().graphic_style.is_some(), "the symbol's mask art keeps its link"); + let tile = back.pattern("Swatch only").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("the swatch's mask art"); + assert!(tile.extras().graphic_style.is_some(), "the pattern's mask art keeps its link"); +} + /// A compound path filled by the non-zero rule keeps its effects, its gradient fill and its link to /// a graphic style. #[test] diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 98b027f79d..8df30c69bc 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -28,7 +28,7 @@ mod variables; pub(super) use brushes::BRUSHES; #[cfg(test)] pub(super) use envelopes::ENVELOPES; -pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; +pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_definitions, styled_objects}; #[cfg(test)] pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, WARPED_IMAGES, same_appearance}; @@ -133,6 +133,10 @@ struct Charges { kept_objects: usize, kept_symbol_nodes: u64, left_out: usize, + /// The objects written with the graphic style they are linked to, and those of them in the + /// definitions written. + links: usize, + kept_links: usize, } pub(super) struct Writer<'a> { @@ -171,6 +175,8 @@ pub(super) struct Writer<'a> { definition_objects: usize, /// The objects of symbol art in those definitions ([`Self::symbol_nodes`]). definition_symbol_nodes: u64, + /// The objects in those definitions written with the graphic style they are linked to. + definition_links: usize, /// The most objects written ([`Self::take_objects`]): the most the reader reads, /// [`crate::import::MAX_EDITING_OBJECTS`]. pub(super) max_objects: usize, @@ -262,6 +268,12 @@ pub(super) struct Writer<'a> { /// The objects that may be written with an art style, as they were before baking, by id /// ([`styles::styled_objects`]). pub(super) styled: HashMap>, + /// The objects of the opacity masks' art in each symbol's art and each pattern's tile art that + /// may be written with an art style, by the symbol's or pattern's name + /// ([`styles::styled_definitions`]). [`Self::define_symbol`] and [`Self::define_pattern`] write + /// a definition's art with its own as [`Self::styled`]. + pub(super) symbol_styled: HashMap, + pub(super) pattern_styled: HashMap, /// The art styles written ([`styles`]). styles: styles::Styles, /// An object's drawn look is being written: no object in it is written with a style, and its @@ -350,6 +362,8 @@ impl<'a> Writer<'a> { symbol_scales: HashMap::new(), root_entries: None, styled: HashMap::new(), + symbol_styled: HashMap::new(), + pattern_styled: HashMap::new(), styles: styles::Styles::default(), in_look: false, drawn: 0, @@ -361,6 +375,7 @@ impl<'a> Writer<'a> { objects: 0, left_out: 0, definition_objects: 0, + definition_links: 0, definition_symbol_nodes: 0, max_objects: crate::import::MAX_EDITING_OBJECTS, last_samples: None, @@ -416,6 +431,8 @@ impl<'a> Writer<'a> { kept_objects: self.definition_objects, kept_symbol_nodes: self.definition_symbol_nodes, left_out: self.left_out, + links: self.styles.links_written, + kept_links: self.definition_links, } } @@ -429,15 +446,18 @@ impl<'a> Writer<'a> { fn keep_since(&mut self, c: Charges) { self.definition_objects = c.kept_objects + (self.objects - c.objects); self.definition_symbol_nodes = c.kept_symbol_nodes + (self.symbol_nodes - c.symbol_nodes); + self.definition_links = c.kept_links + (self.styles.links_written - c.links); } - /// Art written since `c` as `lines` is not used: the objects and symbol art charged since are - /// given back, except those of the definitions written since, and the images `lines` marks + /// Art written since `c` as `lines` is not used: the objects and symbol art charged since, and + /// the objects counted as written with their graphic style, are given back, except those of the + /// definitions written since, and the images `lines` marks /// are emptied (the others keep their indices). An image whose whole copy is among them is no /// longer written whole ([`Self::whole_images`]). fn give_back(&mut self, c: Charges, lines: &str) { self.objects = c.objects + (self.definition_objects - c.kept_objects); self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes); + self.styles.links_written = c.links + (self.definition_links - c.kept_links); let mut emptied = HashSet::new(); for index in lines.split(PAYLOAD).skip(1).step_by(2) { if let Some((i, p)) = index.parse::().ok().and_then(|i| Some((i, self.payloads.get_mut(i)?))) { @@ -655,12 +675,11 @@ impl<'a> Writer<'a> { if n.extras().graph.is_some() && !self.in_look { self.warn(GRAPHS); } - // On the layers or in an opacity mask's art, outside art cut to a clipping object: with its - // art style, when it has one. + // On the layers or in an opacity mask's art (a symbol's and a pattern tile's included), + // outside art cut to a clipping object: with its art style, when it has one. if !self.in_look - && ((self.hidden == 0 && self.nest == 0) || self.mask_level == Some((self.hidden, self.nest))) + && ((self.hidden == 0 && self.nest == 0 && !self.in_symbol) || self.mask_level == Some((self.hidden, self.nest))) && self.clip.is_none() - && !self.in_symbol && let Some(orig) = self.styled.get(&n.id).cloned() && self.check_left(&orig) && let Some(name) = self.object_style(n, &orig) diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index d0a90ce86a..e2702b5714 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -483,6 +483,8 @@ impl Writer<'_> { self.gs.replace(Xy::OPAQUE), (self.clip.take(), std::mem::take(&mut self.shown)), self.block_end, + // The tile's masks' objects are written with their art styles, those of no other art. + (std::mem::replace(&mut self.styled, self.pattern_styled.get(name).cloned().unwrap_or_default()), self.mask_level.take()), ); // `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its // placement is worked out in the tile's space turned y up. @@ -505,7 +507,15 @@ impl Writer<'_> { self.hidden -= 1; self.nest -= 1; let art; - (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, (self.clip, self.shown), self.block_end) = saved; + ( + art, + (self.to_art, self.grad_space, self.canvas), + self.state, + self.gs, + (self.clip, self.shown), + self.block_end, + (self.styled, self.mask_level), + ) = saved; let art = std::mem::replace(&mut self.body, art); self.defining.pop(); if self.left_out_since(before) { diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index a8a2da677c..50aa4df931 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -26,7 +26,8 @@ pub(in crate::ai_editing) const PANEL_EMPTY: &str = pub(in crate::ai_editing) const WARPED_IMAGES: &str = "images with a Warp after the first 256 envelopes and images with a Warp are written as their drawn look, without their Warp, in the editing data for other apps"; pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style"; -/// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. +/// How many objects of `doc`'s layers, opacity masks, symbols and pattern tiles are linked to a +/// graphic style. pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { fn count(n: &Node) -> usize { // Ruler guides are not written as objects. @@ -37,7 +38,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) } - doc.layers.iter().map(|l| count(l)).sum::() + doc.symbols.iter().map(|s| count(&s.art)).sum::() + doc.layers.iter().map(|l| count(l)).sum::() + + doc.symbols.iter().map(|s| count(&s.art)).sum::() + + doc.patterns.iter().flat_map(|p| p.art.iter()).map(|a| count(a)).sum::() } /// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art @@ -48,10 +51,30 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { /// Knockout Shape (the reader takes an object's transparency /// from its look, which doesn't carry it). A group's members are paths, compound paths or such /// groups, none with effects or anything baking changes. -pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { - fn walk(nodes: &[Arc], out: &mut HashMap>) { +pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> StyledMap { + styled_in(&doc.layers, true) +} + +/// The objects that may be written with an art style, by id, as they are before baking. +pub(in crate::ai_editing) type StyledMap = HashMap>; + +/// The objects of the opacity masks' art in each of `doc`'s symbols' art, by symbol name, and in +/// each of its patterns' tile art, by pattern name, that may be written with an art style, as +/// [`styled_objects`] finds them on the layers. A definition's own objects outside its masks' art +/// are not among them, and definitions without such objects are left out. +pub(in crate::ai_editing) fn styled_definitions(doc: &Document) -> (HashMap, HashMap) { + let symbols = doc.symbols.iter().map(|s| (s.name.clone(), styled_in(std::slice::from_ref(&s.art), false))); + let patterns = doc.patterns.iter().map(|p| (p.name.clone(), styled_in(&p.art, false))); + let kept = |(_, m): &(String, StyledMap)| !m.is_empty(); + (symbols.filter(kept).collect(), patterns.filter(kept).collect()) +} + +/// The objects of `nodes` that may be written with an art style ([`styled_objects`]): with `own`, +/// `nodes` and their members as well as their opacity masks' art, else their masks' art only. +fn styled_in(nodes: &[Arc], own: bool) -> StyledMap { + fn walk(nodes: &[Arc], own: bool, out: &mut StyledMap) { for n in nodes { - if candidate(n) { + if own && candidate(n) { out.insert(n.id, n.clone()); } if let Some(m) = n.extras().mask.as_ref() { @@ -59,7 +82,7 @@ pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap HashMap { let doc = self.doc; // A new document's styles are what the reader gives a file without a style list; they are // still written when an object is linked to one of them, so that it keeps its link. - let linked = self.styled.values().any(|n| n.extras().graphic_style.is_some()); + let definitions = self.symbol_styled.values().chain(self.pattern_styled.values()).flat_map(HashMap::values); + let linked = self.styled.values().chain(definitions).any(|n| n.extras().graphic_style.is_some()); if !linked && doc.graphic_styles == Document::new(1.0, 1.0).graphic_styles { return; } diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index 0c8d2c7d3b..4d4bb2acba 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -252,6 +252,8 @@ impl Writer<'_> { self.root_entries.take(), std::mem::take(&mut self.wrote_pattern), (std::mem::take(&mut self.wrote_fixed), std::mem::replace(&mut self.inner_scale, 1.0)), + // The art's masks' objects are written with their art styles, those of no other art. + (std::mem::replace(&mut self.styled, self.symbol_styled.get(name).cloned().unwrap_or_default()), self.mask_level.take()), ); // Gradients' matrices and instances' matrices are written about the canvas point of art // space's origin, y up; `Bg` places a gradient on the art as it lies in the symbol's space. @@ -309,6 +311,7 @@ impl Writer<'_> { self.root_entries, self.wrote_pattern, (self.wrote_fixed, self.inner_scale), + (self.styled, self.mask_level), ) = saved; self.doc = page_doc; (self.in_look, self.drawn) = (in_look, drawn); diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index ba6a8bd2de..df51a99393 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1393,8 +1393,9 @@ impl<'a> Reader<'a> { } /// Link the objects of `links` among `nodes` to their graphic style's id, each only when it has its -/// style's appearance and transparency (`styles`, whose ids are 1, 2, … in order). A path, which -/// has no contents, takes the style's contents slot. +/// style's appearance and transparency (`styles`, whose ids are 1, 2, … in order), their members +/// and their opacity masks' art included. A path, which has no contents, takes the style's contents +/// slot. fn link_styles(nodes: &mut [Arc], links: &HashMap, styles: &[GraphicStyle], depth: usize) { if depth > MAX_DEPTH { return; @@ -1413,6 +1414,11 @@ fn link_styles(nodes: &mut [Arc], links: &HashMap, styles: &[ n.extras_mut().graphic_style = Some(*id); } } + if n.extras().mask.is_some() + && let Some(m) = Arc::make_mut(n).extras_mut().mask.as_mut() + { + link_styles(std::slice::from_mut(&mut m.art), links, styles, depth + 1); + } if n.children().is_some_and(|c| !c.is_empty()) && let Some(c) = Arc::make_mut(n).children_mut() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bf52925f5c..76b51a1ced 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -106,7 +106,9 @@ private editing data. VectorCraft: Round Corners, Scribble, Convert to Shape, Offset Path, Outline Object or Outline Stroke, and groups with Pathfinder › Add. Groups keep them when their members are paths, compound paths or such groups, without effects of their own; when the group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern - fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects with + fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. + This holds for objects on the layers and in opacity masks' art, the masks' art in a symbol's art and in a pattern's tile + included; a symbol's or a pattern's other objects are written as described next. Other objects with live effects are written as their vector art with their geometry effects applied, and their other effects, such as Drop Shadow, the glows and Feather, are left out with a warning. A path, compound path or group linked to a graphic style names it when it has the style's appearance and the style is written; other links to graphic styles, those on a styled group's members included, are @@ -270,7 +272,7 @@ The app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. -An object in a pattern's art or in a brush's art takes the file's art styles in the same way as an object on a layer: with a style VectorCraft reads, it comes in as itself with its effects live, not as the look it draws. An object in a pattern's art with a style in the file's style list keeps that graphic style's id. An art styles section inside a symbol's, a pattern's or a brush's art is skipped. +An object in a pattern's art or in a brush's art takes the file's art styles in the same way as an object on a layer: with a style VectorCraft reads, it comes in as itself with its effects live, not as the look it draws. An object in a pattern's art with a style in the file's style list keeps that graphic style's id, as does an object in an opacity mask's art. An art styles section inside a symbol's, a pattern's or a brush's art is skipped. Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets) 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. The text values of data sets are read in the same encoding. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cad5851e98..d55e45e52c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1740,6 +1740,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 envelopes and images with a Warp in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`). + Opacity masks in a symbol's art and in a pattern's tile are written the same way, so a symbol or pattern whose mask art has Offset Path opens with the effect live in its definition, and its instances and pattern fills draw as they did; each definition's mask art keeps its own style, and mask art linked to a graphic style opens still linked to it (`a_symbols_mask_art_keeps_live_offset_path`, `a_pattern_tiles_mask_art_keeps_live_offset_path`, `mask_art_styles_stay_with_their_own_art` and `opacity_mask_art_keeps_its_graphic_style_link` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps the document's variables, data sets and the bindings of objects to variables in its editing copy, in the form the variable library reader above reads, so they come back when the file is opened. Bindings to layers, to type written as outlines, to graphs and to linked images written as other objects, data set values of graphs, and variables, data sets, bindings and values past the writer's limits are left out with a warning (#6024; `crates/eps/src/ai_editing/tests.rs` `variables_data_sets_and_bindings_are_read_back`; `crates/eps/src/ai_editing/writer/variables.rs` lists what is left out). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps each wrap object's Text Wrap Options in its editing copy: the Offset, positive or negative, and Invert Wrap ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/wrap-or-unwrap-text-around-objects.html)), in the form the `.ai` reader reads them (`text_wrap` in `crates/eps/src/import/ai/read/document.rs`), so they come back when the file is opened. An offset past the -1000 to 1000 pt the reader keeps is written as the nearer end of that range. On a large canvas the offset is written at the art's scale and comes back at its size. Art the writer draws for an object, such as a blend's steps or a symbol instance written as its art, has only that object's options, and a clipping path written as its fills and its strokes has its options once (`crates/eps/src/ai_editing/tests.rs` `text_wrap_options_are_written_and_read_back`, `drawn_art_keeps_only_its_objects_text_wrap`, `text_wrap_offsets_on_a_large_canvas_come_back_at_their_size`, `text_wrap_options_of_type_and_envelopes_are_read_back`, `a_clipping_path_written_as_its_paint_keeps_one_text_wrap`). From 201188c3a85b830bf81980ca36f36ff9417dcce5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 18:06:57 -0800 Subject: [PATCH 06/24] Print: a spot or Registration tint that is not a finite number prints no ink Separations Preview and printed separations clamped a linked spot or Registration tint to 0..1, but a NaN tint passed through the clamp and reached the plate's coverage as NaN. A native file can store an infinite tint (a number such as 1e39 read into an f32), and a blend between two such paints yields NaN. A tint that is not a finite number now prints no ink, as a gradient stop's process plate amounts already did; finite tints are still clamped, and a stop's process plates still print beside it. --- crates/doc/src/inks.rs | 38 +++++++++++++++++++++++++++++++---- crates/pdf/src/tests/print.rs | 11 ++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/inks.rs b/crates/doc/src/inks.rs index a9fa5f3f80..ed920fbd41 100644 --- a/crates/doc/src/inks.rs +++ b/crates/doc/src/inks.rs @@ -80,16 +80,22 @@ pub fn spot_color(doc: &Document, name: &str) -> Option { /// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it. pub type Link<'a> = Option<(&'a str, f32)>; +/// An ink amount clamped to 0..1; an amount that is not a finite number prints no ink. +fn ink_amount(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + /// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at -/// its tint; one linked to the Registration swatch prints its tint on every plate. +/// its tint; one linked to the Registration swatch prints its tint on every plate. A tint that is +/// not a finite number prints no ink. pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks { if let Some((REGISTRATION, tint)) = link { - return Inks { cmyk: [tint.clamp(0.0, 1.0); 4], spot: None, registration: true }; + return Inks { cmyk: [ink_amount(tint); 4], spot: None, registration: true }; } if let Some((name, tint)) = link && doc.swatch(name).is_some_and(|s| s.spot && s.paint.color().is_some()) { - return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))), registration: false }; + return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), ink_amount(tint))), registration: false }; } Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false } } @@ -104,7 +110,7 @@ pub type StopPlates = Option<[f32; 4]>; pub fn stop_inks(doc: &Document, c: &Cms, color: &Color, link: Link, plates: StopPlates, intent: Intent) -> Inks { let mut ink = inks(doc, c, color, link, intent); if let (Some(p), Some(_)) = (plates, &ink.spot) { - ink.cmyk = p.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }); + ink.cmyk = p.map(ink_amount); } ink } @@ -227,6 +233,30 @@ mod tests { } } + #[test] + fn a_spot_or_registration_tint_that_is_not_a_finite_number_prints_no_ink() { + let d = gold_doc(); + let c = vectorcraft_color::cms::active(); + let look = Color::cmyk(0.25, 0.1, 0.8, 0.05); + let ink = |link: Link| inks(&d, &c, &look, link, Intent::RelativeColorimetric); + for t in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let spot = ink(Some(("Gold", t))); + assert_eq!(spot, Inks { cmyk: [0.0; 4], spot: Some(("Gold".into(), 0.0)), registration: false }, "{t}"); + assert_eq!((spot.on_plate("Gold"), spot.on_plate("Cyan")), (0.0, 0.0), "{t}"); + let reg = ink(Some((REGISTRATION, t))); + assert_eq!(reg, Inks { cmyk: [0.0; 4], spot: None, registration: true }, "{t}"); + assert_eq!((reg.on_plate("Gold"), reg.on_plate("Black")), (0.0, 0.0), "{t}"); + // A stop's process plates still print beside a spot tint that prints nothing. + let stop = stop_inks(&d, &c, &look, Some(("Gold", t)), Some([0.25, 0.0, 0.4, 0.0]), Intent::RelativeColorimetric); + assert_eq!((stop.cmyk, stop.on_plate("Gold")), ([0.25, 0.0, 0.4, 0.0], 0.0), "{t}"); + } + // Finite tints outside 0..1 are clamped. + assert_eq!(ink(Some(("Gold", 2.0))).on_plate("Gold"), 1.0); + assert_eq!(ink(Some(("Gold", -1.0))).on_plate("Gold"), 0.0); + assert_eq!(ink(Some((REGISTRATION, 2.0))).cmyk, [1.0; 4]); + assert_eq!(ink(Some((REGISTRATION, -1.0))).cmyk, [0.0; 4]); + } + #[test] fn gradient_stops_pass_their_plates_and_freeform_points_are_visited() { let mut stop = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05)); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index ecd1f25ce1..7fa887a90f 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -662,6 +662,17 @@ fn a_spot_gradient_stop_prints_its_process_plates() { assert_eq!(first(json!({"output": {"mode": "separations", "spotsToProcess": true}})), [25.0, 10.0, 64.0, 5.0]); } +#[test] +fn a_spot_gradient_stop_whose_tint_is_not_a_number_prints_no_spot_ink() { + let mut mixed = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05)); + (mixed.swatch, mixed.tint, mixed.plates) = (Some("Gold".into()), f32::NAN, Some([0.25, 0.0, 0.4, 0.0])); + let g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![mixed, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))])); + let d = gold_gradient_doc(g); + let first = |v| gradient_plates(&d, v).into_iter().map(|(s, _)| s).collect::>(); + assert_eq!(first(json!({"output": {"mode": "separations"}})), [25.0, 0.0, 40.0, 0.0, 0.0], "Cyan, Magenta, Yellow, Black, Gold"); + assert_eq!(first(json!({"output": {"mode": "separations", "spotsToProcess": true}})), [25.0, 0.0, 40.0, 0.0]); +} + #[test] fn a_freeform_gradients_points_separate_into_plates() { let mut g = GradientPaint::new(Gradient::new(GradientKind::Freeform, vec![GradientStop::new(0.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))])); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cad5851e98..fcdfa36303 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1703,6 +1703,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. Print separations and Separations Preview print such a stop's process plates on the Cyan, Magenta, Yellow and Black plates beside its tint on the spot plate; with spots printed as process, each process plate prints the stop's plate layered with the spot tint's process equivalent. A freeform gradient's points separate into plates as its stops do, and converting the document's color mode converts them (`print::a_spot_gradient_stop_prints_its_process_plates`, `print::a_freeform_gradients_points_separate_into_plates`, `colorize::separations_preview_shows_a_spot_gradient_stops_process_plates`, `colormgmt::rgb_cmyk_converts_each_freeform_point`). + Print separations and Separations Preview clamp the tint of a fill, stroke or gradient stop linked to a spot swatch or to Registration to 0–1, and a tint that is not a finite number prints no ink, so the plate's coverage stays a number; a stop's process plates still print beside it. This is VectorCraft's own rule (`inks::tests::a_spot_or_registration_tint_that_is_not_a_finite_number_prints_no_ink`, `print::a_spot_gradient_stop_whose_tint_is_not_a_number_prints_no_spot_ink`). A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A CMYK document, or an export that converts colors to a CMYK destination profile, writes RGB and Lab stops in CMYK first (unless the export converts colors to an RGB destination profile), so there such a gradient, Classic or Perceptual, is written as a DeviceN shading that keeps the spot ink. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). PDF and EPS export name a spot ink in a gradient's Separation or DeviceN space, and EPS in `%%DocumentCustomColors`, only when a stop linked to it at a tint above 0, or with process plates, colors part of the gradient (VectorCraft's own rule): a stop out of order whose neighboring stretches have no width, and in PDF a stop whose stretches are fully transparent, adds no ink and no empty plate. EPS writes every stop opaque, so there a transparent spot stop keeps its ink (`crates/pdf/src/tests/output.rs`, `crates/eps/src/tests.rs`). From 154767077e49cb90cbeebc4cfda7c77b724ab8ec Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 18:43:12 -0800 Subject: [PATCH 07/24] Effects: keep ruler guides below guide-free sublayers and in groups out of effects A layer's Pathfinder or geometry effect now moves every ruler guide in its sublayers and groups out of the art it replaces or copies, including a guide in a layer inside a group inside a sublayer that holds no guide of its own, and a guide directly in a group. A group's Pathfinder or geometry effect does the same for the guides among its members. Each guide stays once, last in the container, hidden or locked as the containers it came from were. Every member is looked at once. --- crates/effects/src/group.rs | 71 +++++++----------- crates/effects/src/group/tests.rs | 120 ++++++++++++++++++++++++++++-- 2 files changed, 140 insertions(+), 51 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 1b64971a3a..0e37be9ded 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -742,45 +742,28 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], } /// The members of a container that [`take_held_guides`] visits, by index, each with the same for -/// its own members. A ruler guide's entry is empty; a container's is not. +/// its own members. A ruler guide's entry is empty, whether its container is a layer or a group; a +/// container's is not. #[derive(Default)] struct HeldGuides(Vec<(usize, HeldGuides)>); -/// The members of `n` holding or leading to the ruler guides of sublayers, found by looking at each -/// member at every depth once: the ruler guides (`is_guide`) of `n` when it is a layer, each -/// sublayer holding one directly or in its sublayers, and each member of another kind with such a -/// sublayer among its members or its groups' members. With them, whether `n` is a layer holding a -/// ruler guide directly or in its sublayers. The other members of a layer holding none are not -/// looked into, unless the layer is the one whose sublayers are gathered (`top`). -fn held_guides(n: &Node, top: bool, is_guide: &impl Fn(&Node) -> bool) -> (bool, HeldGuides) { - let layer = n.is_layer(); - let (mut holds, mut held, mut other) = (false, vec![], vec![]); +/// The members of `n` that are ruler guides (`is_guide`), and those holding one at any depth, in +/// order. Every member at every depth is looked at once: a sublayer or group with no guide of its +/// own is still looked into, and a guide directly in a group counts as one in a layer does. A +/// member holding no guide gets no entry. +fn held_guides(n: &Node, is_guide: &impl Fn(&Node) -> bool) -> HeldGuides { + let mut held = vec![]; for (i, c) in n.children().into_iter().flatten().enumerate() { if is_guide(c) { - if layer { - holds = true; - held.push((i, HeldGuides::default())); - } - } else if c.is_layer() { - let (sub_holds, inner) = held_guides(c, false, is_guide); - if sub_holds { - holds = true; - held.push((i, inner)); - } - } else { - other.push((i, c)); + held.push((i, HeldGuides::default())); + continue; + } + let inner = held_guides(c, is_guide); + if !inner.0.is_empty() { + held.push((i, inner)); } } - if !layer || holds || top { - for (i, c) in other { - let (_, inner) = held_guides(c, false, is_guide); - if !inner.0.is_empty() { - held.push((i, inner)); - } - } - held.sort_unstable_by_key(|(i, _)| *i); - } - (layer && holds, HeldGuides(held)) + HeldGuides(held) } /// Move the ruler guides of `n` and of its members that [`held_guides`] found (`held`) to `out`: @@ -818,15 +801,14 @@ fn take_held_guides(n: &mut Node, held: HeldGuides, out: &mut Vec>, hi out.append(&mut deeper); } -/// Move the ruler guides of the sublayers among the members of `n`, and among its groups' -/// members, to `out`, in order: a sublayer's own guides, then those of its members. A guide taken -/// from a hidden or locked sublayer, or from one inside such a sublayer or group, is hidden or -/// locked itself. Each member at every depth is looked at once at most (`is_guide`), and only -/// containers holding one are copied. +/// Move the ruler guides of `n` and those held at any depth by the sublayers and groups among its +/// members to `out`, in order: a container's own guides, then those of its members. A sublayer or +/// group with no guide of its own still gives up those of its members. A guide taken from a hidden +/// or locked sublayer or group, or from one inside such a container, is hidden or locked itself. +/// Each member at every depth is looked at once (`is_guide`), and only containers holding a guide +/// are copied. fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, is_guide: &impl Fn(&Node) -> bool) { - let (_, mut held) = held_guides(n, true, is_guide); - // The ruler guides of `n` itself stay where they are. - held.0.retain(|(_, inner)| !inner.0.is_empty()); + let held = held_guides(n, is_guide); take_held_guides(n, held, out, false, false); } @@ -850,11 +832,12 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { return None; } let mut m = n.clone(); - // A layer's ruler guides take no part: they go back above the evaluated art at the end. + // Its ruler guides take no part: they go back above the evaluated art at the end. let mut guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); - // So do its sublayers' guides, which would otherwise go with the art the effects replace or - // copy: they stay on this layer, hidden and locked as their sublayers were. - if m.is_layer() && (pathfinder || !fx.is_empty()) { + // So do its other guides and those in its sublayers and groups, which would otherwise go with + // the art the effects replace or copy: they stay on `n`, hidden and locked as their containers + // were. + if pathfinder || !fx.is_empty() { take_sublayer_guides(&mut m, &mut guides, &Node::is_ruler_guide); } if pathfinder { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 519323e5f0..b2b802cc44 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -1808,7 +1808,7 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked() } /// Gathering the ruler guides of nested sublayers, and of a sublayer inside nested groups, looks -/// at each member at every depth once at most, and takes the same guides, in the same order and +/// at each member at every depth once, and takes the same guides, in the same order and /// state, as a layer's effects keep. #[test] fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { @@ -1840,7 +1840,7 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { groups.locked = depth == 3; } let free = Arc::new(Node::group(NodeId(500), vec![sq(501, 0.0, Color::BLACK), sq(502, 5.0, Color::BLACK)])); - // A sublayer with no guide: the art in its group is not looked at. + // A sublayer with no guide, its group holding none either. let quiet = Node::group(NodeId(601), vec![sq(602, 0.0, Color::BLACK), sq(603, 5.0, Color::BLACK)]); let quiet = Arc::new(layer(600, vec![Arc::new(quiet)])); let mut root = layer(10, vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(chain), Arc::new(groups), free.clone(), quiet.clone(), guide(20)]); @@ -1858,15 +1858,16 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { }; let mut taken = vec![]; take_sublayer_guides(&mut m, &mut taken, &is_guide); - assert_eq!(looked.get(), members(&root) - 2, "each member is looked at once, none in the quiet sublayer's group"); - assert_eq!(state(&taken), expected, "a sublayer's own guides, then its sublayers', each once"); - assert!(Arc::ptr_eq(&taken[0], &first_guide), "a guide with nothing to change is not copied"); - assert!(!Arc::ptr_eq(&taken[1], &second_guide), "a guide made hidden is copied"); + assert_eq!(looked.get(), members(&root), "each member is looked at once, those of guide-free containers included"); + assert_eq!(taken[0].id, NodeId(20), "the layer's own guide comes first"); + assert_eq!(state(&taken[1..]), expected, "a sublayer's own guides, then its sublayers', each once"); + assert!(Arc::ptr_eq(&taken[1], &first_guide), "a guide with nothing to change is not copied"); + assert!(!Arc::ptr_eq(&taken[2], &second_guide), "a guide made hidden is copied"); assert!(second_guide.visible, "the guide as it was stays as it was"); let ch = m.children().unwrap(); + assert_eq!(ch.len(), 5, "the layer's own guide is taken"); assert!(Arc::ptr_eq(&ch[3], &free), "a member with no sublayer guide is not copied"); assert!(Arc::ptr_eq(&ch[4], &quiet), "a sublayer with no guide is not copied"); - assert!(ch[5].is_ruler_guide(), "the layer's own guide stays where it is"); let ids = |n: &Node| n.children().into_iter().flatten().map(|c| c.id).collect::>(); assert_eq!(ids(&ch[1]), [NodeId(102), NodeId(301)], "the sublayer's art stays, in order"); assert_eq!(ids(&root.children().unwrap()[1]), [NodeId(102), NodeId(301), NodeId(201)], "the source keeps its guides"); @@ -1892,6 +1893,111 @@ fn gathering_sublayer_ruler_guides_looks_at_each_member_once() { } } +/// Where each ruler guide in `n` is, at every depth, with its position, visibility and lock. +fn ruler_guides_in(n: &Node, out: &mut Vec<(NodeId, NodeKind, bool, bool)>) { + for c in n.children().into_iter().flatten() { + if c.is_ruler_guide() { + out.push((c.id, c.kind.clone(), c.visible, c.locked)); + } + ruler_guides_in(c, out); + } +} + +/// A ruler guide in a layer inside a group inside a sublayer that holds no guide of its own, at +/// any depth of that pattern, survives its layer's Pathfinder effect or Transform copies once, last +/// in the layer, hidden or locked when any container on its way was. +#[test] +fn a_ruler_guide_below_a_guide_free_sublayer_and_a_group_survives_its_layers_effects() { + use vectorcraft_doc::LayerColor; + let layer = |id, children: Vec>, (hidden, locked): (bool, bool)| { + let mut l = Node::layer(NodeId(id), "Sub", LayerColor::Preset(1)); + if let Some(ch) = l.children_mut() { + *ch = children; + } + (l.visible, l.locked) = (!hidden, locked); + Arc::new(l) + }; + let group = |id, children: Vec>, (hidden, locked): (bool, bool)| { + let mut g = Node::group(NodeId(id), children); + (g.visible, g.locked) = (!hidden, locked); + Arc::new(g) + }; + let kind = NodeKind::RulerGuide { vertical: false, pos: 7.0, artboard: None }; + let states = [(false, false), (true, false), (false, true)]; + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + for depth in 1..=3_u64 { + for sub in states { + for grp in states { + for inner in states { + // Layer 10 → sublayer → group → … → layer holding the guide, the pattern + // repeated `depth` times; only the innermost layer holds a guide. + let mut n = layer(900, vec![sq(901, 0.0, Color::BLACK), Arc::new(Node::new(NodeId(20), kind.clone()))], inner); + for d in (0..depth).rev() { + let (s, g) = if d == 0 { (sub, grp) } else { ((false, false), (false, false)) }; + n = group(700 + d, vec![sq(710 + d, 5.0, Color::BLACK), n], g); + n = layer(800 + d, vec![n, sq(810 + d, 2.0, Color::BLACK)], s); + } + let mut root = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = root.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), n]; + } + root.appearance.effects.push(Effect { id: effect.into(), params: params.clone(), visible: true }); + let art = evaluate_container(None, &root).unwrap(); + let (hidden, locked) = (sub.0 || grp.0 || inner.0, sub.1 || grp.1 || inner.1); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + let case = format!("{effect}, depth {depth}, sublayer {sub:?}, group {grp:?}, layer {inner:?}"); + assert_eq!(all, [(NodeId(20), kind.clone(), !hidden, locked)], "{case}: the guide, once, unchanged"); + let last = art.children().and_then(|c| c.last()).unwrap(); + assert_eq!(last.id, NodeId(20), "{case}: last in the layer"); + } + } + } + } + } +} + +/// A ruler guide directly in a group, in a layer or in a group with a Pathfinder effect or +/// Transform copies, survives that effect once, last in the container, hidden or locked as its +/// groups were. So does one among a group's art rather than after it. +#[test] +fn a_ruler_guide_in_a_group_survives_its_containers_effects() { + use vectorcraft_doc::LayerColor; + let kind = NodeKind::RulerGuide { vertical: true, pos: 4.0, artboard: None }; + let guide = || Arc::new(Node::new(NodeId(20), kind.clone())); + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + for (hidden, locked) in [(false, false), (true, false), (false, true)] { + let mut inner = Node::group(NodeId(12), vec![sq(2, 5.0, Color::BLACK), guide(), sq(3, 8.0, Color::BLACK)]); + (inner.visible, inner.locked) = (!hidden, locked); + let outer = Arc::new(Node::group(NodeId(11), vec![Arc::new(inner)])); + let fx = Effect { id: effect.into(), params: params.clone(), visible: true }; + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), outer.clone()]; + } + l.appearance.effects.push(fx.clone()); + let mut g = Node::group(NodeId(13), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), outer]); + g.appearance.effects.push(fx.clone()); + for (what, n) in [("layer", l), ("group", g)] { + let art = evaluate_container(None, &n).unwrap(); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + let case = format!("{effect} on a {what}, hidden {hidden}, locked {locked}"); + assert_eq!(all, [(NodeId(20), kind.clone(), !hidden, locked)], "{case}: the guide, once, unchanged"); + assert_eq!(art.children().and_then(|c| c.last()).map(|c| c.id), Some(NodeId(20)), "{case}: last"); + } + } + // A group's guide placed before its art. + let mut g = Node::group(NodeId(13), vec![guide(), sq(1, 0.0, Color::BLACK), sq(2, 5.0, Color::BLACK)]); + g.appearance.effects.push(Effect { id: effect.into(), params: params.clone(), visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + assert_eq!(all, [(NodeId(20), kind.clone(), true, false)], "{effect}: a group's leading guide, once"); + assert_eq!(art.children().and_then(|c| c.last()).map(|c| c.id), Some(NodeId(20)), "{effect}: moved last"); + } +} + /// A group's own Fill + Offset Path below its contents follows the outline of a clip group among /// its members, its clipping path, not the clipped art it hides outside that path (#7289). #[test] From 05527ee0f13e70fc57360e96b2b1e2bc659a8dee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 18:49:20 -0800 Subject: [PATCH 08/24] Artwork Cleanup: path commands take locked and hidden art named by ids Simplify Anchors, Deep Ungroup and Rejoin Path Pieces now follow the rule the text tools use: with `ids`, they act on the locked and hidden paths and groups the ids name, and on those inside the groups and layers they name, but not on art on a locked artboard. From the selection they still take only art that can be edited. - Rejoin Path Pieces joins only pieces alike in being hidden and in being locked (counting the groups and layers above them), so a joined path does not show hidden art or unlock locked art. Locked and hidden paths it makes are not selected. A compound path member named directly stays out, as members reached through the compound do. - Deep Ungroup takes apart named locked and hidden groups, and the groups inside a named group or layer; a named layer stays. Objects of a hidden group become hidden, and objects of a locked group become locked. - Simplify Anchors reports every named path it took in `ids`. --- crates/engine/src/cmd/addons/cleanup.rs | 155 +++++++--- crates/engine/src/cmd/addons/cleanup/tests.rs | 280 +++++++++++++++++- docs/mcp.md | 17 +- docs/target-app-parity.md | 7 + 4 files changed, 400 insertions(+), 59 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index cba512cd18..d5f241b4ee 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -9,7 +9,7 @@ mod tests; mod text; use serde_json::{Map, Value, json}; -use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Document, LockedArtboards, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, SubPath}; use vectorcraft_pathops::addons as po; @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Simplify Anchors (Keep Shape)…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay, and locked and hidden paths are left out → {ids, before, after} anchor counts", + "{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step when a path changes; open ends stay; from the selection, locked and hidden paths are left out, and named by `ids`, the paths of the objects, groups and layers it names are taken even when locked or hidden, but not those on a locked artboard; a changed path loses its selected anchors and segments → {ids, before, after} anchor counts", has_selection, simplify ), @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Deep Ungroup…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in; locked and hidden groups stay whole, and locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}", + "{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step when a group goes; each object keeps the opacity of the groups it was in; from the selection, locked and hidden groups stay whole; named by `ids`, the groups it names and the groups in the groups and layers it names are ungrouped even when locked or hidden (a named layer stays), but not those on a locked artboard, and their objects become hidden or locked when a group was; locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}", has_selection, deep_ungroup ), @@ -82,7 +82,7 @@ pub fn specs() -> Vec { "Rejoin Path Pieces…", ["Object", "Plug-ins", "Artwork Cleanup"], None, - "{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step → {ids, joined (paths made), removed (pieces merged into them)}", + "{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step when a path is made; from the selection, locked and hidden paths are left out, and named by `ids`, they are taken, but not those on a locked artboard; only pieces alike in being hidden (by themselves or a group or layer above them) and in being locked join; locked and hidden paths made are not selected → {ids, joined (paths made), removed (pieces merged into them)}", has_selection, rejoin ), @@ -91,9 +91,28 @@ pub fn specs() -> Vec { v } -/// The paths (not guides) under `roots`, compound path members included; locked and hidden -/// paths, and those in locked or hidden groups, stay out. -fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { +/// Whether a request names its objects with `ids` (present and not null) rather than taking the +/// selection. +fn named(p: &Value) -> bool { + p.get("ids").is_some_and(|v| !v.is_null()) +} + +/// Keep the items of `items` whose object (`id`) a command acts on: when `named`, every one not on +/// a locked artboard, locked and hidden ones (and ones in a locked or hidden group or layer) +/// included; else only those that can be edited. +fn retain_targets(d: &Document, items: &mut Vec, named: bool, id: impl Fn(&T) -> NodeId) { + if named { + let locks = d.locked_artboards(); + items.retain(|t| !locks.holds_id(id(t))); + } else { + let editable = d.editable(); + items.retain(|t| editable(id(t))); + } +} + +/// The paths (not guides) under `roots`, compound path members included, that a command acts on +/// ([`retain_targets`]). +fn paths_under(d: &Document, roots: &[NodeId], named: bool) -> Vec { let mut out = vec![]; for n in roots.iter().filter_map(|id| d.node(*id)) { n.walk(&mut |c| { @@ -102,8 +121,7 @@ fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { } }); } - let editable = d.editable(); - out.retain(|id| editable(*id)); + retain_targets(d, &mut out, named, |id| *id); out } @@ -116,7 +134,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { let protect = switch_or(p, "protectCorners", false, C)?; let roots = checked_ids_or_selection(s, p, C)?; let d = &s.doc()?.doc; - let ids = paths_under(d, &roots); + let ids = paths_under(d, &roots, named(p)); let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect(); let before: usize = paths.iter().map(|(_, p)| p.anchor_count()).sum(); if before > MAX_ANCHORS { @@ -258,10 +276,11 @@ fn ungroupable(n: &Node, clips: bool) -> bool { } /// `n` with every level of grouping Deep Ungroup takes apart removed, into `out`; `count` counts -/// the groups. Members take on the opacity of the groups they were in, and a clipping path -/// becomes a plain path. A locked or hidden group stays whole. -fn flatten(n: Node, clips: bool, out: &mut Vec, count: &mut usize) { - if !ungroupable(&n, clips) || n.locked || !n.visible { +/// the groups. Members take on the opacity of the groups they were in, are hidden when one of them +/// was hidden and locked when one of them was locked, and a clipping path becomes a plain path. A +/// locked or hidden group stays whole unless `named`. +fn flatten(n: Node, clips: bool, named: bool, out: &mut Vec, count: &mut usize) { + if !ungroupable(&n, clips) || (!named && (n.locked || !n.visible)) { out.push(n); return; } @@ -276,26 +295,57 @@ fn flatten(n: Node, clips: bool, out: &mut Vec, count: &mut usize) { *clipping = false; } c.opacity *= n.opacity; - flatten(c, clips, out, count); + c.visible &= n.visible; + c.locked |= n.locked; + flatten(c, clips, named, out, count); } } +/// The outermost groups Deep Ungroup takes apart in layer `n`, its sublayers included, in paint +/// order, leaving out those on a locked artboard. +fn groups_in_layer(d: &Document, n: &Node, clips: bool, out: &mut Vec) { + let locks = d.locked_artboards(); + fn visit(n: &Node, clips: bool, locks: &LockedArtboards, out: &mut Vec) { + for c in n.children().into_iter().flatten() { + if c.is_layer() { + visit(c, clips, locks, out); + } else if ungroupable(c, clips) && !locks.holds_id(c.id) { + out.push(c.id); + } + } + } + visit(n, clips, &locks, out); +} + fn deep_ungroup(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.cleanup.deepUngroup"; let clips = switch_or(p, "includeClipGroups", false, C)?; - let roots = checked_ids_or_selection(s, p, C)?; + let named = named(p); + let mut roots = checked_ids_or_selection(s, p, C)?; let d = &s.doc()?.doc; - // Locked and hidden objects, and those in locked or hidden groups, stay as they are. - let roots: Vec = { - let editable = d.editable(); - roots.into_iter().filter(|id| editable(*id)).collect() - }; + // From the selection, locked and hidden objects, and those in locked or hidden groups, stay as + // they are; named, only those on a locked artboard do. + retain_targets(d, &mut roots, named, |id| *id); + // A named layer stays: the groups in it, and in its sublayers, are taken apart. + let mut groups = vec![]; + for id in roots { + match d.node(id) { + Some(n) if named && n.is_layer() => { + let at = groups.len(); + groups_in_layer(d, n, clips, &mut groups); + if groups.len() == at { + groups.push(id); + } + } + _ => groups.push(id), + } + } let (mut items, mut selected, mut count) = (vec![], vec![], 0); - for id in &roots { + for id in &groups { match d.node(*id).filter(|n| ungroupable(n, clips)) { Some(n) => { let mut out = vec![]; - flatten(n.clone(), clips, &mut out, &mut count); + flatten(n.clone(), clips, named, &mut out, &mut count); selected.extend(out.iter().map(|n| n.id)); items.push((*id, out)); } @@ -304,7 +354,7 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result { } // With no group to take apart, the document, the selection and the undo history stay as they // are, and the reply lists the selection. - if items.is_empty() { + if count == 0 { selected = s.doc()?.selection.objects.to_vec(); } else { selected = s.edit("Deep Ungroup", move |d, sel| { @@ -319,26 +369,39 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::>(), "ungrouped": count })) } +/// One path Rejoin Path Pieces takes: its id, its subpath, and whether it and every group and layer +/// above it are visible, and whether they are all unlocked. +type Piece = (NodeId, SubPath, (bool, bool)); + /// The open single-subpath paths under `roots` that Rejoin Path Pieces takes, in paint order -/// (compound path members, clipping paths and guides stay out). -fn pieces(d: &Document, roots: &[NodeId]) -> Vec<(NodeId, SubPath)> { - fn visit(n: &Node, out: &mut Vec<(NodeId, SubPath)>) { +/// (compound path members, clipping paths and guides stay out; [`retain_targets`]). +fn pieces(d: &Document, roots: &[NodeId], named: bool) -> Vec { + fn visit(n: &Node, above: (bool, bool), out: &mut Vec) { + let state = (above.0 && n.visible, above.1 && !n.locked); match &n.kind { NodeKind::Path { path, guide: false, clipping: false, .. } => { if let [sp] = path.subpaths.as_slice() && !sp.closed { - out.push((n.id, sp.clone())); + out.push((n.id, sp.clone(), state)); } } - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, state, out)), _ => {} } } let mut out = vec![]; - roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out)); - let editable = d.editable(); - out.retain(|(id, _)| editable(*id)); + for (id, n) in roots.iter().filter_map(|id| Some((*id, d.node(*id)?))) { + // The groups and layers above the root, and their state. + let above = d.ancestry(id).unwrap_or_default(); + let above = above.split_last().map_or(&[][..], |(_, up)| up); + if above.last().and_then(|i| d.node(*i)).is_some_and(|parent| matches!(parent.kind, NodeKind::Compound { .. })) { + continue; + } + let state = above.iter().filter_map(|i| d.node(*i)).fold((true, true), |(v, u), a| (v && a.visible, u && !a.locked)); + visit(n, state, &mut out); + } + retain_targets(d, &mut out, named, |piece| piece.0); out } @@ -349,18 +412,26 @@ fn rejoin(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "`gap` must be from 0 to 6 pt")); } let roots = checked_ids_or_selection(s, p, C)?; - let found = pieces(&s.doc()?.doc, &roots); + let found = pieces(&s.doc()?.doc, &roots, named(p)); if found.len() > MAX_OBJECTS { return Err(EngineError::Other("Rejoin Path Pieces: too many paths to rejoin at once".into())); } - let subpaths: Vec = found.iter().map(|(_, sp)| sp.clone()).collect(); - // Each chain becomes its topmost piece (the last in paint order); the others go. + // Only pieces alike in being shown and in being unlocked join, so that a joined path neither + // shows hidden art nor hides shown art, nor locks or unlocks a piece. let (mut kept, mut gone) = (vec![], vec![]); - for j in po::rejoin(&subpaths, gap) { - let ids: Vec = j.members.iter().filter_map(|&m| found.get(m).map(|f| f.0)).collect(); - let Some(&top) = j.members.iter().max().and_then(|&m| found.get(m).map(|f| &f.0)) else { continue }; - gone.extend(ids.into_iter().filter(|id| *id != top)); - kept.push((top, j.path)); + for state in [(true, true), (true, false), (false, true), (false, false)] { + let part: Vec<&Piece> = found.iter().filter(|f| f.2 == state).collect(); + if part.is_empty() { + continue; + } + let subpaths: Vec = part.iter().map(|f| f.1.clone()).collect(); + // Each chain becomes its topmost piece (the last in paint order); the others go. + for j in po::rejoin(&subpaths, gap) { + let ids: Vec = j.members.iter().filter_map(|&m| part.get(m).map(|f| f.0)).collect(); + let Some(top) = j.members.iter().max().and_then(|&m| part.get(m).map(|f| f.0)) else { continue }; + gone.extend(ids.into_iter().filter(|id| *id != top)); + kept.push((top, j.path)); + } } if !kept.is_empty() { let ids: Vec = kept.iter().map(|k| k.0).collect(); @@ -369,7 +440,9 @@ fn rejoin(s: &mut Session, p: &Value) -> Result { set_path(d, *id, PathData::single(sp.clone())); } d.remove_many(&gone)?; - sel.set(ids); + // Locked and hidden paths, and those in locked or hidden groups, are not selected. + let editable = d.editable(); + sel.set(ids.into_iter().filter(|id| editable(*id))); Ok(()) })?; } diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 4d4ed6461b..e1a50b928b 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Appearance, LayerColor, Node, NodeId, NodeKind}; use vectorcraft_geom::{FillRule, Rect}; use crate::Session; @@ -123,18 +123,100 @@ fn simplify_leaves_locked_and_hidden_paths_alone() { assert_eq!(r["before"], 16); assert!(anchors(&s, ids[0]) < 16); assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16)); - // Named by `ids`, or in a locked group, a path stays too, with no undo step. - let steps = undo_steps(&s); - s.execute("addon.cleanup.simplify", &json!({"ids": [ids[1].0, ids[2].0]})).unwrap(); - assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16)); - assert_eq!(undo_steps(&s), steps); + // In a selected locked group, a path stays too, with no undo step. change(&mut s, ids[1], |n| n.locked = false); change(&mut s, g, |n| n.locked = true); let steps = undo_steps(&s); - let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap(); + let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap(); assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(0), Some(0))); assert_eq!(anchors(&s, ids[1]), 16); assert_eq!(undo_steps(&s), steps); + // Named by `ids`, the locked group's paths, its hidden one included, are simplified. + let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap(); + assert_eq!(r["ids"], json!([ids[0].0, ids[1].0, ids[2].0])); + assert!(anchors(&s, ids[1]) < 16 && anchors(&s, ids[2]) < 16); + assert_eq!(undo_steps(&s), steps + 1); +} + +/// A path of 16 anchors at (`x`, `y`): an ellipse with anchors added twice. +fn ellipse16(s: &mut Session, x: f64, y: f64) -> NodeId { + let e = id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 50, "height": 50})).unwrap()); + select(s, &[e]); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + e +} + +/// Simplify Anchors takes the locked and hidden paths `ids` names, those in the locked or hidden +/// groups and layers it names (their locked and hidden members too) and a hidden compound path's +/// member, as one undo step, without selecting them; from the selection it leaves them alone, and a +/// path on a locked artboard stays even when named. +#[test] +fn simplify_named_locked_and_hidden_paths() { + let mut s = session(); + let locked = ellipse16(&mut s, 10.0, 10.0); + let hidden = ellipse16(&mut s, 70.0, 10.0); + change(&mut s, locked, |n| n.locked = true); + change(&mut s, hidden, |n| n.visible = false); + // A locked group with a hidden member, and a hidden group with a locked member. + let in_locked = ellipse16(&mut s, 130.0, 10.0); + let locked_group = wrap(&mut s, in_locked, |c| NodeKind::Group { children: c, clip: false }); + change(&mut s, locked_group, |n| n.locked = true); + change(&mut s, in_locked, |n| n.visible = false); + let in_hidden = ellipse16(&mut s, 190.0, 10.0); + let hidden_group = wrap(&mut s, in_hidden, |c| NodeKind::Group { children: c, clip: false }); + change(&mut s, hidden_group, |n| n.visible = false); + change(&mut s, in_hidden, |n| n.locked = true); + // A member of a hidden compound path. + let member = ellipse16(&mut s, 250.0, 10.0); + let compound = wrap(&mut s, member, |c| NodeKind::Compound { children: c, rule: FillRule::NonZero }); + change(&mut s, compound, |n| n.visible = false); + // A path in a locked layer, named itself. + let layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let in_layer = ellipse16(&mut s, 10.0, 100.0); + let on_board = ellipse16(&mut s, 10.0, 200.0); + change(&mut s, layer, |n| n.locked = true); + let all = [locked, hidden, in_locked, in_hidden, member, in_layer]; + select(&mut s, &[locked, hidden, locked_group, hidden_group, compound]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([]), "from the selection, none is taken"); + assert_eq!(undo_steps(&s), steps); + assert!(all.iter().all(|id| anchors(&s, *id) == 16)); + select(&mut s, &[]); + s.edit("Select Anchors", |_, sel| { + sel.anchors.insert(locked, [(0, 1)].into()); + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({"ids": [locked.0, hidden.0, locked_group.0, hidden_group.0, member.0, in_layer.0]})).unwrap(); + assert_eq!(r["ids"], json!(all.iter().map(|i| i.0).collect::>())); + assert_eq!(r["before"], 16 * 6); + assert_eq!(r["after"].as_u64(), Some(all.iter().map(|id| anchors(&s, *id) as u64).sum::())); + assert!(all.iter().all(|id| anchors(&s, *id) < 16), "{r}"); + assert_eq!(undo_steps(&s), steps + 1); + let sel = &s.doc().unwrap().selection; + assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "nothing is selected, and the changed path's anchors are not"); + let n = node(&s, in_hidden).unwrap(); + assert!(n.locked && n.visible && !node(&s, hidden_group).unwrap().visible); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(all.iter().all(|id| anchors(&s, *id) == 16)); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(all.iter().all(|id| anchors(&s, *id) < 16)); + // Nothing left to remove: no undo step. + let steps = undo_steps(&s); + s.execute("addon.cleanup.simplify", &json!({"ids": [locked.0]})).unwrap(); + assert_eq!(undo_steps(&s), steps); + // On a locked artboard, a named path stays as it is. + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({"ids": [on_board.0]})).unwrap(); + assert_eq!((r["ids"].clone(), anchors(&s, on_board), undo_steps(&s)), (json!([]), 16, steps)); } /// Each kind: what makes the issue in a fresh document (beside a clean rectangle). @@ -610,14 +692,15 @@ fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() { assert!(!d.node(hidden).unwrap().visible && d.node(b).unwrap().visible); assert!(d.node(locked).unwrap().locked && d.node(e).unwrap().locked); assert_eq!(s.doc().unwrap().selection.objects, [a]); - // Named by `ids`, a locked group stays as it is, with no undo step. + // Named by `ids`, a locked group is taken apart as one undo step; its member becomes locked + // and is not selected. let steps = undo_steps(&s); let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0]})).unwrap(); - assert_eq!(r["ungrouped"], 0); - assert!(node(&s, locked).is_some()); - assert_eq!(undo_steps(&s), steps); + assert_eq!(r["ungrouped"], 1); + assert!(node(&s, locked).is_none() && node(&s, c).unwrap().locked); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(r["ids"], json!([])); // With nothing ungrouped, the selection stays and the reply lists it. - assert_eq!(r["ids"], json!([a.0])); let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0); select(&mut s, &[a]); let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [f.0]})).unwrap(); @@ -625,6 +708,108 @@ fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() { assert_eq!(s.doc().unwrap().selection.objects, [a]); } +/// Put `ids` inside a new container that `kind` makes of its members. +fn wrap_all(s: &mut Session, ids: &[NodeId], kind: fn(Vec>) -> NodeKind) -> NodeId { + s.edit("t", |d, _| { + let w = d.alloc_id(); + Ok(d.wrap(ids, |c| Node::new(w, kind(c)))?) + }) + .unwrap() +} + +fn plain_group(c: Vec>) -> NodeKind { + NodeKind::Group { children: c, clip: false } +} + +fn sublayer(c: Vec>) -> NodeKind { + NodeKind::Layer { color: LayerColor::Preset(0), template: false, printable: true, children: c, clip: false, preview: true, dim_images: None } +} + +/// Named by `ids`, Deep Ungroup takes apart locked and hidden groups, those nested in a group it +/// names and those in a layer it names (its sublayers included, the layer staying), as one undo +/// step; their members become locked or hidden as the groups were, keep the groups' opacity and are +/// not selected. From the selection those groups stay whole, as do a clipping group and a group on a +/// locked artboard even when named. +#[test] +fn deep_ungroup_named_locked_and_hidden_groups() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let locked = wrap_all(&mut s, &[a], plain_group); + let hidden = wrap_all(&mut s, &[b], plain_group); + change(&mut s, locked, |n| n.locked = true); + change(&mut s, hidden, |n| { + n.visible = false; + n.opacity = 0.5; + }); + select(&mut s, &[locked, hidden]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap(); + assert_eq!((r["ungrouped"].clone(), undo_steps(&s)), (json!(0), steps), "from the selection, they stay whole"); + assert!(node(&s, locked).is_some() && node(&s, hidden).is_some()); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0, hidden.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), r["ids"].clone()), (json!(2), json!([]))); + assert_eq!(undo_steps(&s), steps + 1); + assert!(node(&s, locked).is_none() && node(&s, hidden).is_none()); + let (na, nb) = (node(&s, a).unwrap(), node(&s, b).unwrap()); + assert!(na.locked && na.visible && !nb.visible && !nb.locked); + assert_eq!(nb.opacity, 0.5); + assert!(s.doc().unwrap().selection.objects.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, locked).is_some() && !node(&s, a).unwrap().locked && node(&s, b).unwrap().visible); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(node(&s, locked).is_none() && node(&s, a).unwrap().locked); + // Locked and hidden groups nested in a named group that can be edited. + let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let e = rect(&mut s, 100.0, 10.0, 20.0, 20.0); + let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0); + let inner_locked = wrap_all(&mut s, &[e], plain_group); + let inner_hidden = wrap_all(&mut s, &[f], plain_group); + change(&mut s, inner_locked, |n| n.locked = true); + change(&mut s, inner_hidden, |n| n.visible = false); + let outer = wrap_all(&mut s, &[c, inner_locked, inner_hidden], plain_group); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [outer.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), r["ids"].clone()), (json!(3), json!([c.0]))); + assert!(node(&s, e).unwrap().locked && !node(&s, f).unwrap().visible); + assert_eq!(s.doc().unwrap().selection.objects, [c]); + // In a named layer and its sublayer; the layers and a clipping group stay. + let layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let g = rect(&mut s, 10.0, 100.0, 20.0, 20.0); + let h = rect(&mut s, 40.0, 100.0, 20.0, 20.0); + let layer_locked = wrap_all(&mut s, &[g], plain_group); + change(&mut s, layer_locked, |n| n.locked = true); + let layer_hidden = wrap_all(&mut s, &[h], plain_group); + change(&mut s, layer_hidden, |n| n.visible = false); + let sub = wrap_all(&mut s, &[layer_hidden], sublayer); + let clipped = rect(&mut s, 100.0, 100.0, 10.0, 10.0); + let clip = rect(&mut s, 95.0, 95.0, 30.0, 30.0); + select(&mut s, &[clipped, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let clip_group = s.doc().unwrap().selection.objects[0]; + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [layer.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), undo_steps(&s)), (json!(2), steps + 1)); + assert!(node(&s, layer_locked).is_none() && node(&s, layer_hidden).is_none()); + assert!(node(&s, layer).is_some() && node(&s, clip_group).is_some()); + assert!(node(&s, g).unwrap().locked && !node(&s, h).unwrap().visible); + assert_eq!(s.doc().unwrap().doc.parent_of(h), Some(sub)); + // On a locked artboard, a named group stays whole, with the selection as it was. + let k = rect(&mut s, 10.0, 200.0, 20.0, 20.0); + let on_board = wrap_all(&mut s, &[k], plain_group); + let off_board = rect(&mut s, 500.0, 10.0, 20.0, 20.0); + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + select(&mut s, &[off_board]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [on_board.0]})).unwrap(); + assert_eq!((r["ungrouped"].clone(), r["ids"].clone(), undo_steps(&s)), (json!(0), json!([off_board.0]), steps)); + assert!(node(&s, on_board).is_some()); + assert_eq!(s.doc().unwrap().selection.objects, [off_board]); +} + #[test] fn rejoin_joins_pieces_within_the_gap_only() { let mut s = session(); @@ -646,3 +831,74 @@ fn rejoin_joins_pieces_within_the_gap_only() { assert!(s.execute("addon.cleanup.rejoin", &bad).is_err(), "{bad}"); } } + +/// Named by `ids`, Rejoin Path Pieces takes locked and hidden pieces (in a locked group or layer +/// too) and joins only pieces alike in being hidden and in being locked, whichever is topmost, as one +/// undo step; a locked or hidden path it makes is not selected. From the selection it takes only +/// pieces that can be edited, and compound path members and pieces on a locked artboard stay out. +#[test] +fn rejoin_named_locked_and_hidden_pieces() { + let mut s = session(); + let a = line(&mut s, &[(10.0, 10.0), (50.0, 10.0)]); + let b = line(&mut s, &[(50.05, 10.0), (90.0, 10.0)]); + let g = wrap_all(&mut s, &[a, b], plain_group); + change(&mut s, g, |n| n.locked = true); + select(&mut s, &[g]); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.rejoin", &json!({})).unwrap(); + assert_eq!((r["joined"].clone(), undo_steps(&s)), (json!(0), steps), "from the selection, none is taken"); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [g.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["removed"].clone(), r["ids"].clone()), (json!(1), json!(1), json!([b.0]))); + assert_eq!(undo_steps(&s), steps + 1); + assert!(node(&s, a).is_none() && anchors(&s, b) == 3); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the locked path is not selected"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, a).is_some() && anchors(&s, b) == 2); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(node(&s, a).is_none() && anchors(&s, b) == 3); + // A shown piece and two hidden ones, the hidden on top: only the hidden pair joins. + let c = line(&mut s, &[(10.0, 40.0), (50.0, 40.0)]); + let e = line(&mut s, &[(50.05, 40.0), (90.0, 40.0)]); + let h = line(&mut s, &[(90.05, 40.0), (130.0, 40.0)]); + change(&mut s, e, |n| n.visible = false); + change(&mut s, h, |n| n.visible = false); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [c.0, e.0, h.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["removed"].clone(), r["ids"].clone()), (json!(1), json!(1), json!([h.0]))); + assert!(node(&s, e).is_none() && anchors(&s, c) == 2 && anchors(&s, h) == 3); + assert!(!node(&s, h).unwrap().visible && node(&s, c).unwrap().visible); + assert!(s.doc().unwrap().selection.objects.is_empty(), "the hidden path is not selected"); + // An unlocked piece under a locked one, and a hidden piece under a shown one: none joins. + let k = line(&mut s, &[(10.0, 70.0), (50.0, 70.0)]); + let m = line(&mut s, &[(50.05, 70.0), (90.0, 70.0)]); + let n = line(&mut s, &[(10.0, 100.0), (50.0, 100.0)]); + let o = line(&mut s, &[(50.05, 100.0), (90.0, 100.0)]); + change(&mut s, m, |x| x.locked = true); + change(&mut s, n, |x| x.visible = false); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [k.0, m.0, n.0, o.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["removed"].clone(), undo_steps(&s)), (json!(0), json!(0), steps)); + assert!([k, m, n, o].iter().all(|id| anchors(&s, *id) == 2)); + // Named pieces in a locked layer join; members of a compound path stay out. + let layer = id_of(&s.execute("layer.new", &json!({})).unwrap()); + let p = line(&mut s, &[(10.0, 130.0), (50.0, 130.0)]); + let q = line(&mut s, &[(50.05, 130.0), (90.0, 130.0)]); + let u = line(&mut s, &[(10.0, 160.0), (50.0, 160.0)]); + let v = line(&mut s, &[(50.05, 160.0), (90.0, 160.0)]); + wrap_all(&mut s, &[u, v], |c| NodeKind::Compound { children: c, rule: FillRule::NonZero }); + let w = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)]); + let x = line(&mut s, &[(50.05, 200.0), (90.0, 200.0)]); + change(&mut s, layer, |l| l.locked = true); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [p.0, q.0, u.0, v.0]})).unwrap(); + assert_eq!((r["joined"].clone(), r["ids"].clone()), (json!(1), json!([q.0]))); + assert!(node(&s, p).is_none() && anchors(&s, u) == 2 && anchors(&s, v) == 2); + // On a locked artboard, named pieces stay as they are. + s.edit("Lock Artboard", |d, _| { + d.artboards[0].locked = true; + Ok(()) + }) + .unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [w.0, x.0]})).unwrap(); + assert_eq!((r["joined"].clone(), undo_steps(&s)), (json!(0), steps)); + assert!(node(&s, w).is_some() && node(&s, x).is_some()); +} diff --git a/docs/mcp.md b/docs/mcp.md index d4e03892e9..e1291c0554 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -11091,9 +11091,10 @@ Plug-ins › Artwork Cleanup and in its panel (`window.panel {panel: "artworkCle `addon.cleanup.simplify {tolerance?, protectCorners?, ids?}` removes the anchors each selected path can do without and refits the handles beside each one, so the outline keeps its shape. `tolerance` runs from 1 to 100 (20 by default); the outline stays within tolerance × 0.02 pt of where it was, measured against the original outline, so removals don't add -up. With `protectCorners`, anchors where the path turns by more than 30° stay. Open ends always stay. Locked and hidden -paths, and paths in locked or hidden groups, are left out. It reports the anchor counts `before` and `after`, as one -undo step. +up. With `protectCorners`, anchors where the path turns by more than 30° stay. Open ends always stay. From the +selection, locked and hidden paths, and paths in locked or hidden groups, are left out. With `ids`, it also takes the +locked and hidden paths of the objects, groups and layers the ids name, but not paths on a locked artboard. It reports +the paths it took as `ids` and their anchor counts `before` and `after`, as one undo step when a path changed. The health check looks for issues in the selection, or in the whole document while nothing is selected (`scope: "selection"|"document"` picks one). Locked and hidden objects, and the insides of live or generated art (blends, @@ -11127,13 +11128,17 @@ envelopes, graphs, symbols), are left out. `addon.cleanup.deepUngroup {includeClipGroups?, ids?}` takes apart every level of grouping in the selection. Clipping groups stay whole unless `includeClipGroups` is true; their clipping paths then become plain paths. Objects keep the -opacity of the groups they were in. Locked and hidden groups stay whole, and locked and hidden objects stay locked or -hidden and are left out of the selection. +opacity of the groups they were in. From the selection, locked and hidden groups stay whole. With `ids`, it also takes +apart the locked and hidden groups the ids name and the groups inside the groups and layers they name (a named layer +stays a layer), but not groups on a locked artboard; an object of a hidden group becomes hidden, and an object of a +locked group becomes locked. Locked and hidden objects stay locked or hidden and are left out of the selection. `addon.cleanup.rejoin {gap?, ids?}` welds the selected open paths whose ends lie within `gap` (0 to 6 pt, 0.1 by default) back into paths. Each end joins one other end: where several are in reach, the one the path turns least to go on into. Pieces may run either way, and a chain whose ends meet closes. Each new path takes the place and paint of its -topmost piece. +topmost piece. With `ids`, it also takes locked and hidden open paths, but not paths on a locked artboard. Only pieces +that are alike in being hidden and in being locked (by themselves or by a group or layer above them) join, and locked +and hidden paths it makes are not selected. ```json {"name":"run_command","arguments":{"command":"addon.cleanup.check","params":{"scope":"document"}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cad5851e98..28d03d4c99 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2182,6 +2182,13 @@ With `ids`, the text tools act on the type the ids name even when it, or its gro rule). Tests: `named_locked_and_hidden_type`, `remove_breaks_named_locked_and_hidden_type` and `remove_breaks_named_story_with_a_locked_frame` in `cmd::addons::cleanup::text::tests`. +With `ids`, Simplify Anchors, Deep Ungroup and Rejoin Path Pieces act the same way on the locked and hidden paths and +groups the ids name, and on those in the groups and layers they name, but not on art on a locked artboard; from the +selection they still take only art that can be edited (VectorCraft's own rule). Rejoin Path Pieces joins only pieces +alike in being hidden and in being locked, and the objects of a group Deep Ungroup takes apart keep its hidden or locked +state. Tests: `simplify_named_locked_and_hidden_paths`, `deep_ungroup_named_locked_and_hidden_groups` and +`rejoin_named_locked_and_hidden_pieces` in `cmd::addons::cleanup::tests`. + ## Path Precision Tools add-on (2026-10-30) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools, From a4df0015c0545e79946f93e9b381b0c17e90ab25 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 19:35:07 -0800 Subject: [PATCH 09/24] Selection: test that a symbol instance is hit on its art's stroke past its box A click on a stroke of a symbol's art where it paints past the instance's box selects the instance; past the stroke, and in Outline view, it does not. The progress line names the new test. --- crates/doc/src/hit/tests.rs | 21 +++++++++++++++++++++ docs/target-app-parity.md | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index ffb32d851d..1c817f2bf1 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1940,6 +1940,27 @@ fn a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art } } +/// A click on the stroke of a symbol's art where it paints past the instance's box selects the +/// instance; past the stroke, and in Outline view, it does not. +#[test] +fn a_symbol_instance_is_hit_on_the_stroke_of_its_art_past_its_box() { + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + // A 20 pt stroke reaches 10 pt past the 0..100 square. + let look = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 20.0); + let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), look); + let art = Node::group(d.alloc_id(), vec![Arc::new(square)]); + d.symbols.push(crate::Symbol { dynamic: false, name: "Stroked".into(), art: Arc::new(art) }); + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::IDENTITY; + d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Stroked".into(), xf })).unwrap(); + let at = |x: f64, opt: HitOptions| hit_test(&d, Point::new(x, 50.0), opt).map(|h| h.leaf); + assert_eq!(at(108.0, HitOptions::default()), Some(inst), "on the stroke past the box"); + assert_eq!(at(50.0, HitOptions::default()), Some(inst), "in the box"); + assert_eq!(at(116.0, HitOptions::default()), None, "past the stroke"); + assert_eq!(at(108.0, HitOptions { outline: true, ..HitOptions::default() }), None, "Outline view"); +} + /// In a dynamic symbol's art that placing through its 9-slice guides could make more than /// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the /// canvas draws nothing of it. At the art's natural size the same art is picked. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b3735636d..db2a0fcea8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -646,7 +646,7 @@ since 2026-10-01. - **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. -- **Symbol instances are selected where their art draws past their box:** a click or a marquee selects a symbol instance where a clipping path in its art, moved or reshaped by its live geometry effects, shows the art past the instance's box, as it selects a group of the same art; a click also selects it where a stroke in its art paints past the box ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects.html)). The rest is VectorCraft's own rule: inside the box the instance is selected as before; in Outline view and below a layer whose Preview is off only the box counts; hidden art and the area a raster effect paints are left out. This covers instances in groups, nested in other symbols' art, moved and scaled. Regression test: `a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art` in `crates/doc/src/hit/tests.rs`. +- **Symbol instances are selected where their art draws past their box:** a click or a marquee selects a symbol instance where a clipping path in its art, moved or reshaped by its live geometry effects, shows the art past the instance's box, as it selects a group of the same art; a click also selects it where a stroke in its art paints past the box ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects.html)). The rest is VectorCraft's own rule: inside the box the instance is selected as before; in Outline view and below a layer whose Preview is off only the box counts; hidden art and the area a raster effect paints are left out. This covers instances in groups, nested in other symbols' art, moved and scaled. Regression tests: `a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art` and `a_symbol_instance_is_hit_on_the_stroke_of_its_art_past_its_box` in `crates/doc/src/hit/tests.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit leaves out groups holding type or images:** Select › Start Global Edit on a group that holds type or an image, at any depth, refuses to start, and such a group is not taken as a match, as Global Edit does not take images or text ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test for a group holding type: `global_edit_needs_one_supported_object` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From a5d643b2530204da86e3ac62fe6259814930a9ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 20:05:39 -0800 Subject: [PATCH 10/24] TIFF: an opaque 16-bit CMYK TIFF's inks are rounded to 8 bits for display Each 16-bit ink sample of an opaque CMYK TIFF, chunky or planar, is reduced to the display's 8 bits as round(s * 255 / 65535), the rescaling the PNG specification gives (section 13.12), where it was cut to its high byte. An ink of 200 out of 65535 showed as none, and 0x8100 showed one level too dark. The alpha path already rounds this way. The stored image keeps its 16-bit samples. --- crates/doc/src/cmyk.rs | 6 +++--- crates/engine/src/cmd/fileio/tests.rs | 19 +++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 71c6642846..6670cd081e 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -525,8 +525,8 @@ fn planar(d: &mut tiff::decoder::Decoder) - } /// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color -/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file -/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to +/// type has), pixel by pixel and 8 bits each (16-bit samples rounded to the nearest), whether the +/// file stores each pixel's samples together or one plane per channel. `None` when they don't decode to /// that many samples, which includes images whose planes together need more than the decoder's /// limit (256 MiB of samples), and, before any decoding, files with more than /// [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel, files whose samples aren't unsigned integers (a @@ -535,7 +535,7 @@ fn planar(d: &mut tiff::decoder::Decoder) - pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { Some(match read_samples_at_depth(d, n, per)? { Samples::Eight(v) => v, - Samples::Sixteen(v) => v.iter().map(|s| (s >> 8) as u8).collect(), + Samples::Sixteen(v) => v.iter().map(|&s| u8::try_from((u32::from(s) + 128) / 257).unwrap_or(u8::MAX)).collect(), }) } diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index b66b22d1a8..bbf6883834 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -202,6 +202,25 @@ fn a_16_bit_cmyk_tiff_with_alpha_places_at_16_bits() { } } +/// An opaque 16-bit CMYK TIFF's inks are rounded to the nearest 8-bit level, chunky and planar, and +/// the stored image keeps its 16-bit samples. +#[test] +fn an_opaque_16_bit_cmyk_tiffs_inks_are_rounded_to_8_bits() { + let c = vec![0u16, 200, 0x80FF, 0x8100, 65535, 32896]; + let m = vec![511u16, 65535, 0, 128, 129, 0]; + let none = vec![0u16; 6]; + let channels = [c, m, none.clone(), none]; + let want: Vec = [[0, 2, 0, 0], [1, 255, 0, 0], [128, 0, 0, 0], [128, 0, 0, 0], [255, 1, 0, 0], [128, 0, 0, 0]].concat(); + let chunky_bytes = hand_tiff(5, 16, &[], false, &channels); + let chunky = raster_image(&chunky_bytes).unwrap(); + assert_eq!(chunky.blob.cmyk().map(|i| i.data), Some(want.clone()), "chunky: the inks"); + assert_eq!(*chunky.blob.bytes, chunky_bytes, "chunky: the file is stored as it is"); + let planar = raster_image(&hand_tiff(5, 16, &[], true, &channels)).unwrap(); + assert_eq!(planar.blob.cmyk().map(|i| i.data), Some(want), "planar: the inks"); + let stored = image::load_from_memory(&planar.blob.bytes).unwrap(); + assert_eq!(stored.color(), image::ColorType::Rgb16, "planar: the stored image keeps 16-bit samples"); +} + /// A CMYK or grayscale TIFF whose alpha follows another extra sample opens and places with that /// alpha, at 8 and 16 bits, chunky and planar; a CMYK one is not kept as inks. A CMYK one with two /// more samples than its ExtraSamples values count does too. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 90d82894db..9a9d389fbc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1833,6 +1833,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - A 16-bit CMYK or grayscale TIFF with an alpha channel is converted from its 16-bit samples, and only the resulting color and alpha are rounded to 8 bits; associated alpha is divided out at 16 bits, and a pixel whose associated alpha is zero has no color. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives extra samples the depth in BitsPerSample, and its Associated Alpha Handling section states the arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. Each sample was cut to its high byte first, so a faint ink or a low alpha could be lost (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). + - An opaque 16-bit CMYK TIFF, chunky or planar, shows each ink rounded to the nearest of 256 levels, `round(s × 255 / 65535)`, as the [PNG specification's sample depth rescaling](https://www.w3.org/TR/png-3/#13.12-sample-depth-rescaling) gives it; [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) sets 0 and 65535 as no ink and full ink, and using that rounding for TIFF inks is VectorCraft's own rule. Each ink was cut to its high byte before, so an ink of 200 showed as none. The stored image keeps its 16-bit samples (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels. A CMYK TIFF with two or more extra channels past its ExtraSamples values, or with no ExtraSamples tag, opens opaque when none of them is marked as alpha, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MiB, as long as its samples, the copy a planar file needs, the row the decoder reads each strip or tile into and the RGBA pixels together fit the decoding allowance; a TIFF whose SamplesPerPixel is more than 64 is not read, which is VectorCraft's own limit, and both checks run on the file's tags before any decoding. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. From b8d47806117ee7b2ca4fd32115d25fcfb9b0fe58 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 21:43:44 -0800 Subject: [PATCH 11/24] 3D (Classic): Preserve Spot Colors keeps spot inks in shaded faces Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) take a preserveSpotColors param and a Preserve Spot Colors checkbox on the Lighting tab. When it is on, a fill or stroke linked to a spot or global swatch stays linked on every face: the highlight lowers the swatch's tint, and a face that darkens gets black over it, multiplied and overprinting, so a spot color keeps its own plate in separations and PDF. The checkbox is unavailable while Shading Color is Custom, and the param does nothing then. With the option off, the faces draw as before. A value that is not true or false is refused. --- crates/effects/src/catalog.rs | 12 ++-- crates/effects/src/revolve/classic.rs | 49 ++++++++++++++- crates/effects/src/solids/classic.rs | 41 ++++++++++++- crates/effects/src/solids/tests.rs | 60 ++++++++++++++++++- crates/engine/src/tests/tints.rs | 28 +++++++++ crates/ui-egui/src/dialogs/three_d_classic.rs | 12 ++-- docs/effects-parity.md | 2 +- docs/mcp.md | 15 +++-- docs/three-d.md | 9 ++- 9 files changed, 205 insertions(+), 23 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 1c1ca96d9f..8a4f49ecab 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -210,22 +210,22 @@ pub fn effect_catalog() -> Vec { CLASSIC_EXTRUDE, "Extrude & Bevel (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.", - json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}), + "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, lighter where the highlight is, with black multiplied and overprinting where the faces darken, so a spot color keeps its plate; it does nothing while shadingColor is custom), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.", + json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false,"drawHiddenFaces":false}), ), g( CLASSIC_REVOLVE, "Revolve (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", - json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, lighter where the highlight is, with black multiplied and overprinting where the faces darken, so a spot color keeps its plate; it does nothing while shadingColor is custom)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", + json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false}), ), g( CLASSIC_ROTATE, "Rotate (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, with black multiplied and overprinting where the art darkens, so a spot color keeps its plate; it does nothing while shadingColor is custom)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false}), ), g( "convertToShape.rectangle", diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs index 1881381567..8dc013e9dc 100644 --- a/crates/effects/src/revolve/classic.rs +++ b/crates/effects/src/revolve/classic.rs @@ -23,7 +23,7 @@ const RANGES: [(&str, f64, f64, bool); 13] = [ ("blendSteps", 1.0, 256.0, true), ]; -const FLAGS: [&str; 2] = ["cap", "lightBehind"]; +const FLAGS: [&str; 3] = ["cap", "lightBehind", "preserveSpotColors"]; /// The surface Revolve (Classic)'s parameters `p` sweep: Revolve's angle, cap, offset, edge, /// rotation and perspective, built unshaded, since the classic surfaces light its faces. @@ -118,6 +118,52 @@ mod tests { } } + /// With Preserve Spot Colors, a fill linked to a spot swatch stays linked on every face: the + /// highlight lowers its tint, and the shadow is black multiplied and overprinting over it. + #[test] + fn preserve_spot_colors_keeps_the_swatch_on_every_face() { + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.2); + let spot = |params: Value| { + let mut n = profile(); + *n.appearance = Appearance::basic(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 0.5 }, Paint::None, 0.0); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, ¶ms).unwrap()); + crate::revolve_art(&n).unwrap() + }; + let art = spot(json!({"preserveSpotColors": true})); + let mut darkened = 0; + for face in faces(&art) { + let mut fills = face.appearance.items.iter().filter_map(|i| match i { + AppearanceItem::Fill(f) => Some(f), + AppearanceItem::Stroke(_) => None, + }); + match fills.next().map(|f| &f.paint) { + Some(Paint::Solid { color, swatch: Some(name), tint }) => { + assert_eq!(name, "Ink"); + assert!(*tint > 0.0 && *tint <= 0.5, "{tint}"); + assert_eq!(*color, ink.tinted(*tint / 0.5), "the color is the swatch at the face's tint"); + } + other => panic!("the face keeps its swatch: {other:?}"), + } + if let Some(shadow) = fills.next() { + assert!(matches!(shadow.paint.color(), Some(Color::Gray { .. })), "{:?}", shadow.paint); + assert_eq!((shadow.blend, shadow.overprint), (vectorcraft_doc::color::BlendMode::Multiply, true)); + darkened += 1; + } + } + assert!(darkened > 0, "faces turned from the light are darkened"); + // Off, and with a custom shading color, the faces are process mixes again. + for params in [json!({}), json!({"preserveSpotColors": true, "shadingColor": "custom", "shadingCustom": "#336699"})] { + let art = spot(params.clone()); + let linked = faces(&art) + .iter() + .filter(|f| { + f.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if matches!(f.paint, Paint::Solid { swatch: Some(_), .. }))) + }) + .count(); + assert!(linked < faces(&art).len(), "{params}: shaded faces lose the swatch"); + } + } + #[test] fn angle_offset_edge_and_cap_shape_the_surface() { let full = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).visual_bounds().unwrap(); @@ -207,6 +253,7 @@ mod tests { (json!({"rotationY": -181}), "`rotationY` must be between -180 and 180"), (json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number"), (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"preserveSpotColors": "on"}), "`preserveSpotColors` must be true or false"), (json!({"surface": "glossy"}), "`surface` must be one of"), (json!({"shadingColor": "white"}), "`shadingColor` must be one of"), (json!({"shadingCustom": "red"}), "`shadingCustom` must be a #rrggbb color"), diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 29c05406a2..f48b94bce9 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -102,7 +102,7 @@ const RANGES: [(&str, f64, f64, bool); 13] = [ ("blendSteps", 1.0, 256.0, true), ]; -const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"]; +const FLAGS: [&str; 5] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces", "preserveSpotColors"]; /// Check Extrude & Bevel (Classic)'s parameters `p` (its defaults filled in): every key is one of /// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and @@ -127,7 +127,7 @@ const ROTATE_RANGES: [(&str, f64, f64, bool); 9] = [ /// Check Rotate (Classic)'s parameters `p` (its defaults filled in) as [`validate`] checks Extrude & /// Bevel (Classic)'s, with `surface` No Shading or Diffuse only. pub fn validate_rotate(p: &Value) -> Result<(), String> { - validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind"])?; + validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind", "preserveSpotColors"])?; if p.get("surface").is_some_and(|v| !v.as_str().is_some_and(|s| ROTATE_SURFACES.contains(&s))) { return Err(format!("`surface` must be one of {}", ROTATE_SURFACES.join(", "))); } @@ -192,6 +192,35 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option) -> Color { Color::rgb(r, g, b) } +/// Whether Preserve Spot Colors applies with parameters `p`: `preserveSpotColors` is on and the +/// shading color is not Custom, which cannot keep a spot color. +fn preserves_spot_colors(p: &Value) -> bool { + flag(p, "preserveSpotColors", false) && text(p, "shadingColor", "black") != "custom" +} + +/// A face of `paint`, a color linked to a swatch, with Preserve Spot Colors: the swatch at a lower +/// tint for the highlight `spec`, its link kept so a spot color stays on its own plate, and, where +/// tone `tone` is below full light and the faces darken (`shade`), black over it at `1 - tone`, +/// multiplied and overprinting, so it prints on the black plate. `seam` is the width of the seam +/// stroke each layer draws around the face. +fn linked_face(paint: &Paint, tone: f64, spec: f64, shade: Option, seam: f64) -> Vec { + let Paint::Solid { color, swatch, tint } = paint else { return vec![] }; + let keep = (1.0 - spec).clamp(0.0, 1.0) as f32; + let fill = Paint::Solid { color: color.tinted(keep), swatch: swatch.clone(), tint: tint * keep }; + let mut items = vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))]; + if shade.is_some() && tone < 1.0 { + let black = Paint::solid(Color::gray((1.0 - tone).clamp(0.0, 1.0) as f32)); + let mut layer = FillLayer::new(black.clone()); + layer.blend = BlendMode::Multiply; + layer.overprint = true; + let mut edge = crate::lighting::seam_stroke(black, seam); + edge.blend = BlendMode::Multiply; + edge.overprint = true; + items.extend([AppearanceItem::Fill(layer), AppearanceItem::Stroke(edge)]); + } + items +} + /// The width of the stroke [`surface`] draws around `face` with parameters `p`: a wire edge on /// Wireframe, else the seam between faces. pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 { @@ -200,12 +229,14 @@ pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 { /// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child /// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by -/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by +/// its tone and highlight (with Preserve Spot Colors, a color linked to a swatch as [`linked_face`] +/// draws it). A gradient with transparent stops is shaded opaque and then masked by /// its own transparency, so the shading follows the gradient's coverage. pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { let shading = classic_shading(p); let lights = lights(p); let shade = shading_color(p); + let preserve = preserves_spot_colors(p); let wire = shading.surface == ClassicSurface::Wireframe; let (paint, mask) = if wire { (source.clone(), None) } else { crate::lighting::opaque_paint(source) }; let paint = &paint; @@ -229,6 +260,10 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), ] } + // Preserve Spot Colors: a color linked to a swatch stays a tint of that swatch. + Some(_) if preserve && matches!(paint, Paint::Solid { swatch: Some(_), .. }) && !paint.is_registration() => { + linked_face(paint, tone, spec, shade, seam) + } Some(c) => { let fill = Paint::solid(shaded(c, tone, spec, shade)); vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))] diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 945eb639aa..de1ec75de3 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_doc::{Document, NodeId, StrokeLayer}; +use vectorcraft_doc::{Document, FillLayer, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, Point, Shape}; use super::*; @@ -2015,6 +2015,62 @@ fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { } } +/// The fills of each face of `art`: the first is the face's paint, a second the shadow over it. +fn face_fills(art: &Node) -> Vec> { + faces(art) + .iter() + .map(|f| { + f.appearance + .items + .iter() + .filter_map(|i| match i { + AppearanceItem::Fill(f) => Some(f.clone()), + AppearanceItem::Stroke(_) => None, + }) + .collect() + }) + .collect() +} + +#[test] +fn classic_preserve_spot_colors_keeps_a_linked_fill_on_every_face() { + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.2); + let art = |params: Value| { + let mut n = classic(params); + n.appearance.items = vec![AppearanceItem::Fill(FillLayer::new(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 1.0 }))]; + solid_art(&n).unwrap() + }; + let lit = json!({"preserveSpotColors": true, "highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60}); + let (mut highlighted, mut shaded) = (false, false); + for layers in face_fills(&art(lit)) { + let Paint::Solid { color, swatch: Some(name), tint } = &layers[0].paint else { panic!("the swatch is kept: {layers:?}") }; + assert_eq!(name, "Ink"); + assert_eq!(*color, ink.tinted(*tint), "the color is the swatch at the face's tint"); + highlighted |= *tint < 1.0; + if let Some(shadow) = layers.get(1) { + let Some(Color::Gray { k }) = shadow.paint.color() else { panic!("black shading: {:?}", shadow.paint) }; + assert!(k > 0.0 && k <= 1.0, "{k}"); + assert_eq!((shadow.blend, shadow.overprint, shadow.opacity), (BlendMode::Multiply, true, 1.0)); + shaded = true; + } + assert!(layers.len() <= 2, "{layers:?}"); + } + assert!(highlighted && shaded, "the highlight lowers the tint and the shadow adds black"); + // No shading color: the highlight only, as tints of the swatch. + for layers in face_fills(&art(json!({"preserveSpotColors": true, "shadingColor": "none"}))) { + assert!(matches!(&layers[..], [f] if matches!(&f.paint, Paint::Solid { swatch: Some(_), .. })), "{layers:?}"); + } + // Off, or with a custom shading color, shaded faces are process mixes, as before. + for params in [json!({}), json!({"preserveSpotColors": true, "shadingColor": "custom", "shadingCustom": "#3366cc"})] { + let unlinked = face_fills(&art(params.clone())).iter().filter(|l| matches!(l[0].paint, Paint::Solid { swatch: None, .. })).count(); + assert!(unlinked > 0, "{params}"); + } + // An unlinked fill is shaded as it is with the option off. + let off: Vec<[f32; 3]> = classic_faces(json!({})).iter().map(fill_rgb).collect(); + let on: Vec<[f32; 3]> = classic_faces(json!({"preserveSpotColors": true})).iter().map(fill_rgb).collect(); + assert_eq!(off, on); +} + #[test] fn classic_parameters_are_checked() { let n = classic(json!({})); @@ -2034,6 +2090,7 @@ fn classic_parameters_are_checked() { (json!({"highlightSize": "big"}), "`highlightSize` must be a number"), (json!({"cap": "yes"}), "`cap` must be true or false"), (json!({"drawHiddenFaces": 1}), "`drawHiddenFaces` must be true or false"), + (json!({"preserveSpotColors": "yes"}), "`preserveSpotColors` must be true or false"), (json!({"bevel": "fancy"}), "bevel must be none or one of"), (json!({"twist": 10}), "unknown parameter `twist`"), (json!({"lights": []}), "lights must contain one to ten lights"), @@ -2110,6 +2167,7 @@ fn classic_rotate_parameters_are_checked() { (json!({"lightIntensity": 101}), "`lightIntensity` must be between 0 and 100 (got 101)"), (json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"), (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"preserveSpotColors": null}), "`preserveSpotColors` must be true or false"), (json!({"highlightSize": 50}), "unknown parameter `highlightSize`"), (json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"), (json!({"shadow": true}), "unknown parameter `shadow`"), diff --git a/crates/engine/src/tests/tints.rs b/crates/engine/src/tests/tints.rs index 25d4cb0d4b..d5ac2161f3 100644 --- a/crates/engine/src/tests/tints.rs +++ b/crates/engine/src/tests/tints.rs @@ -126,6 +126,34 @@ fn spot_tints_print_their_percentage_of_the_plate() { assert!(pdf.contains("cs 0.4 scn"), "the tint is the Separation value"); } +/// Extrude & Bevel (Classic) with Preserve Spot Colors keeps a spot fill's plate in PDF: the faces +/// are tints of the Separation, and with the option off they are process colors. +#[test] +fn classic_3d_preserve_spot_colors_keeps_the_spot_plate_in_pdf() { + let pdf = |preserve: bool| { + let mut s = inks(); + rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Ink"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let params = json!({"preserveSpotColors": preserve, "highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60}); + run(&mut s, "effect.apply", json!({"effect": "threeD.classicExtrude", "params": params})); + let bytes = + vectorcraft_pdf::export(doc(&s), &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }) + .unwrap(); + String::from_utf8_lossy(&bytes).into_owned() + }; + // The Separation values the page sets ("/CS1 cs 0.6 scn"). + let tints = |pdf: &str| -> std::collections::BTreeSet { + pdf.split(" scn").filter_map(|s| s.rsplit("cs ").next().filter(|t| t.parse::().is_ok()).map(str::to_string)).collect() + }; + let kept = pdf(true); + assert!(kept.contains("/Separation/Ink/DeviceCMYK"), "the faces print on the Ink plate"); + assert!(tints(&kept).len() > 1, "the highlight prints lighter tints of the ink: {:?}", tints(&kept)); + assert!(kept.contains("/Multiply"), "the shading is black multiplied over the ink"); + // Off, only a face left unshaded keeps the swatch, at its full tint. + assert!(tints(&pdf(false)).len() <= 1, "without the option shaded faces are process mixes"); +} + #[test] fn global_mode_shifts_tints_of_linked_colours_only() { let mut s = inks(); diff --git a/crates/ui-egui/src/dialogs/three_d_classic.rs b/crates/ui-egui/src/dialogs/three_d_classic.rs index a3857c914a..22c18df97e 100644 --- a/crates/ui-egui/src/dialogs/three_d_classic.rs +++ b/crates/ui-egui/src/dialogs/three_d_classic.rs @@ -1,7 +1,7 @@ //! The own controls of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) in the //! compact 3D dialog: the extrude and bevel options, or the revolve options, on the Object tab -//! (Rotate (Classic) has none), and the surface with its lights, highlight, blend steps and shading -//! color on the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D +//! (Rotate (Classic) has none), and the surface with its lights, highlight, blend steps, shading +//! color and Preserve Spot Colors on the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D //! effect shares. use serde_json::{Value, json}; @@ -41,8 +41,8 @@ pub(super) fn revolve_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { } /// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic -/// only, and not for Rotate (Classic), which has no Plastic surface), Blend Steps and Shading Color; -/// Draw Hidden Faces last, for Extrude & Bevel (Classic) (Revolve (Classic) draws every face, and +/// only, and not for Rotate (Classic), which has no Plastic surface), Blend Steps, Shading Color and +/// Preserve Spot Colors (unavailable while Shading Color is Custom); Draw Hidden Faces last, for Extrude & Bevel (Classic) (Revolve (Classic) draws every face, and /// Rotate (Classic)'s plane is seen from either side). pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { let id = d.str("__effect"); @@ -69,9 +69,11 @@ pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { } number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None); choice(ui, d, "shadingColor", tl!("Shading Color"), &options_of(effect, "shadingColor")); - if d.str("shadingColor") == "custom" { + let custom = d.str("shadingColor") == "custom"; + if custom { shading_custom(ui, d); } + ui.add_enabled_ui(!custom, |ui| toggle(ui, d, "preserveSpotColors", tl!("Preserve Spot Colors"), false)); }); if id == CLASSIC_EXTRUDE { toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 342a5171b6..4fcb1152e4 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -181,7 +181,7 @@ VectorCraft choices; an original approximation, never compared with another appl | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, overlapping faces drawn in the order of their depths where they overlap, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle, and wrapped around Revolve's side ([three-d.md](three-d.md#graphics)); no base materials, graphics on Inflate or on Extrude & Bevel's walls and bevels, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Preserve Spot Colors in all three, unavailable with a Custom Shading Color ([Preserve Spot Color](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=260)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | diff --git a/docs/mcp.md b/docs/mcp.md index 8ca4121032..0da8fed984 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4199,6 +4199,13 @@ is tall), `bevelInside` (false adds the bevel around the shape, true carves it i - `shadingColor`: `black` (the default), `custom` (darkens toward `shadingCustom`, a `#rrggbb` color) or `none` (the paint is kept and only the highlight lightens it). `drawHiddenFaces` also draws the faces turned away from the viewer, behind the others. +- `preserveSpotColors` (default false) is the Lighting tab's Preserve Spot Colors checkbox. When it is on, a fill or + stroke linked to a spot or global swatch stays linked on every face, so a spot color prints on its own plate in + separations and PDF. The highlight lowers the swatch's tint, and a face that darkens gets black at the shade's + strength over the swatch, multiplied and overprinting. These rules for drawing the shading are VectorCraft's own. The + option does nothing while `shadingColor` is `custom`, since a custom shading color cannot keep a spot color + ([Preserve Spot Color](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=260)). + Unlinked colors, gradients, patterns and the Wireframe surface draw as they do with it off. The defaults are VectorCraft's own. An unknown parameter, a number out of range, a flag that is not true or false, or an unknown surface or shading color is refused with an error and the document is not changed. @@ -4215,8 +4222,8 @@ for a full turn, and draws every face, back to front, with the classic surfaces. 0..360, default 360), `cap` (true closes the profile's open ends, false leaves the surface hollow), `offset` (points, 0..1000, default 0; the axis's distance from the profile, so a value above 0 makes a ring), `edge` (`left`, the default, or `right`: the edge of the profile's bounds the axis is at), `rotationX/Y/Z` and `perspectiveAngle` as -above, and `surface`, the lights, `ambient`, `highlightIntensity`, `highlightSize`, `blendSteps`, `shadingColor` and -`shadingCustom` as Extrude & Bevel (Classic) takes them. It has no `drawHiddenFaces`. The effect also goes on a group +above, and `surface`, the lights, `ambient`, `highlightIntensity`, `highlightSize`, `blendSteps`, `shadingColor`, +`shadingCustom` and `preserveSpotColors` as Extrude & Bevel (Classic) takes them. It has no `drawHiddenFaces`. The effect also goes on a group or layer, whose members turn about one shared axis, or on a fill or stroke. The public documentation describes the dialog ([Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)) and its Angle (0 to 360), Cap, Offset (0 to 1000) and Left Edge or Right Edge settings @@ -4235,8 +4242,8 @@ changed. 3D (Classic) › Rotate (Classic)…. It turns every fill and stroke as one flat plane about the center of the object's bounds, as `threeD.rotate` does, each in its own paint, and draws the plane from either side. Parameters: `rotationX/Y/Z` and `perspectiveAngle` as above; `surface` (`diffuse`, the default, lights the art softly, and -`none` keeps the paint unlit); and the lights, `ambient`, `blendSteps`, `shadingColor` and `shadingCustom` as Extrude & -Bevel (Classic) takes them. It has no highlight, no `drawHiddenFaces` and no shadow. The effect also goes on a group +`none` keeps the paint unlit); and the lights, `ambient`, `blendSteps`, `shadingColor`, `shadingCustom` and +`preserveSpotColors` as Extrude & Bevel (Classic) takes them. It has no highlight, no `drawHiddenFaces` and no shadow. The effect also goes on a group or layer, whose members turn about one shared center, or on a fill or stroke. The public documentation describes the dialog's Position and Surface settings ([Rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)), diff --git a/docs/three-d.md b/docs/three-d.md index f12ca4675c..fe018791b5 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -502,8 +502,13 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. - **3D (Classic)**: Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) are built, with their surface types and blend steps. Revolve (Classic) sweeps the surface Revolve sweeps, at 64 segments for a full turn, and draws every face back to front, so it has no Draw Hidden Faces setting. Rotate (Classic) turns the plane Rotate turns, seen - from either side, with the No Shading and Diffuse surfaces only. The track cube, map art, Turntable and 3D export - are separate, unbuilt items. + from either side, with the No Shading and Diffuse surfaces only. Preserve Spot Colors, on the Lighting tab of all + three, keeps a color linked to a spot or global swatch linked on every face: the highlight lowers its tint, and a + face that darkens gets black over it, multiplied and overprinting, so a spot color stays on its own plate. The + checkbox is unavailable while Shading Color is Custom + ([Preserve Spot Color](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=260)); how + the faces are shaded with it is VectorCraft's own. The track cube, map art, Turntable and 3D export are separate, + unbuilt items. Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md). From f96d69a5a0c85e2b11609639d1eab9a12aa4ecba Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:06:39 -0800 Subject: [PATCH 12/24] Window: hidden panels show while the pointer rests on the window edge With the panels hidden by Tab or Shift+Tab, resting the pointer for 0.3 s on a 6 pt band along the left or right edge shows the panels docked on that side, over the canvas's edge. The canvas keeps its size, so the art does not move, and it takes no input under the panels. They hide again 0.3 s after the pointer leaves them; Tab or Shift+Tab shows them for good. Floating panels, Tools and the Control bar stay hidden, and nothing shows in Presentation Mode. window.panels.peek {side: left|right|null} does the same from a script, and ui.docking reports the side shown as peek. Part of #5661 --- crates/mcp/src/ui_catalog.rs | 6 + crates/ui-egui/src/menus/ui_commands.rs | 6 + crates/ui-egui/src/panel_docking.rs | 19 +- crates/ui-egui/src/panel_docking/peek.rs | 248 ++++++++++++++++++++++ crates/ui-egui/src/panel_docking/tests.rs | 194 +++++++++++++++++ docs/mcp.md | 9 + docs/ui-parity.md | 1 + 7 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/panel_docking/peek.rs diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 1ae99d0c25..6d1f5e5b22 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -612,6 +612,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Shift+Tab", "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", ), + ( + "window.panels.peek", + "Show Hidden Panels at Edge", + "", + "{side: left|right|null} while Tab or Shift+Tab hides the panels, show the docked panels on that side of the canvas over its edge, as resting the pointer on that edge of the window for 0.3 s does; they hide again 0.3 s after the pointer moves away from them, and null hides them now. Floating panels, the toolbar and the Control bar stay hidden, and Tab or Shift+Tab shows the panels for good. Fails and changes nothing while the panels show, in Presentation Mode, for any other side, or when no panel is docked on that side; returns the side shown or null", + ), ( "window.panel", "Show Panel", diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index d83f03d376..3bd0ccfab4 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -613,6 +613,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Shift+Tab", "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", ), + ( + "window.panels.peek", + "Show Hidden Panels at Edge", + "", + "{side: left|right|null} while Tab or Shift+Tab hides the panels, show the docked panels on that side of the canvas over its edge, as resting the pointer on that edge of the window for 0.3 s does; they hide again 0.3 s after the pointer moves away from them, and null hides them now. Floating panels, the toolbar and the Control bar stay hidden, and Tab or Shift+Tab shows the panels for good. Fails and changes nothing while the panels show, in Presentation Mode, for any other side, or when no panel is docked on that side; returns the side shown or null", + ), ( "window.panel", "Show Panel", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index e22dcd9cac..5b4913d552 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -51,6 +51,8 @@ pub(crate) struct Runtime { raise: Option, /// The panel the user last clicked or pressed in (a tab, a panel body, a drop). focused: Option, + /// Hidden panels shown from the window's edges ([`peek`]). + peek: peek::State, } /// Every docking control the main window drew last frame with its rect. Controls in floating @@ -72,6 +74,7 @@ pub(crate) fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "hidden": app.ui.docking_hidden.keys().collect::>(), "autoCollapse": app.session.prefs.auto_collapse_icon_panels, "panelsShown": app.ui.dock && app.ui.screen_mode < 3, + "peek": peek::shown(app), "hitRects": hits, }) } @@ -322,6 +325,7 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Opt match id { "window.panel" => return Some(show_panel(app, params)), "window.collapseDock" => return Some(crate::menus::opt_bool(params, "collapsed").and_then(|c| collapse_dock(app, c))), + "window.panels.peek" => return Some(peek::command(app, params)), "window.panel.collapse" => { let minimized = match crate::menus::opt_bool(params, "collapsed") { Ok(c) => c, @@ -1167,6 +1171,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if modal || egui::Popup::is_any_open(&ctx) { blocked.push(ctx.content_rect()); } + // Panels hidden by Tab or Shift+Tab show on the side of the window edge the pointer rests on. + let peeking = peek::update(app, &ctx, ui.available_rect_before_wrap(), &layout, !blocked.is_empty()).map(|edge| peek::edge_panels(&layout, edge)); + let shown_from_edge = |panel: &String| peeking.as_ref().is_some_and(|panels| panels.contains(panel)); let style = dock_style(ui); let mut dock = craft_ui::docking::DockArea::new(area) .blocked_rects(&blocked) @@ -1187,7 +1194,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { // Tab and Shift+Tab (`window.dock`, `window.panels`) and the full screen modes hide the panels; // the canvas stays. if !app.ui.dock || app.ui.screen_mode >= 3 { - dock = dock.hide_panels(HidePanels::All); + dock = dock.hide_panels(if peeking.is_some() { HidePanels::Except(&shown_from_edge) } else { HidePanels::All }); } if let Some(panel) = app.dock_rt.raise.take() { dock = dock.raise_panel(panel); @@ -1220,7 +1227,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { app.open_dropped_files(window.dropped, None, shift); } forward_keys(app, &ctx, output.forwarded_events, output.forwarded_modifiers); - if !thumbnail_panel_showing(&app.ui, &ctx) { + if peeking.is_none() && !thumbnail_panel_showing(&app.ui, &ctx) { // No panel can ask for these rows again this pass. Keep the cache of open documents, but // discard worker jobs queued behind the one already rendering. crate::panels::layers::show_thumbs_of(None); @@ -1383,8 +1390,10 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { /// Draw a registered panel body, fitting untouched flyouts and pinning sized panels' footers. fn body(&mut self, ui: &mut egui::Ui, id: &String) { if id == CANVAS { - crate::toolbar::show_beside_canvas(self.app, ui); - self.app.canvas_area(ui); + peek::canvas(self.app, ui, |app, ui| { + crate::toolbar::show_beside_canvas(app, ui); + app.canvas_area(ui); + }); return; } let floating = ui.ctx().viewport_id() != egui::ViewportId::ROOT; @@ -1513,6 +1522,8 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } } +mod peek; + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panel_docking/peek.rs b/crates/ui-egui/src/panel_docking/peek.rs new file mode 100644 index 0000000000..baf147f4fd --- /dev/null +++ b/crates/ui-egui/src/panel_docking/peek.rs @@ -0,0 +1,248 @@ +//! Panels hidden by Tab or Shift+Tab, shown for a while: resting the pointer on the left or right +//! edge of the dock area shows the docked panels on that side of the canvas +//! (`window.panels.peek`). They hide again once the pointer has been away from them for +//! [`GRACE`] seconds; Tab or Shift+Tab shows them for good. The canvas keeps the size it has with +//! every panel hidden, so the panels cover its edge and the artwork stays where it is. Floating +//! panels, the Tools panel and the Control bar stay hidden. + +use craft_ui::docking::{Layout, Node}; +use craft_ui::layout::SplitAxis; +use serde_json::{Value, json}; + +use super::CANVAS; +use crate::VectorcraftApp; + +/// Width of the band along each edge of the dock area that shows the panels on that side. +pub(crate) const EDGE: f32 = 6.0; +/// How long the pointer rests on an edge band before the panels show, in seconds. +pub(crate) const DELAY: f64 = 0.3; +/// How long the pointer stays away from the panels shown before they hide, in seconds. +pub(crate) const GRACE: f64 = 0.3; + +/// A side of the canvas. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Edge { + Left, + Right, +} + +impl Edge { + fn id(self) -> &'static str { + match self { + Edge::Left => "left", + Edge::Right => "right", + } + } +} + +/// The panels shown on one side. +#[derive(Clone, Copy, Debug)] +struct Shown { + edge: Edge, + /// When the pointer left them; `None` while it is over them or has not moved since they showed. + left_at: Option, +} + +/// The session's state of the panels shown from the edges. +pub(crate) struct State { + /// Whether Tab or Shift+Tab hides the panels this frame. + hidden: bool, + /// The dock area this frame. + area: egui::Rect, + /// The edge band the pointer rests on, and since when. + resting: Option<(Edge, f64)>, + shown: Option, + /// How far the canvas's corners lay from the dock area's with every panel hidden. + canvas: Option<(egui::Vec2, egui::Vec2)>, + /// The part of the canvas left uncovered while panels show. + uncovered: Option, +} + +impl Default for State { + fn default() -> Self { + Self { hidden: false, area: egui::Rect::NOTHING, resting: None, shown: None, canvas: None, uncovered: None } + } +} + +/// The docked panels on `edge`'s side of the canvas, at any depth of horizontal splits. +pub(crate) fn edge_panels(layout: &Layout, edge: Edge) -> Vec { + let canvas = CANVAS.to_string(); + let tree = |node: &Node| Layout { root: Some(node.clone()), floating: Vec::new() }; + let mut out = Vec::new(); + let mut node = layout.root.as_ref(); + let mut depth = 0usize; + while let Some(Node::Split { axis, first, second, .. }) = node { + depth = depth.saturating_add(1); + if depth > 2 * craft_ui::docking::MAX_PANELS { + break; + } + let (inner, outer, side) = if tree(first).contains(&canvas) { (first, second, Edge::Right) } else { (second, first, Edge::Left) }; + if matches!(axis, SplitAxis::Horizontal) && side == edge { + out.extend(tree(outer).panels().into_iter().filter(|p| **p != canvas).cloned()); + } + node = Some(inner); + } + out +} + +/// The side whose panels show this frame. `area` is the dock area and `blocked` whether a modal +/// dialog or a popup takes the input. +pub(super) fn update(app: &mut VectorcraftApp, ctx: &egui::Context, area: egui::Rect, layout: &Layout, blocked: bool) -> Option { + let hidden = !app.ui.dock && app.ui.screen_mode < 3; + let flyout = app.ui.open_panel.is_some(); + // The docked Tools panel's rect, read only while Tools shows (Tab turns `toolbar` off). + let tools = app.ui.toolbar.then(|| ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id()))).flatten(); + let st = &mut app.dock_rt.peek; + st.hidden = hidden; + st.area = area; + if !hidden { + st.resting = None; + st.shown = None; + st.uncovered = None; + return None; + } + let (now, pointer, down, moved) = ctx.input(|i| { + // A release counts as moving away: a press may have held the panels while it left them. + let moved = + i.pointer.delta() != egui::Vec2::ZERO || i.pointer.any_released() || i.events.iter().any(|e| matches!(e, egui::Event::PointerGone)); + (i.time, i.pointer.hover_pos(), i.pointer.any_down(), moved) + }); + // A workspace switched to while panels show may dock none on that side. + if st.shown.is_some_and(|shown| edge_panels(layout, shown.edge).is_empty()) { + st.shown = None; + st.uncovered = None; + } + if let Some(shown) = st.shown.as_mut() { + // The panels shown sit beside the canvas, level with it. + let over = pointer.is_some_and(|p| { + let uncovered = st.uncovered.unwrap_or_else(|| area.shrink2(egui::vec2(EDGE, 0.0))); + area.contains(p) + && (uncovered.top()..=uncovered.bottom()).contains(&p.y) + && match shown.edge { + Edge::Left => p.x < uncovered.left(), + Edge::Right => p.x > uncovered.right(), + } + }); + if over || down || blocked || flyout { + shown.left_at = None; + } else if moved || pointer.is_none() { + shown.left_at.get_or_insert(now); + } + match shown.left_at { + Some(left) if now - left >= GRACE => { + st.shown = None; + st.uncovered = None; + // A flyout opened from the panels shown closes with them. + app.ui.open_panel = None; + return None; + } + Some(left) => ctx.request_repaint_after_secs((GRACE - (now - left)) as f32), + None => {} + } + return Some(shown.edge); + } + let edge = pointer.filter(|p| area.contains(*p) && !tools.is_some_and(|r| r.contains(*p))).and_then(|p| { + if p.x <= area.left() + EDGE { + Some(Edge::Left) + } else if p.x >= area.right() - EDGE { + Some(Edge::Right) + } else { + None + } + }); + let Some(edge) = edge.filter(|e| !down && !blocked && !edge_panels(layout, *e).is_empty()) else { + st.resting = None; + return None; + }; + let since = match st.resting { + Some((rested, since)) if rested == edge => since, + _ => now, + }; + if now - since >= DELAY { + st.resting = None; + st.shown = Some(Shown { edge, left_at: None }); + return Some(edge); + } + st.resting = Some((edge, since)); + ctx.request_repaint_after_secs((DELAY - (now - since)) as f32); + // The strip the pointer rests on. + let band = match edge { + Edge::Left => egui::Rect::from_min_max(area.left_top(), egui::pos2(area.left() + EDGE, area.bottom())), + Edge::Right => egui::Rect::from_min_max(egui::pos2(area.right() - EDGE, area.top()), area.right_bottom()), + }; + let t = crate::theme::Tokens::get(ctx); + let layer = egui::LayerId::new(egui::Order::Foreground, super::area_id().with("peek-strip")); + ctx.layer_painter(layer).rect_filled(band, 0.0, t.accent.gamma_multiply(0.6)); + None +} + +/// The side shown, for `ui.docking`. +pub(super) fn shown(app: &VectorcraftApp) -> Option<&'static str> { + app.dock_rt.peek.shown.map(|s| s.edge.id()) +} + +/// Draw the canvas panel's body with `draw`. While panels show from an edge, the canvas keeps the +/// rect it has with every panel hidden, drawn under the panels and taking input only where they +/// leave it uncovered. +pub(super) fn canvas(app: &mut VectorcraftApp, ui: &mut egui::Ui, draw: impl FnOnce(&mut VectorcraftApp, &mut egui::Ui)) { + let uncovered = ui.max_rect(); + let st = &mut app.dock_rt.peek; + let wide = if !st.hidden { + st.canvas = None; + None + } else if st.shown.is_none() { + st.canvas = Some((uncovered.min - st.area.min, uncovered.max - st.area.max)); + None + } else { + st.uncovered = Some(uncovered); + // The canvas's insets in the dock area with every panel hidden, kept as the window resizes; + // none before such a frame was drawn, as it fills the area then. + let (min, max) = st.canvas.unwrap_or((egui::Vec2::ZERO, egui::Vec2::ZERO)); + Some(egui::Rect::from_min_max(st.area.min + min, st.area.max + max)) + }; + match wide { + Some(full) if full != uncovered && full.is_positive() => { + let clip = uncovered.intersect(ui.clip_rect()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(full)); + child.set_clip_rect(clip); + draw(app, &mut child); + } + _ => draw(app, ui), + } +} + +/// `window.panels.peek {side: left|right|null}`: show the docked panels on that side while Tab or +/// Shift+Tab hides the panels, as resting the pointer on that edge does, or (null) hide them. +pub(super) fn command(app: &mut VectorcraftApp, params: &Value) -> Result { + let edge = match params.get("side") { + None | Some(Value::Null) => None, + Some(Value::String(s)) if s == "left" => Some(Edge::Left), + Some(Value::String(s)) if s == "right" => Some(Edge::Right), + Some(_) => return Err("`side` must be left, right or null".into()), + }; + if app.ui.screen_mode >= 3 { + return Err("panels can't be shown in Presentation Mode".into()); + } + if app.ui.dock { + return Err("the panels are not hidden".into()); + } + match edge { + Some(edge) => { + if edge_panels(&super::layout_of(&app.ui), edge).is_empty() { + return Err(format!("no panels are docked on the {} side", edge.id())); + } + let st = &mut app.dock_rt.peek; + st.resting = None; + st.shown = Some(Shown { edge, left_at: None }); + Ok(json!(edge.id())) + } + None => { + let st = &mut app.dock_rt.peek; + if st.shown.take().is_some() { + st.uncovered = None; + app.ui.open_panel = None; + } + Ok(Value::Null) + } + } +} diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index befffb1a9e..2c95ff0e16 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -468,6 +468,200 @@ fn tab_hides_the_panels_and_the_canvas_keeps_drawing() { assert!(h.tab("properties").is_some()); } +/// Hide the panels with Tab and settle; returns the canvas rect with every panel hidden. +fn hide_with_tab(h: &mut Harness) -> Rect { + h.app.run("window.dock", json!({})).unwrap(); + h.frame(vec![]); + h.frame(vec![]); + assert!(h.tab("properties").is_none()); + h.app.canvas_rect.expect("the canvas draws") +} + +/// Frames with the pointer still, for `secs` of the harness clock. +fn wait(h: &mut Harness, secs: f64) { + let until = h.time + secs; + while h.time < until { + h.frame(vec![]); + } +} + +#[test] +fn resting_on_the_right_edge_shows_the_hidden_panels_over_the_canvas_until_the_pointer_leaves() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + let edge = pos2(SCREEN.right() - 2.0, 500.0); + h.frame(vec![Event::PointerMoved(edge)]); + wait(&mut h, peek::DELAY / 2.0); + assert!(h.tab("properties").is_none(), "the panels wait for the pointer to rest"); + wait(&mut h, peek::DELAY); + let tab = h.tab("properties").expect("resting on the edge shows the right-hand panels"); + assert!(h.hit(&Hit::Icon("swatches".into())).is_some(), "the icon column shows with them"); + assert!(!h.app.ui.dock, "the panels stay hidden for good"); + assert_eq!(h.app.canvas_rect, Some(wide), "the canvas keeps its size, so the art stays where it is"); + assert_eq!(inspect(&h.app, &h.ctx)["peek"], json!("right")); + h.frame(vec![Event::PointerMoved(tab.center())]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.tab("properties").is_some(), "they stay while the pointer is over them"); + h.frame(vec![Event::PointerMoved(wide.center())]); + wait(&mut h, peek::GRACE / 2.0); + assert!(h.tab("properties").is_some(), "a short trip away keeps them"); + wait(&mut h, peek::GRACE); + assert!(h.tab("properties").is_none(), "away from them, they hide again"); + assert_eq!(inspect(&h.app, &h.ctx)["peek"], Value::Null); +} + +#[test] +fn a_press_on_the_canvas_while_the_panels_show_from_the_edge_does_not_reach_the_art_under_them() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + h.app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + h.frame(vec![]); + fn selected(app: &VectorcraftApp) -> bool { + !app.session.active().unwrap().selection.is_empty() + } + assert!(selected(&h.app)); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(), json!("right")); + h.frame(vec![]); + h.frame(vec![]); + let tab = h.tab("properties").expect("the right-hand panels show"); + assert!(tab.left() < wide.right(), "they cover the canvas's right edge"); + assert_eq!(h.app.canvas_rect, Some(wide)); + // A click in the panels, where the canvas lies under them, leaves the selection. + h.click(pos2(tab.center().x, wide.bottom() - 40.0)); + assert!(selected(&h.app), "the canvas under the panels takes no click"); + // A click on the uncovered canvas away from the art deselects it, as it always does. + h.click(pos2(wide.left() + 40.0, wide.bottom() - 40.0)); + assert!(!selected(&h.app)); +} + +#[test] +fn the_canvas_keeps_the_hidden_panels_size_when_the_window_resizes_or_the_panels_show_at_once() { + let mut h = Harness::new(); + // Shown before any frame has drawn the panels hidden. + h.app.run("window.dock", json!({})).unwrap(); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(), json!("right")); + h.frame(vec![]); + h.frame(vec![]); + assert!(h.tab("properties").is_some()); + let wide = h.app.canvas_rect.unwrap(); + h.app.run("window.panels.peek", json!({"side": null})).unwrap(); + h.frame(vec![]); + h.frame(vec![]); + assert_eq!(h.app.canvas_rect, Some(wide), "the canvas had the size it has with every panel hidden"); + // A narrower window while they show. + h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(); + let narrow = Rect::from_min_max(SCREEN.min, pos2(SCREEN.right() - 200.0, SCREEN.bottom())); + for _ in 0..3 { + h.time += 0.05; + h.run(egui::RawInput { time: Some(h.time), screen_rect: Some(narrow), ..Default::default() }); + } + assert!(h.tab("properties").is_some()); + let canvas = h.app.canvas_rect.unwrap(); + assert!((canvas.width() - (wide.width() - 200.0)).abs() < 1.0, "{canvas:?} follows the window, under the panels"); +} + +#[test] +fn panels_held_by_a_press_hide_after_its_release_away_from_them() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + let tab = h.tab("properties").expect("the panels show"); + let body = pos2(tab.center().x, wide.bottom() - 40.0); + h.frame(vec![Event::PointerMoved(body), button(body, true)]); + h.frame(vec![Event::PointerMoved(wide.center())]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.tab("properties").is_some(), "a held button keeps them"); + h.frame(vec![button(wide.center(), false)]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.tab("properties").is_none(), "released away from them, they hide"); +} + +#[test] +fn a_drag_to_the_edge_or_a_pointer_on_the_left_edge_shows_nothing() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + // The default layout docks no panel on the left. + h.frame(vec![Event::PointerMoved(pos2(SCREEN.left() + 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_none()); + // A drag that reaches the right edge holds a button down. + h.frame(vec![Event::PointerMoved(wide.center()), button(wide.center(), true)]); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_none(), "a drag never shows them"); + h.frame(vec![button(pos2(SCREEN.right() - 2.0, 500.0), false)]); +} + +#[test] +fn tab_while_the_panels_show_from_the_edge_shows_them_for_good() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_some()); + h.app.run("window.dock", json!({})).unwrap(); + h.frame(vec![Event::PointerMoved(wide.center())]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.app.ui.dock && h.tab("properties").is_some()); + assert_eq!(inspect(&h.app, &h.ctx)["peek"], Value::Null); +} + +#[test] +fn shift_tab_hidden_panels_show_from_the_edge_too() { + let mut h = Harness::new(); + h.app.run("window.panels", json!({})).unwrap(); + h.frame(vec![]); + h.frame(vec![]); + assert!(h.tab("properties").is_none()); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_some()); +} + +#[test] +fn window_panels_peek_rejects_what_it_cannot_show_and_changes_nothing() { + let mut h = Harness::new(); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap_err(), "the panels are not hidden"); + hide_with_tab(&mut h); + for side in [json!("up"), json!(1), json!(true), json!("Right")] { + assert_eq!(h.app.run("window.panels.peek", json!({"side": side})).unwrap_err(), "`side` must be left, right or null", "{side}"); + } + assert_eq!(h.app.run("window.panels.peek", json!({"side": "left"})).unwrap_err(), "no panels are docked on the left side"); + assert_eq!(h.app.run("window.panels.peek", json!({})).unwrap(), Value::Null, "nothing to hide"); + h.frame(vec![]); + assert!(h.tab("properties").is_none() && !h.app.ui.dock); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(), json!("right")); + h.frame(vec![]); + assert!(h.tab("properties").is_some(), "the panels stay while the pointer has not moved"); + assert_eq!(h.app.run("window.panels.peek", json!({"side": null})).unwrap(), Value::Null); + h.frame(vec![]); + assert!(h.tab("properties").is_none()); + h.app.ui.screen_mode = 3; + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap_err(), "panels can't be shown in Presentation Mode"); + h.app.ui.screen_mode = 0; +} + +#[test] +fn edge_panels_are_the_docked_panels_on_each_side_of_the_canvas() { + let layout = default_from(DockTab::ALL[0], &[], false, None, None); + let right = peek::edge_panels(&layout, peek::Edge::Right); + assert!(right.contains(&"properties".to_string()) && right.contains(&"swatches".to_string())); + assert!(!right.contains(&CANVAS.to_string())); + assert!(peek::edge_panels(&layout, peek::Edge::Left).is_empty()); + let mirrored = Layout { + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::FixedFirst(310.0), + first: Box::new(Node::Tabs { panels: vec!["layers".to_string()], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![CANVAS.to_string()], active: 0 }), + }), + floating: Vec::new(), + }; + assert_eq!(peek::edge_panels(&mirrored, peek::Edge::Left), ["layers"]); + assert!(peek::edge_panels(&mirrored, peek::Edge::Right).is_empty()); +} + #[test] fn switching_workspace_during_a_tab_drag_keeps_the_new_workspace_layout() { let mut h = Harness::new(); diff --git a/docs/mcp.md b/docs/mcp.md index 8ca4121032..50066fd7c7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1797,6 +1797,15 @@ and the action still plays. `Session::replay` plays a journal the same way. The hides the dock, no panel is checked, and the panel shows again with the dock in its earlier place; the toolbar and the Control bar stay as they are. +While Tab or Shift+Tab hides the panels, resting the pointer for 0.3 s on a 6 pt band along the left or right edge of +the area below the bars shows the panels docked on that side of the canvas, over the canvas's edge; the canvas keeps its +size, so the art does not move. They hide again once the pointer has been away from them for 0.3 s, and stay while a +flyout opened from them, a menu or a pressed button is open. Floating panels, the toolbar and the Control bar stay +hidden, and Tab or Shift+Tab shows the panels for good. `window.panels.peek {side: "left" | "right"}` shows them as the +pointer does, `{side: null}` hides them, and `ui.docking`'s `peek` names the side shown (null for none). The command +refuses another side, a side with no docked panel, panels that are not hidden and Presentation Mode, and changes +nothing. + The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` and `file.export` refuse a key that no format takes. They accept a key that only another format takes and do not use it: `{"format": "pdf", "quality": 80}` writes the PDF, and the reply's `warnings` name the key diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 082e565bd2..94070b5d48 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -384,6 +384,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | | A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | +| Panels hidden by Tab or Shift+Tab show for a while when the pointer rests on the left or right edge of the window, over the canvas, and hide again when it moves away; Tab shows them for good | done | Part of #5661; `window.panels.peek` ([Workspace basics](https://web.archive.org/web/2014/https://helpx.adobe.com/illustrator/using/workspace-basics.html), "Hide or show all panels"); VectorCraft's own choices: the 6 pt band and the 0.3 s delays, only the docked panels on that side show (floating panels, Tools and the Control bar stay hidden), and nothing shows in Presentation Mode; `panel_docking::tests::resting_on_the_right_edge_shows_the_hidden_panels_over_the_canvas_until_the_pointer_leaves` and the tests after it | | Behaviour | Status | Notes | |---|---|---| From 609d8836a247d4ca0a02e85631555230c87938e2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:06:20 -0800 Subject: [PATCH 13/24] Place: import options a cursor click asks for place within the cursor's roots The OK of the import options a click of the place cursor asks for now places the file within the automation roots the cursor was loaded in, or within the roots of a caller that confirms with roots of its own. Options from a click before the cursor was loaded again are refused. A cursor loaded again (a queue set with tool.setOption, a script) no longer asks for import options or keeps the queued sizes of the cursor the Place dialog loaded. The engine counts cursor loads (Session::place_queue_loads) so the app can tell. --- crates/engine/src/lib.rs | 2 + crates/engine/src/session/documents.rs | 1 + crates/engine/src/tooling.rs | 8 +++ crates/ui-egui/src/place.rs | 52 +++++++++++++- crates/ui-egui/src/tests/place.rs | 95 ++++++++++++++++++++++++++ 5 files changed, 157 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 890f8622f8..df62ddb318 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -244,6 +244,8 @@ pub struct Session { /// keyboard: those files, and the files they link to, are read under these roots, so a queue /// an agent loaded never reads beyond what it was given. pub(crate) place_queue_roots: Option>, + /// How many times the place cursor has been loaded ([`Session::place_queue_loads`]). + pub(crate) place_queue_loads: u64, /// While [`Session::import_image_read`] runs `clipboard.importImage`: the image read for it, /// which the command loads instead of reading `dataBase64`; `None` otherwise. pub(crate) image_hand_off: Option, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b54333206f..b2836d159c 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -113,6 +113,7 @@ impl Session { place_hand_off: None, queue_hand_off: None, place_queue_roots: None, + place_queue_loads: 0, image_hand_off: None, trace_hand_off: None, place_cursor_requests: false, diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index f6bc163e65..4f7e85cd99 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1256,6 +1256,14 @@ impl Session { /// is placed under ([`Session::place_queue_roots`]). fn note_place_queue_roots(&mut self) { self.place_queue_roots = crate::file_access::current(); + self.place_queue_loads = self.place_queue_loads.wrapping_add(1); + } + + /// How many times the place cursor has been loaded (`file.place.queue`, or a `queue` set on + /// the Place tool): a frontend that keeps its own state about the loaded cursor compares it to + /// tell a newer load. + pub fn place_queue_loads(&self) -> u64 { + self.place_queue_loads } /// The automation roots the place cursor's files are placed under: those in force when it diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 3205446c89..82a5d7c6f7 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -74,8 +74,20 @@ pub struct PlaceState { queue_asks: bool, /// The loaded cursor's files by name, with the size a click places them at. queue_sizes: Vec<(String, f64, f64)>, + /// The engine's count of cursor loads ([`vectorcraft_engine::Session::place_queue_loads`]) when + /// `queue_asks` and `queue_sizes` were set: a later load (`tool.setOption`, a script) clears them. + queue_load: u64, + /// The import options dialog a click of the cursor opened (the token in its params' + /// [`ASKED`]), with the automation roots the cursor was loaded in: its OK, pressed outside any + /// roots, places the file within them. Cleared when the cursor is loaded again. + asked: Option<(u64, std::sync::Arc)>, } +/// The `file.place` param that ties an import options dialog's OK to the click of the place cursor +/// that opened it ([`PlaceState::asked`]); hidden from the dialog's fields and removed before the +/// file is placed. +const ASKED: &str = "__placeCursorAsk"; + /// A place cursor thumbnail: decoded when the cursor is loaded, uploaded when first drawn (and /// again in a new graphics context). struct Thumb { @@ -137,6 +149,20 @@ enum Read { } fn place_file(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result { + // The OK of import options a click of the place cursor asked for places within the roots the + // cursor was loaded in. A caller with roots of its own places within those. + let Some(token) = p.get(ASKED) else { return place_file_in(app, p, read) }; + forget_older_queue(app); + let Some(roots) = app.place.asked.as_ref().filter(|(t, _)| Some(*t) == token.as_u64()).map(|(_, r)| r.clone()) else { + return Err("the place cursor that asked for these import options is no longer loaded".into()); + }; + if vectorcraft_engine::file_access::current().is_some() { + return place_file_in(app, p, read); + } + vectorcraft_engine::file_access::confine(Some(&roots), || place_file_in(app, p, read)) +} + +fn place_file_in(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result { let show = match p.get("showImportOptions") { None | Some(Value::Null) => None, Some(Value::Bool(b)) => Some(*b), @@ -188,6 +214,9 @@ fn place_file(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result { let r = crate::placing::start(app, &p, dropped); @@ -425,10 +454,19 @@ pub(crate) fn place_from_cursor(app: &mut VectorcraftApp, p: &Value) { // Read within the automation roots of whoever loaded the cursor, even on a click of the person // at the keyboard: an agent's queue reaches no file it wasn't given. let roots = app.session.place_queue_roots(); + forget_older_queue(app); vectorcraft_engine::file_access::confine(roots.as_ref(), || { if app.place.queue_asks { + let mut p = clicked_at_full_size(app, p); + // The import options it may ask for keep the token, and their OK places within the + // same roots ([`place_file`]). + app.place.asked = roots.clone().map(|r| { + static TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let token = TOKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + p[ASKED] = json!(token); + (token, r) + }); // Placing shows its own errors in the status bar. - let p = clicked_at_full_size(app, p); let _ = place_file(app, &p, Read::ForUser { dropped: false }); } else if crate::placing::available(app) { if let Err(e) = crate::placing::start(app, p, false) { @@ -440,6 +478,16 @@ pub(crate) fn place_from_cursor(app: &mut VectorcraftApp, p: &Value) { }); } +/// The cursor was loaded again since [`show_thumbnails`] (`tool.setOption` with a `queue`, a +/// script): its files no longer ask for their import options, and their queued sizes are unknown. +fn forget_older_queue(app: &mut VectorcraftApp) { + if app.place.queue_load != app.session.place_queue_loads() { + app.place.queue_asks = false; + app.place.queue_sizes.clear(); + app.place.asked = None; + } +} + /// The params of a cursor click (`rect` at the file's queued size) on a PDF or DXF file, whose /// import options may change its size (another page, another scale): placed as its options place /// it at the click (`at`, the center of the box the click gave), as File › Place of that one file @@ -467,7 +515,9 @@ fn clicked_at_full_size(app: &VectorcraftApp, p: &Value) -> Value { /// The cursor's thumbnails and its files' sizes from `file.place.queue`'s reply `r` once the cursor /// is loaded; `asks`: its files ask for their import options when placed. pub(crate) fn show_thumbnails(app: &mut VectorcraftApp, r: &Value, asks: bool) { + app.place.queue_load = app.session.place_queue_loads(); app.place.queue_asks = asks; + app.place.asked = None; app.place.queue_sizes = r["files"] .as_array() .into_iter() diff --git a/crates/ui-egui/src/tests/place.rs b/crates/ui-egui/src/tests/place.rs index cbddfbf2c1..f470ef49bc 100644 --- a/crates/ui-egui/src/tests/place.rs +++ b/crates/ui-egui/src/tests/place.rs @@ -1071,6 +1071,101 @@ fn the_place_cursors_files_ask_for_their_import_options_when_show_import_options assert_eq!(dialog_kind(&app), None); } +/// A cursor loaded again after the Place dialog loaded it with Show Import Options on (a queue set +/// on the Place tool) places its files at once. +#[test] +fn a_place_cursor_loaded_again_no_longer_asks_for_import_options() { + let mut app = app(); + let dir = Folder::new("ui-place-cursor-reloaded"); + let a = dir.file("a.png", &png(10, 10, 72.0)); + let notes = dir.file("notes.txt", NOTES); + app.run("file.place", json!({"paths": [a, notes]})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + let entries: Vec = [(&a, "a.png"), (¬es, "notes.txt")] + .iter() + .map(|(path, name)| json!({"params": {"path": path}, "name": name, "width": 190, "height": 90})) + .collect(); + assert_eq!(app.session.tool_id(), "place"); + app.run("tool.setOption", json!({"values": {"queue": {"entries": entries}}})).unwrap(); + crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 190, 90]})); + assert_eq!(dialog_kind(&app), None); + assert!(selected_text(&app).contains("three")); +} + +/// The import options a click of a cursor loaded within automation roots asks for place within +/// those roots when OK is pressed, by the person at the keyboard too: a file outside them is not +/// read. One inside them is placed. +#[test] +fn the_import_options_a_place_cursor_asks_for_place_within_its_roots() { + use vectorcraft_engine::file_access::{AutomationRoots, confine, unconfined}; + let mut app = app(); + let dir = Folder::new("ui-place-cursor-ask-roots"); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let a = dir.file("in/a.png", &png(10, 10, 72.0)); + let notes = dir.file("in/notes.txt", NOTES); + let pages = [PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), PdfPage::new(300.0, 100.0, "1 0 0 rg 0 0 30 30 re f")]; + let two = dir.file("in/two.pdf", &pdf(&pages, None)); + let outside_notes = dir.file("outside.txt", NOTES); + let outside_pdf = dir.file("outside.pdf", &pdf(&pages, None)); + let roots = AutomationRoots::new(&[&dir.join("in")], &[]).unwrap(); + confine(roots.as_ref(), || { + app.run("file.place", json!({"paths": [a, notes, two]})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false)); + crate::dialogs::confirm(&mut app).unwrap(); + }); + assert_eq!(app.session.tool_options()["count"], 3); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = undo(&app); + // The text file's OK, its params naming a file outside the roots: nothing is placed. + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + app.ui.dialog.as_mut().unwrap().fields.get_mut("__params").unwrap()["path"] = json!(outside_notes); + let _ = unconfined(|| crate::dialogs::confirm(&mut app)); + assert_eq!(undo(&app), before, "the text file outside the roots"); + // The PDF's OK, its file outside the roots: nothing is placed. + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": two, "link": false, "rect": [10, 10, 100, 100]}))); + assert_eq!(dialog_kind(&app), Some("importPdf")); + app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(outside_pdf)); + let _ = unconfined(|| crate::dialogs::confirm(&mut app)); + assert_eq!(undo(&app), before, "the PDF outside the roots"); + app.ui.dialog = None; + // A caller with roots of its own confirms within those: the file in the cursor's roots but + // outside the caller's is not placed. + std::fs::create_dir_all(dir.join("b")).unwrap(); + let narrower = AutomationRoots::new(&[&dir.join("b")], &[]).unwrap(); + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + let _ = confine(narrower.as_ref(), || crate::dialogs::confirm(&mut app)); + assert_eq!(undo(&app), before, "outside the caller's roots"); + // The text file inside the roots is placed. + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + unconfined(|| crate::dialogs::confirm(&mut app)).unwrap(); + assert!(selected_text(&app).contains("three")); + // Once the cursor is loaded again, the earlier click's import options place nothing. + let after = undo(&app); + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + app.run("file.place.queue", json!({"paths": [a]})).unwrap(); + assert!(unconfined(|| crate::dialogs::confirm(&mut app)).is_err()); + assert_eq!(undo(&app), after); + // So do those of a click before a queue set on the Place tool. + confine(roots.as_ref(), || { + app.run("file.place", json!({"paths": [a, notes]})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + }); + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + let entry = json!({"params": {"path": a}, "name": "a.png", "width": 10, "height": 10}); + app.run("tool.setOption", json!({"values": {"queue": {"entries": [entry]}}})).unwrap(); + let saved = app.ui.dialog.clone(); + assert!(unconfined(|| crate::dialogs::confirm(&mut app)).is_err()); + assert_eq!(undo(&app), after); + // Also when confirmed by a caller with roots that hold the file. + app.ui.dialog = saved; + assert!(confine(roots.as_ref(), || crate::dialogs::confirm(&mut app)).is_err()); + assert_eq!(undo(&app), after); +} + #[test] fn a_file_placed_while_idle_follows_its_changes() { let mut app = app(); From be32d6be892ed1bba69309f9dde6176c5c5b6929 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:14:03 -0800 Subject: [PATCH 14/24] Align: a Shift-clicked anchor becomes the key anchor that Align and Distribute keep in place With Direct Selection, the point a Shift-click adds to two or more selected anchor points is the key anchor. It is drawn as a larger filled square in a frame, and the Align panel and the Control bar show the key choice chosen as Align to Key Anchor. The Align buttons move the other points to its x or y, and Distribute with a spacing places them from it. A plain click still selects one point alone. select.key takes {id, anchor} to set it, select.key {} cancels it, and document.inspect reports it as keyAnchor. It goes when it is deselected, deleted or left as the only selected point. --- crates/doc/src/selection.rs | 61 +++++++++- crates/engine/src/cmd/object/align.rs | 44 +++++-- crates/engine/src/cmd/object/commands.rs | 6 +- crates/engine/src/cmd/select.rs | 59 ++++++++- crates/engine/src/inspect.rs | 2 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/keyanchor.rs | 149 +++++++++++++++++++++++ crates/ui-egui/src/canvas/overlays.rs | 13 ++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/gu.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/align.rs | 27 ++-- crates/ui-egui/src/panels/align/tests.rs | 26 ++++ docs/mcp.md | 9 ++ docs/ui-parity.md | 2 +- 40 files changed, 397 insertions(+), 30 deletions(-) create mode 100644 crates/engine/src/tests/keyanchor.rs diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 4ef6446247..541e7801c2 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -49,6 +49,13 @@ pub struct Selection { /// it when no object is selected to align to it. #[serde(default, skip_serializing_if = "Option::is_none")] pub key_guide: Option, + /// The key anchor for Align: one of two or more anchor points selected with Direct Selection, + /// the last one a Shift-click added (path, anchor in [`Self::anchors`]). Align and Distribute + /// move the other selected points relative to it, and it stays in place. Never set with `key` + /// or `key_guide`; [`Self::prune`] clears it when the point is no longer a selected anchor, or + /// fewer than two points are selected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_anchor: Option<(NodeId, AnchorRef)>, /// The new art on a locked artboard that this selection keeps editable /// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and /// the engine then sets the document's from it. Editing state, never saved. @@ -98,6 +105,7 @@ impl Selection { self.slices.clear(); self.guides.clear(); self.key_guide = None; + self.key_anchor = None; self.symbol_part = None; self.on_path_member = None; } @@ -113,9 +121,15 @@ impl Selection { self.set([instance]); self.symbol_part = Some(SymbolPart { instance, path }); } - /// Is there a key for Align: an object or a guide? + /// Is there a key for Align: an object, a guide or an anchor point? pub fn has_key(&self) -> bool { - self.key.is_some() || self.key_guide.is_some() + self.key.is_some() || self.key_guide.is_some() || self.key_anchor.is_some() + } + /// Where the key anchor ([`Self::key_anchor`]) is. `None` without one, or when the point is + /// gone from its path. + pub fn key_anchor_point(&self, doc: &Document) -> Option { + let (id, (si, ai)) = self.key_anchor?; + Some(doc.node(id)?.path_data()?.subpaths.get(si)?.anchors.get(ai)?.p) } pub fn set(&mut self, ids: impl IntoIterator) { self.clear(); @@ -143,6 +157,9 @@ impl Selection { if self.key == Some(id) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| k == id) { + self.key_anchor = None; + } } /// [`Self::remove`] for each of `ids`, going through the selected objects once. pub fn remove_all(&mut self, ids: impl IntoIterator) { @@ -162,6 +179,9 @@ impl Selection { if self.key.is_some_and(|k| ids.contains(&k)) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| ids.contains(&k)) { + self.key_anchor = None; + } } /// Select slices `ids` (see [`Selection::slices`]) and nothing else. pub fn set_slices(&mut self, ids: impl IntoIterator) { @@ -271,6 +291,40 @@ impl Selection { if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) { self.key_guide = None; } + // A key anchor needs its point on its path and selected as an anchor point (not as a + // selected segment's end), with another point selected too: another anchor, or a path + // selected whole (Align and Distribute act on path points only). It takes the place of a + // key object or key guide. + let path_of = |o: &NodeId| found.get(o).and_then(|p| doc.node_at(p)).and_then(|n| n.path_data()); + let whole_path = |o: &NodeId| !self.anchors.contains_key(o) && path_of(o).is_some_and(|path| path.anchors().next().is_some()); + // Selected anchor points that are on their paths, counted up to two. + let points = || { + self.anchors + .iter() + .flat_map(|(o, set)| { + let path = path_of(o); + set.iter().filter(move |&&(si, ai)| path.is_some_and(|p| p.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len()))) + }) + .take(2) + .count() + }; + let anchor_kept = self.key_anchor.is_some_and(|(id, (si, ai))| { + self.objects.contains(&id) + && !self.segments.contains_key(&id) + && self.anchors.get(&id).is_some_and(|set| set.contains(&(si, ai))) + && found + .get(&id) + .and_then(|p| doc.node_at(p)) + .and_then(|n| n.path_data()) + .is_some_and(|path| path.subpaths.get(si).is_some_and(|sp| ai < sp.anchors.len())) + && (points() >= 2 || self.objects.iter().any(whole_path)) + }); + if anchor_kept { + self.key = None; + self.key_guide = None; + } else { + self.key_anchor = None; + } // A selected part needs its instance selected alone, with no guides, its symbol dynamic // and the object still in the symbol's art as a path, compound path or type, where neither // it nor a group or layer around it is hidden, locked or a template. @@ -348,6 +402,9 @@ impl Selection { if self.key.is_some_and(|k| gone.contains(&k)) { self.key = None; } + if self.key_anchor.is_some_and(|(k, _)| gone.contains(&k)) { + self.key_anchor = None; + } } /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected /// (the art of its sublayers too), anything else is selected itself. diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index c3a2898571..d55fdda7b2 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -292,8 +292,22 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str> .collect()) } +/// The key anchor ([`vectorcraft_doc::Selection::key_anchor`]) when Align and Distribute act on the +/// selected anchor points and align to the key ([`align_to`]): the path and the point. +fn key_anchor(s: &Session, p: &Value) -> Option<(NodeId, (usize, usize))> { + (align_to(s, p) == Some("key")).then(|| s.doc().ok()?.selection.key_anchor).flatten() +} + +/// Where the key anchor of [`key_anchor`] is. +fn key_anchor_point(s: &Session, p: &Value) -> Option { + key_anchor(s, p)?; + let st = s.doc().ok()?; + st.selection.key_anchor_point(&st.doc) +} + /// [`align`] of the selected anchor points (whole paths among them give all theirs): each point -/// moves to the edge or centre of the points' bounds, or of the artboard with Align To Artboard. +/// moves to the edge or centre of the points' bounds, of the artboard with Align To Artboard, or to +/// the key anchor when aligning to it. fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&str>, Option<&str>)) -> Result { let targets = crate::cmd::path::anchor_targets(s)?; let d = &s.doc()?.doc; @@ -304,9 +318,10 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st let Some(bounds) = pts.iter().skip(1).fold(pts.first().map(|q| Rect::from_points(*q, *q)), |b, q| b.map(|b| b.union_pt(*q))) else { return ok(); }; - // shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does - // (or to a key guide). - let (r, along_x, along_y) = align_reference(s, p, ids, Measure::default(), Some(bounds))?; + // Aligning to the key, the points go to the key anchor, which stays where it is. Without one, + // a key object aligns them to their bounds as Selection does (a key guide lines them up on it). + let own = key_anchor_point(s, p).map_or(bounds, |k| Rect::from_points(k, k)); + let (r, along_x, along_y) = align_reference(s, p, ids, Measure::default(), Some(own))?; let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y)); let target = |q: Point| { let x = match h { @@ -410,9 +425,10 @@ fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: /// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them /// give all theirs): the points between the first and the last along the axis are spaced evenly, -/// or `gap` apart from the first. A point has no size, so every Distribute option of an axis -/// gives the same result. -fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option) -> Result { +/// or `gap` apart from the first. With a gap and a key anchor `key` among them, the key anchor stays +/// where it is and the others are placed `gap` apart outward from it, in their order along the +/// axis. A point has no size, so every Distribute option of an axis gives the same result. +fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option, key: Option<(NodeId, (usize, usize))>) -> Result { let targets = crate::cmd::path::anchor_targets(s)?; let d = &s.doc()?.doc; let along = |q: Point| if horiz { q.x } else { q.y }; @@ -431,8 +447,11 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option = - pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, first + step * i as f64 - at)).collect(); + pts.iter().enumerate().map(|(i, &(id, si, ai, at))| (id, si, ai, at_from + step * (i as f64 - from as f64) - at)).collect(); if moves.iter().all(|m| m.3.abs() <= 1e-9) { return ok(); } @@ -466,11 +485,13 @@ pub(super) fn distribute(s: &mut Session, p: &Value) -> Result { let m = measure(s, p, "object.distribute")?; let board = distribute_to(s, p, &ids, m, "object.distribute")?; let spacing = spacing(p, "object.distribute")?; - // Anchor points selected with Direct Selection are distributed themselves, as Align aligns them. + // Anchor points selected with Direct Selection are distributed themselves, as Align aligns them; + // with a key anchor, a spacing places them from it. if !s.doc()?.selection.anchors.is_empty() { + let key = key_anchor(s, p); return match direction(p, "object.distribute")? { (None, None) => Err(bad("object.distribute", "give horizontal or vertical")), - (h, _) => distribute_anchors(s, "Distribute", h.is_some(), None), + (h, _) => distribute_anchors(s, "Distribute", h.is_some(), spacing.filter(|_| key.is_some()), key), }; } let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? { @@ -543,7 +564,8 @@ pub(super) fn distribute_spacing(s: &mut Session, p: &Value) -> Result { let board = distribute_to(s, p, &ids, m, "object.distributeSpacing")?; let spacing = spacing(p, "object.distributeSpacing")?; if !s.doc()?.selection.anchors.is_empty() { - return distribute_anchors(s, "Distribute Spacing", horiz, spacing); + let key = key_anchor(s, p); + return distribute_anchors(s, "Distribute Spacing", horiz, spacing, key); } // The selected guides across the axis are spaced with the objects, as items with no size (#1597). let mut items = spaced_items(s, &ids, m, horiz)?; diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index b8c4199623..e89c8fd208 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -260,7 +260,7 @@ pub fn specs() -> Vec { "Align", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, to the artboard (#1180), or, aligning to the key with a key anchor (select.key {id, anchor}, the last point a Shift-click added), to the key anchor, which stays put", has_selection_or_guides, align ), @@ -269,7 +269,7 @@ pub fn specs() -> Vec { "Distribute", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key, or with anchor points selected and no key anchor), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636); with spacing and a key anchor (select.key {id, anchor}), the key anchor stays put and the others are placed spacing apart, outward from it, in their order along the axis", can_distribute, distribute ), @@ -278,7 +278,7 @@ pub fn specs() -> Vec { "Distribute Spacing", ["Window", "Align"], None, - "{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)", + "{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636), or with a key anchor (select.key {id, anchor}) `spacing` apart outward from the key anchor, which stays put", can_distribute, distribute_spacing ), diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index ac9ca365f4..9aa304e96e 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -81,11 +81,19 @@ pub fn specs() -> Vec { "Cancel Key Object", [], None, - "{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more or the only one with ruler guides selected (the Selection tool: a click on one object of a selection of several, or of one with guides; on the key again: no id), guide?: index (a selected ruler guide, guide.list, with one or more objects selected: the Selection tool's click on a selected guide; Align lines the objects up on it and the guide stays put)} the key object Align aligns to and Distribute Spacing spaces from; without `id` or `guide` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members) and no ruler guide, or a key guide with no object selected or taken out of the selection; any other id or guide is an error that leaves the key as it was → {key, guide?} (the resulting key object, or null; guide: the key guide, left out without one)", + "{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more or the only one with ruler guides selected (the Selection tool: a click on one object of a selection of several, or of one with guides; on the key again: no id), guide?: index (a selected ruler guide, guide.list, with one or more objects selected: the Selection tool's click on a selected guide; Align lines the objects up on it and the guide stays put), anchor?: [subpath, anchor] with `id` the path (an anchor point selected with Direct Selection beside at least one other point becomes the key anchor: Align and Distribute move the other points relative to it and it stays put; select.anchors add or toggle of one point makes it the key anchor, as a Shift-click does)} the key object Align aligns to and Distribute Spacing spaces from; without `id` or `guide` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members) and no ruler guide, or a key guide with no object selected or taken out of the selection, or a key anchor that is deselected, deleted or left as the only selected point; any other id, guide or anchor is an error that leaves the key as it was → {key, guide?, keyAnchor?} (the resulting key object, or null; guide: the key guide, left out without one; keyAnchor: {id, anchor}, left out without one)", has_doc, key ), - cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors), + cmd!( + "select.anchors", + "Select Anchors", + [], + None, + "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}; add or toggle that selects one point makes it the key anchor while another point is selected too (see select.key)", + has_doc, + anchors + ), cmd!( "select.segments", "Select Segments", @@ -610,6 +618,9 @@ fn key(s: &mut Session, p: &Value) -> Result { } None => None, }; + if let Some(v) = p.get("anchor").filter(|v| !v.is_null()) { + return key_anchor(s, id, guide, v); + } if let Some(g) = guide { let sel = &s.doc()?.selection; if id.is_some() { @@ -624,6 +635,7 @@ fn key(s: &mut Session, p: &Value) -> Result { } s.select(|_, sel| { sel.key = None; + sel.key_anchor = None; sel.key_guide = Some(gid); })?; return key_result(s); @@ -640,17 +652,46 @@ fn key(s: &mut Session, p: &Value) -> Result { s.select(|_, sel| { sel.key = id; sel.key_guide = None; + sel.key_anchor = None; })?; key_result(s) } -/// `{key}`, with `guide` when a guide is the key. +/// `select.key {id, anchor: [subpath, anchor]}`: anchor point `anchor` of path `id`, selected with +/// Direct Selection beside at least one other point, becomes the key anchor +/// ([`vectorcraft_doc::Selection::key_anchor`]). +fn key_anchor(s: &mut Session, id: Option, guide: Option, v: &Value) -> Result { + const C: &str = "select.key"; + let r = match checked_refs(Some(&Value::Array(vec![v.clone()])), C)?.as_slice() { + [r] => *r, + _ => return Err(bad(C, "`anchor` must be a [subpath, anchor] pair")), + }; + if guide.is_some() { + return Err(bad(C, "give `anchor` or `guide`, not both")); + } + let id = id.ok_or_else(|| bad(C, "`anchor` needs `id`, the path the point is on"))?; + let sel = &s.doc()?.selection; + if sel.segments.contains_key(&id) || !sel.anchors.get(&id).is_some_and(|set| set.contains(&r)) { + return Err(bad(C, format!("anchor [{}, {}] of object {id} is not a selected anchor point", r.0, r.1))); + } + s.select(|_, sel| sel.key_anchor = Some((id, r)))?; + if s.doc()?.selection.key_anchor.is_none() { + return Err(bad(C, "select another anchor point to align to the key anchor")); + } + key_result(s) +} + +/// `{key}`, with `guide` when a guide is the key and `keyAnchor` (`{id, anchor}`) when an anchor +/// point is. fn key_result(s: &Session) -> Result { let st = s.doc()?; let mut out = json!({ "key": st.selection.key }); if let Some(g) = st.selection.key_guide.and_then(|id| super::docmenu::guide_indexes(&st.doc, &[id]).first().copied()) { out["guide"] = json!(g); } + if let Some((id, (si, ai))) = st.selection.key_anchor { + out["keyAnchor"] = json!({ "id": id.0, "anchor": [si, ai] }); + } Ok(out) } @@ -783,15 +824,23 @@ fn anchors(s: &mut Session, p: &Value) -> Result { if whole && let Some(path) = d.node(id).and_then(|n| n.path_data()) { e.extend(path.anchors().map(|(s, i, _)| (s, i))); } + // One point that add or toggle selects becomes the key anchor (a Shift-click with Direct + // Selection); `prune` drops it unless another point is selected too. + let single = refs.len() == 1; + let mut newest = None; for r in refs { if mode == "toggle" && e.contains(&r) { e.remove(&r); - } else { - e.insert(r); + } else if e.insert(r) && single { + newest = Some(r); } } if e.is_empty() { sel.remove(id); + } else if mode != "set" + && let Some(r) = newest + { + sel.key_anchor = Some((id, r)); } })?; ok() diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 146583778f..323d5d0918 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -308,6 +308,8 @@ fn document_base(s: &Session) -> Value { "key": st.selection.key.map(|k| k.0), // The ruler guide that is the key instead (`select.key {guide}`): an index into `guide.list`. "keyGuide": st.selection.key_guide.and_then(|id| crate::cmd::guide_indexes(d, &[id]).first().copied()), + // The anchor point that is the key instead (`select.key {id, anchor}`): {id, anchor}. + "keyAnchor": st.selection.key_anchor.map(|(id, (si, ai))| json!({"id": id.0, "anchor": [si, ai]})), "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), "objects": d.node_count(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f89cc98ff8..74b0260235 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -86,6 +86,7 @@ mod inline; mod interactivity; mod intertwine; mod journal; +mod keyanchor; mod keyguide; mod knockout; mod labspots; diff --git a/crates/engine/src/tests/keyanchor.rs b/crates/engine/src/tests/keyanchor.rs new file mode 100644 index 0000000000..afa321ff73 --- /dev/null +++ b/crates/engine/src/tests/keyanchor.rs @@ -0,0 +1,149 @@ +//! #1636: the key anchor. With Direct Selection, the point a Shift-click adds to two or more +//! selected anchor points is the key anchor; Align moves the other points to it, Distribute with a +//! spacing places them from it, and it stays in place. + +use serde_json::json; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn path(s: &mut Session, d: &str) -> NodeId { + NodeId(s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap()) +} + +fn pts(s: &Session, id: NodeId) -> Vec<(f64, f64)> { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() +} + +fn key_anchor(s: &Session) -> Option<(NodeId, (usize, usize))> { + s.doc().unwrap().selection.key_anchor +} + +/// A Direct Selection click at (`x`, `y`), with Shift when `shift`. +fn click(s: &mut Session, x: f64, y: f64, shift: bool) { + let (v, mods) = (ViewInfo::default(), Mods { shift, ..Default::default() }); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), v).unwrap(); + } +} + +#[test] +fn a_shift_click_makes_the_key_anchor_and_a_plain_click_narrows_the_selection() { + let mut s = session(); + let id = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + click(&mut s, 100.0, 40.0, false); + assert_eq!(key_anchor(&s), None, "one point: no key anchor"); + click(&mut s, 150.0, 160.0, true); + assert_eq!(key_anchor(&s), Some((id, (0, 2))), "the point the Shift-click added"); + click(&mut s, 200.0, 60.0, true); + assert_eq!(key_anchor(&s), Some((id, (0, 1))), "the last point added"); + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspect["keyAnchor"], json!({"id": id.0, "anchor": [0, 1]})); + // A Shift-click on the key anchor deselects it, and there is no key anchor. + click(&mut s, 200.0, 60.0, true); + assert_eq!(key_anchor(&s), None); + click(&mut s, 200.0, 60.0, true); + // A plain click on one of the selected points selects that point alone. + click(&mut s, 100.0, 40.0, false); + let sel = &s.doc().unwrap().selection; + assert_eq!(sel.anchors.get(&id).map(|a| a.iter().copied().collect::>()), Some(vec![(0, 0)])); + assert_eq!(sel.key_anchor, None); +} + +#[test] +fn align_moves_the_other_points_to_the_key_anchor() { + let mut s = session(); + let id = path(&mut s, "M100 40 L200 60 L150 160 L60 120 Z"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + assert_eq!(key_anchor(&s), None, "a set selects no key anchor"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((id, (0, 2)))); + s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); + assert_eq!(pts(&s, id), [(150.0, 40.0), (150.0, 60.0), (150.0, 160.0), (60.0, 120.0)], "to the key anchor's x"); + s.execute("object.align", &json!({"vertical": "bottom"})).unwrap(); + assert_eq!(pts(&s, id), [(150.0, 160.0), (150.0, 160.0), (150.0, 160.0), (60.0, 120.0)], "the key anchor stays"); + let steps = super::history(&s); + s.execute("object.align", &json!({"vertical": "top"})).unwrap(); + assert_eq!(super::history(&s), steps, "nothing moved: no undo step"); + // `to: "selection"` aligns to the points' bounds instead. + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 3]], "mode": "add"})).unwrap(); + s.execute("object.align", &json!({"horizontal": "left", "to": "selection"})).unwrap(); + assert_eq!(pts(&s, id).iter().map(|p| p.0).collect::>(), [60.0; 4]); +} + +#[test] +fn distribute_with_a_spacing_places_the_points_from_the_key_anchor() { + let mut s = session(); + let id = path(&mut s, "M10 10 L20 50 L100 30 L60 90"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "toggle"})).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 20})).unwrap(); + assert_eq!(pts(&s, id), [(60.0, 10.0), (80.0, 50.0), (100.0, 30.0), (60.0, 90.0)], "20 apart, outward from the key anchor"); + s.execute("object.distribute", &json!({"vertical": "center", "spacing": 5})).unwrap(); + assert_eq!(pts(&s, id), [(60.0, 25.0), (80.0, 35.0), (100.0, 30.0), (60.0, 90.0)], "in their order along y, from y 30"); + // Without a spacing, Distribute spaces the points between the first and the last. + s.execute("object.distribute", &json!({"horizontal": "center"})).unwrap(); + assert_eq!(pts(&s, id)[1].0, 80.0); +} + +#[test] +fn the_key_anchor_goes_when_it_can_no_longer_be_one() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + let b = path(&mut s, "M300 300 L400 300"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((b, (0, 1))), "a point of another path"); + assert!(s.doc().unwrap().selection.has_key()); + let r = s.execute("select.key", &json!({})).unwrap(); + assert_eq!((r["key"].clone(), r.get("keyAnchor")), (json!(null), None), "select.key {{}} cancels it"); + assert_eq!(key_anchor(&s), None); + let r = s.execute("select.key", &json!({"id": b.0, "anchor": [0, 1]})).unwrap(); + assert_eq!(r["keyAnchor"], json!({"id": b.0, "anchor": [0, 1]})); + // Deleting the key anchor's path leaves no key anchor. + s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(key_anchor(&s), None); + // A key object replaces a key anchor. + let c = path(&mut s, "M500 500 L600 500"); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((a, (0, 1))), "beside a path selected whole"); + s.execute("select.key", &json!({"id": c.0})).unwrap(); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.key, sel.key_anchor), (Some(c), None)); + // Type selected whole has no points to align, so one anchor beside it is no key anchor. + let t = NodeId(s.execute("text.create", &json!({"x": 40, "y": 400, "size": 12, "text": "Key"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), None); +} + +#[test] +fn select_key_rejects_an_anchor_that_cannot_be_the_key() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + let err = |s: &mut Session, p: serde_json::Value| s.execute("select.key", &p).expect_err(&p.to_string()).to_string(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("select another anchor point"), "the only point"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 9]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), None, "a point the path does not have"); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("select another anchor point"), "the only point on the path"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 2]})).contains("is not a selected anchor point")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 9]})).contains("is not a selected anchor point")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0]})).contains("expected [subpath, anchor]")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, -1]})).contains("non-negative integer")); + assert!(err(&mut s, json!({"anchor": [0, 1]})).contains("needs `id`")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 1], "guide": 0})).contains("not both")); + // A selected segment's ends are not selected anchor points. + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 0]], "mode": "set"})).unwrap(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("is not a selected anchor point")); + assert_eq!(key_anchor(&s), None, "the key stays as it was"); +} diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 6e222cc405..139d0adc7d 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -86,6 +86,11 @@ impl<'a> HandleLook<'a> { } } +/// How many points bigger than the other selected anchors the key anchor's square is drawn. +const KEY_ANCHOR_GROW: f32 = 2.0; +/// The gap between the key anchor's square and the frame drawn around it. +const KEY_ANCHOR_GAP: f32 = 2.0; + pub(super) fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { let r = egui::Rect::from_center_size(c, vec2(size, size)); if filled { @@ -188,6 +193,14 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); } marks.paint(st, p, xf, &options, key_outline, if big { &[] } else { &st.selection.objects }); + // The key anchor (Align to Key Anchor): a filled square bigger than the other anchors, in + // a frame as thick as a key object's outline. + if let Some(((k, _), q)) = st.selection.key_anchor.zip(st.selection.key_anchor_point(&st.doc)) { + let (c, color, size) = (xf.to_screen(q), c32(st.doc.layer_color(k)), anchor(direct) + KEY_ANCHOR_GROW); + anchor_square(p, c, color, true, size); + let frame = egui::Rect::from_center_size(c, vec2(size + 2.0 * KEY_ANCHOR_GAP, size + 2.0 * KEY_ANCHOR_GAP)); + p.rect_stroke(frame, 0.0, Stroke::new(KEY_OUTLINE, color), StrokeKind::Outside); + } } // The spine of each selected blend (or of the blend a selected key object belongs to). let mut spines = vec![]; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 948b295e17..34dcaf4a8b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -146,6 +146,7 @@ Align to Artboard Подравняване спрямо работната област Align to Glyph Bounds Подравняване спрямо границите на глифа Align to Key Object Подравняване спрямо ключов обект + Align to Key Anchor Подравняване спрямо ключова опорна точка Align to Page Подравняване спрямо страницата Align to Path Подравняване спрямо пътя Align to Selection Подравняване спрямо селекцията diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f8d6fa263b..003e0b036b 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1491,6 +1491,7 @@ lighting Light Svjetlo Align to Artboard Poravnaj s radnom pločom Align to Glyph Bounds Poravnaj s granicama glifa Align to Key Object Poravnaj s ključnim objektom + Align to Key Anchor Poravnaj s ključnom sidrenom tačkom Align to Selection Poravnaj prema odabiru Align To: Poravnaj s: Cancel Key Object Otkaži ključni objekat diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3ed3ca8331..7162092c1a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -242,6 +242,7 @@ Align to Artboard Alinea al tauler Align to Glyph Bounds Alinea als límits del glif Align to Key Object Alinea a l’objecte clau + Align to Key Anchor Alinea al punt d’ancoratge clau Align to Page Alinea a la pàgina Align to Path Alinea al traçat Align to Selection Alinea a la selecció diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 161e235c56..0b69dc14c5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1519,6 +1519,7 @@ lighting Light Světlo Align to Artboard Zarovnat k pracovní ploše Align to Glyph Bounds Zarovnat k hranicím glyfů Align to Key Object Zarovnat ke klíčovému objektu + Align to Key Anchor Zarovnat ke klíčovému kotevnímu bodu Align to Selection Zarovnat k výběru Align To: Zarovnat k: Cancel Key Object Zrušit klíčový objekt diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index fd0b7c042e..4d7702cb3d 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2035,6 +2035,7 @@ lighting Light Lys Align to Artboard Juster til tegnebræt Align to Glyph Bounds Juster til glyfafgrænsning Align to Key Object Juster til nøgleobjekt + Align to Key Anchor Juster til nøgleankerpunkt Align to Selection Juster til markering Align To: Juster til: Cancel Key Object Annullér nøgleobjekt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2a5f32a5d9..f263a9f9b4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -135,6 +135,7 @@ Align to Artboard An Zeichenfläche ausrichten Align to Glyph Bounds An Glyphenbegrenzung ausrichten Align to Key Object An Basisobjekt ausrichten + Align to Key Anchor An Basisankerpunkt ausrichten Align to Page An Seite ausrichten Align to Path An Pfad ausrichten Align to Selection An Auswahl ausrichten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b1b7b4d039..caff68c490 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -140,6 +140,7 @@ Align to Artboard Στοίχιση ως προς τον πίνακα σχεδίασης Align to Glyph Bounds Στοίχιση στα όρια των γλυφών Align to Key Object Στοίχιση ως προς το βασικό αντικείμενο + Align to Key Anchor Στοίχιση ως προς το βασικό σημείο αγκύρωσης Align to Page Στοίχιση ως προς τη σελίδα Align to Path Στοίχιση ως προς τη διαδρομή Align to Selection Στοίχιση ως προς την επιλογή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b3c675f7b..fafa644a60 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -160,6 +160,7 @@ Align to Artboard Alinear con la mesa de trabajo Align to Glyph Bounds Alinear con los límites del glifo Align to Key Object Alinear con el objeto clave + Align to Key Anchor Alinear con el punto de ancla clave Align to Page Alinear con la página Align to Path Alinear con el trazado Align to Selection Alinear con la selección diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e77a617c52..d4ba43a2b7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1493,6 +1493,7 @@ lighting Light Valo Align to Artboard Tasaa piirtoalustaan Align to Glyph Bounds Tasaa merkin rajoihin Align to Key Object Tasaa avainobjektiin + Align to Key Anchor Tasaa avainankkuripisteeseen Align to Selection Tasaa valintaan Align To: Tasaa: Cancel Key Object Peruuta avainobjekti diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2762d2a954..cb489d39c0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -161,6 +161,7 @@ Align to Artboard Aligner sur le plan de travail Align to Glyph Bounds Aligner sur les limites du glyphe Align to Key Object Aligner sur l’objet clé + Align to Key Anchor Aligner sur le point d’ancrage clé Align to Page Aligner sur la page Align to Path Aligner sur le tracé Align to Selection Aligner sur la sélection diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 1c96900b2b..74f043de20 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -188,6 +188,7 @@ Align to Artboard આર્ટબોર્ડ પર સંરેખિત કરો Align to Glyph Bounds ગ્લિફ સીમા પર સંરેખિત કરો Align to Key Object કી ઑબ્જેક્ટ પર સંરેખિત કરો + Align to Key Anchor કી એન્કર બિંદુ પર સંરેખિત કરો Align to Page પેજ પર સંરેખિત કરો Align to Path પાથ પર સંરેખિત કરો Align to Pixel Grid પિક્સેલ ગ્રિડ પર સંરેખિત કરો diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 548f961f51..e05fdd3262 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -283,6 +283,7 @@ Align to Corners and Ends कोनों और सिरों पर संरेखित करें Align to Glyph Bounds ग्लिफ़ की सीमाओं पर संरेखित करें Align to Key Object मुख्य ऑब्जेक्ट पर संरेखित करें + Align to Key Anchor मुख्य एंकर पॉइंट पर संरेखित करें Align to Page पेज पर संरेखित करें Align to Path पाथ पर संरेखित करें Align to Selection सिलेक्शन पर संरेखित करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 200b45d1b1..148af36fc7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -229,6 +229,7 @@ Align to Artboard Igazítás a rajztáblához Align to Glyph Bounds Igazítás a glifák határaihoz Align to Key Object Igazítás a kulcsobjektumhoz + Align to Key Anchor Igazítás a kulcs-horgonyponthoz Align to Page Igazítás az oldalhoz Align to Path Igazítás az útvonalhoz Align to Selection Igazítás a kijelöléshez diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f3dcca3874..5cc0a03ed5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1426,6 +1426,7 @@ lighting Light Cahaya Align to Artboard Ratakan ke artboard Align to Glyph Bounds Ratakan ke batas glif Align to Key Object Ratakan ke objek kunci + Align to Key Anchor Ratakan ke titik jangkar kunci Align to Selection Ratakan ke seleksi Align To: Ratakan ke: Cancel Key Object Batalkan objek kunci diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 90ec79404c..34d36705db 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -160,6 +160,7 @@ Align to Artboard Allinea alla tavola da disegno Align to Glyph Bounds Allinea ai limiti del glifo Align to Key Object Allinea all'oggetto chiave + Align to Key Anchor Allinea al punto di ancoraggio chiave Align to Page Allinea alla pagina Align to Path Allinea al tracciato Align to Selection Allinea alla selezione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d98acfa22d..b335b76df1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1076,6 +1076,7 @@ digits Malayalam マラヤーラム数字 Align to Artboard アートボードに整列 Align to Glyph Bounds 字形の境界に整列 Align to Key Object キーオブジェクトに整列 + Align to Key Anchor キーアンカーポイントに整列 Align to Page ページに沿う Align to Path パスに沿う Align to Selection 選択範囲に整列 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 794e5454a9..56d6a9048f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1365,6 +1365,7 @@ lighting Light სინათლე Align to Artboard სამუშაო დაფაზე სწორება Align to Glyph Bounds გლიფის საზღვრებზე სწორება Align to Key Object საკვანძო ობიექტზე სწორება + Align to Key Anchor საკვანძო საყრდენ წერტილზე სწორება Align to Selection მონიშნულზე სწორება Align To: სწორება: Cancel Key Object საკვანძო ობიექტის გაუქმება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bb8676df71..db692e1e7b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1066,6 +1066,7 @@ digits Malayalam 말라얄람어 Align to Artboard 아트보드에 정렬 Align to Glyph Bounds 글리프 경계에 정렬 Align to Key Object 키 개체에 정렬 + Align to Key Anchor 키 앵커 포인트에 정렬 Align to Page 페이지에 정렬 Align to Path 패스에 정렬 Align to Selection 선택 영역에 정렬 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a597a03fd0..775308d392 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1944,6 +1944,7 @@ lighting Light Licht Align to Artboard Uitlijnen op tekengebied Align to Glyph Bounds Uitlijnen op glyphbegrenzing Align to Key Object Uitlijnen op sleutelobject + Align to Key Anchor Uitlijnen op sleutelankerpunt Align to Selection Uitlijnen op selectie Align To: Uitlijnen op: Cancel Key Object Sleutelobject annuleren diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 99ee2080b8..fdcec3b241 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1432,6 +1432,7 @@ lighting Light Światło Align to Artboard Wyrównaj do obszaru kompozycji Align to Glyph Bounds Wyrównaj do granic glifów Align to Key Object Wyrównaj do obiektu kluczowego + Align to Key Anchor Wyrównaj do kluczowego punktu kontrolnego Align to Selection Wyrównaj do zaznaczenia Align To: Wyrównaj do: Cancel Key Object Anuluj obiekt kluczowy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 677c8e460f..5d2bd7b254 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -164,6 +164,7 @@ Align to Artboard Alinhar à Mesa de Trabalho Align to Glyph Bounds Alinhar aos Limites do Glifo Align to Key Object Alinhar ao Objeto-Chave + Align to Key Anchor Alinhar ao Ponto de Ancoragem-Chave Align to Page Alinhar à Página Align to Path Alinhar ao Caminho Align to Selection Alinhar à Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 106343cddd..dfe6da25e8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -158,6 +158,7 @@ Align to Artboard Выровнять по монтажной области Align to Glyph Bounds Выровнять по границам глифа Align to Key Object Выровнять по ключевому объекту + Align to Key Anchor Выровнять по ключевой опорной точке Align to Page Выровнять по странице Align to Path Выровнять по контуру Align to Selection Выровнять по выделению diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cc854aa362..539ec3e2d9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -148,6 +148,7 @@ Align to Artboard Justera mot rityta Align to Glyph Bounds Justera mot glyfgränser Align to Key Object Justera mot nyckelobjekt + Align to Key Anchor Justera mot nyckelankarpunkt Align to Page Justera mot sida Align to Path Justera mot bana Align to Selection Justera mot markering diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f2888a30f0..110a4f55ef 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -145,6 +145,7 @@ Align to Artboard Çalışma Yüzeyine Hizala Align to Glyph Bounds Glif Sınırlarına Hizala Align to Key Object Anahtar Nesneye Hizala + Align to Key Anchor Anahtar Bağlantı Noktasına Hizala Align to Page Sayfaya Hizala Align to Path Yola Hizala Align to Selection Seçime Hizala diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 411b2c3baf..85f4f77c98 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -154,6 +154,7 @@ Align to Artboard Вирівняти по монтажній області Align to Glyph Bounds Вирівняти за межами гліфа Align to Key Object Вирівняти за ключовим об’єктом + Align to Key Anchor Вирівняти за ключовою опорною точкою Align to Page Вирівняти за сторінкою Align to Path Вирівняти за контуром Align to Selection Вирівняти за виділенням diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1460910197..dfe5dd2fcb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -175,6 +175,7 @@ Align to Artboard Căn theo bảng vẽ Align to Glyph Bounds Căn theo giới hạn glyph Align to Key Object Căn theo đối tượng chính + Align to Key Anchor Căn theo điểm neo chính Align to Page Căn theo trang Align to Path Căn theo đường dẫn Align to Selection Căn theo vùng chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3fe2f9e009..2bcfddac9e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -109,6 +109,7 @@ Align to Artboard 对齐画板 Align to Glyph Bounds 对齐到字形边界 Align to Key Object 对齐关键对象 + Align to Key Anchor 对齐关键锚点 Align to Page 对齐页面 Align to Path 对齐路径 Align to Selection 对齐选区 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ab7f9d9dd2..2e0b6c2a3f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -118,6 +118,7 @@ Align to Artboard 對齊工作區域 Align to Glyph Bounds 對齊字符邊界 Align to Key Object 對齊關鍵物件 + Align to Key Anchor 對齊關鍵錨點 Align to Page 對齊頁面 Align to Path 對齊路徑 Align to Selection 對齊選取範圍 diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 02c7969be2..e5cb5d07dc 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -297,9 +297,10 @@ fn topmost(app: &VectorcraftApp) -> Option { app.session.active().and_then(|st| st.selection.in_paint_order(&st.doc).last().copied()) } -/// What Align aligns to: the key object or the key guide (#2143) while there is one (a click on -/// an object of the selection makes it the key, and Align to Key Object makes the topmost one the -/// key, or a guide selected with a single object), else the stored Align To choice, Selection or +/// What Align aligns to: the key object, the key guide (#2143) or the key anchor while there is one +/// (a click on an object of the selection makes it the key, and Align to Key Object makes the +/// topmost one the key, or a guide selected with a single object; a Shift-click with Direct +/// Selection makes the point it adds the key anchor), else the stored Align To choice, Selection or /// Artboard. The Distribute Objects and Distribute Spacing buttons send it as their `to`. fn align_to(app: &VectorcraftApp) -> AlignTo { if has_key(app) { AlignTo::Key } else { app.ui.align_to } @@ -308,15 +309,23 @@ fn align_to(app: &VectorcraftApp) -> AlignTo { /// The Align To choices of the Align panel and of the Control bar's Align To menu, in their order. const ALIGN_TO: [AlignTo; 3] = [AlignTo::Selection, AlignTo::Key, AlignTo::Artboard]; -/// The icon and the translated name of Align To choice `v`. -fn align_to_icon_name(v: AlignTo) -> (&'static str, &'static str) { +/// The icon and the translated name of Align To choice `v`; `anchor`: the key is an anchor point +/// (Direct Selection), and the key choice is named Align to Key Anchor. +fn align_to_icon_name(v: AlignTo, anchor: bool) -> (&'static str, &'static str) { match v { AlignTo::Selection => ("dc-alignto-selection", tl!("Align to Selection")), + AlignTo::Key if anchor => ("dc-alignto-key", tl!("Align to Key Anchor")), AlignTo::Key => ("dc-alignto-key", tl!("Align to Key Object")), AlignTo::Artboard => ("dc-alignto-artboard", tl!("Align to Artboard")), } } +/// Whether the key is an anchor point: the last of two or more anchor points that a Shift-click +/// with Direct Selection added ([`vectorcraft_doc::Selection::key_anchor`]). +fn key_is_anchor(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| st.selection.key_anchor.is_some()) +} + /// The Align To choice that the Align panel and the Control bar show chosen: Artboard while one /// object is selected (the buttons then align it to the artboard), else [`align_to`]. With the /// Artboard tool chosen, it is the stored choice, Selection or Artboard. @@ -386,7 +395,8 @@ const ALIGN_TO_ARROW: f32 = 14.0; pub(crate) fn align_to_menu(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { let t = crate::theme::Tokens::get(ui.ctx()); let shown = shown_align_to(app); - let (icon, name) = align_to_icon_name(shown); + let anchor = key_is_anchor(app); + let (icon, name) = align_to_icon_name(shown, anchor); let (rect, resp) = ui.allocate_exact_size(egui::vec2(align_to_menu_width(size), size), egui::Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, egui::CornerRadius::same(5), t.hover); @@ -403,7 +413,7 @@ pub(crate) fn align_to_menu(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { let picked = egui::Popup::menu(&resp).show(|ui| { let mut picked = None; for v in ALIGN_TO { - if menu_item(ui, align_to_icon_name(v).1, can_choose(app, v), shown == v) { + if menu_item(ui, align_to_icon_name(v, anchor).1, can_choose(app, v), shown == v) { picked = Some(v); } } @@ -485,9 +495,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::subheader(ui, tl!("Align To:")); // With one object selected the buttons align it to the artboard, and Align To shows Artboard. let shown = shown_align_to(app); + let anchor = key_is_anchor(app); ui.horizontal(|ui| { for v in ALIGN_TO { - let (icon, tip) = align_to_icon_name(v); + let (icon, tip) = align_to_icon_name(v, anchor); if widgets::icon_button_enabled(ui, icon, tip, shown == v, can_choose(app, v), 28.0).clicked() { choose_align_to(app, v); } diff --git a/crates/ui-egui/src/panels/align/tests.rs b/crates/ui-egui/src/panels/align/tests.rs index 385fd25fae..3e3d51f8d6 100644 --- a/crates/ui-egui/src/panels/align/tests.rs +++ b/crates/ui-egui/src/panels/align/tests.rs @@ -672,6 +672,32 @@ fn anchor_points_of_one_path_align_to_each_other() { assert_eq!(xs(&app), [0.0, 0.0, 150.0], "to the artboard's left edge: {}", app.ui.status); } +/// #1636: with a key anchor, the Align panel shows the key choice chosen and named Align to Key +/// Anchor, its align button moves the other points to the key anchor, which stays, and Align to +/// Selection lets go of it. +#[test] +fn a_key_anchor_is_chosen_named_and_aligned_to() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("path.create", json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap(); + let xs = |app: &VectorcraftApp| -> Vec { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + node.path_data().unwrap().anchors().map(|(_, _, a)| a.p.x).collect() + }; + app.run("select.anchors", json!({"id": id, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + app.run("select.anchors", json!({"id": id, "anchors": [[0, 2]], "mode": "toggle"})).unwrap(); + let ctx = egui::Context::default(); + let ([selection, key, _], left) = panel_buttons(&mut app, &ctx); + assert_eq!((align_to(&app), chosen_align_to(&mut app, &ctx)), (AlignTo::Key, vec![key]), "chosen automatically"); + let named = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&named, "Align to Key Anchor"), (Role::Button, Some(Toggled::True))); + click(&ctx, &mut app, left.center(), show); + assert_eq!(xs(&app), [150.0, 200.0, 150.0], "to the key anchor's x: {}", app.ui.status); + click(&ctx, &mut app, selection.center(), show); + assert!(!has_key(&app), "Align to Selection lets go of the key anchor"); + assert_eq!(app.ui.align_to, AlignTo::Selection); +} + /// #1636: the Distribute buttons work on the anchor points of one path (one object selected). #[test] fn distribute_buttons_space_the_anchor_points_of_one_path() { diff --git a/docs/mcp.md b/docs/mcp.md index 8ca4121032..ce22248575 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2706,6 +2706,15 @@ A click with the Selection tool on a guide selected with objects runs `select.ke The key guide goes when it leaves the selection or no object is left selected, and an object key replaces it (and the other way round). It returns `{key}`, the key object's id or null, with `guide`, the key guide's index, when a guide is the key. `document.inspect` (the `inspect_document` tool) reports the key object as `key`, null without one, and the key guide as `keyGuide`. + +`select.key {id, anchor: [subpath, anchor]}` makes an anchor point selected with Direct Selection the key anchor, while at least one other point (or a path selected whole) is selected too. +`select.anchors` with mode `add` or `toggle` and one point makes that point the key anchor when it selects it; a Shift-click on an anchor with Direct Selection runs it, and a plain click still selects that point alone. +Aligning to the key, `object.align` moves the other selected points to the key anchor's x or y and the key anchor stays put; a pixel-aligned path is then snapped to the pixel grid, as after any other edit. +`object.distribute {spacing}` and `object.distributeSpacing {spacing}` keep the key anchor in place and put the other points `spacing` apart, outward from it, in their order along the axis. +The key anchor goes when it is deselected or deleted, when it is the only point left selected, when `select.key {}` cancels it, and when a key object or key guide replaces it. +An anchor that is not a selected anchor point (a selected segment's end is not one) is an error, and the key stays as it was. +The result has `keyAnchor: {id, anchor}` with a key anchor, and `document.inspect` reports it as `keyAnchor`. +The canvas draws the key anchor as a larger filled square in a frame, and the Align panel and the Control bar then name the Align To choice Align to Key Anchor. The `ui.pointer` and `pointer_gesture` replies list it as `key` beside `selection`. The command palette lists `select.key` as Cancel Key Object and runs it without an id. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 082e565bd2..9a0559230d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -203,7 +203,7 @@ tools, never checked side by side) isn't captured row by row. | Deleting segments or anchors that disconnect an ordinary path creates independent selected objects | done | #3380, deletion only: first piece retains its ID, styles and transforms carry over in sibling order, bounds-fitted gradients keep their original placement, and one Undo restores the original. Existing multi-subpath paths and compound members stay intact. `delete_path_pieces` covers transformed/styled rectangles, cubic handles and Undo/Redo. Endpoint highlighting remains open. | | Click/Shift-click anchors; marquee toggles with Shift | done | #483 | | Align selected anchors to their bounds or the artboard; repeating an alignment that moves nothing keeps Undo, Redo and saved state | done | #1180 follow-up; engine regressions include rounding-only moves and unchanged handles; #1641 (fixes #1636): Align and Distribute act on the anchors selected with Direct Selection, the points of one path aligning to each other and Distribute spacing them | -| Align selected anchors to a key anchor | missing | #1636 | +| Align selected anchors to a key anchor | done | #1636; a Shift-click with Direct Selection makes the point it adds the key anchor, drawn as a larger filled square in a frame, and Align To shows Align to Key Anchor; Align moves the other points to it and Distribute with a spacing places them from it; `select.key {id, anchor}` | | A ruler guide as the key object: a click on a guide selected with objects (or Align to Key Object with one object and a guide) makes it the key, drawn thicker; Align lines the objects (or selected anchors) up on it and the guide stays put | done | #2143; `select.key {guide}`; a vertical guide sets x only, a horizontal one y | | Distribute Objects and Distribute Spacing with a spacing from a key guide: the guide stays put and the other items are spaced the Distribute Spacing value from it, as from a key object with no size | done | Key guide from #3165; a vertical guide spaces the horizontal options, a horizontal one the vertical ones, and a guide along the axis leaves everything | | Distribute ruler guides: the Distribute Objects and Distribute Spacing buttons space the selected guides across the axis with the selected objects, as items with no size; two or more guides selected alone are enough | done | #1597 in part; guides alone: select them, choose Align to Artboard and click Horizontal (Vertical) Distribute Center for equal columns (rows) ([public forum answer, 2017](https://community.adobe.com/questions-652/illustrator-equal-guides-822924)); with objects: a guide on each artboard edge, included in the selection, keeps the outermost lines there ([public forum answer, 2025](https://www.signs101.com/threads/is-there-a-way-to-distribute-shapes-like-this-in-illustrator.179556/)); a guide has no width, so the three options of an axis are the same for it; guides along the axis stay | From 18fe65acd6815197a1bdbbdc1f531191e53b384d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:16:08 -0800 Subject: [PATCH 15/24] Type: composite fonts take custom character sets Type > Composite Fonts... has Custom: named character sets, each setting the characters it lists in its own font, style, size, baseline and scales ahead of their class. The later of two sets that list a character sets it. Characters are added by Direct Entry or by Unicode code and deleted one at a time, and the parts table scrolls when it grows. - type.compositeFonts.set takes `custom`; part keys left out take the Kanji part's values. Sets are saved with the document and in .vccomposite files, and Export refuses a file over 4 MB. - VectorCraft's own limits: 64 sets per font, 8,192 characters per set, each character once per set, up to U+FFFF and no control characters. - The font database caches each font's character lookup. Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md. --- crates/doc/src/lib.rs | 2 +- crates/doc/src/mem.rs | 7 +- crates/doc/src/text.rs | 4 +- crates/doc/src/text/composite.rs | 273 +++++++++++++-- crates/engine/src/cmd/compositefonts.rs | 82 ++++- crates/engine/src/cmd/compositefonts/tests.rs | 124 +++++++ crates/render/src/tests/fontchange.rs | 8 +- crates/text/src/fontdb.rs | 3 + crates/text/src/fontdb/composite.rs | 119 +++++-- crates/text/src/fontdb/db.rs | 1 + crates/text/src/tests/composite.rs | 45 +++ crates/ui-egui/src/dialogs/composite_fonts.rs | 322 +++++++++++++++--- .../src/dialogs/composite_fonts/tests.rs | 87 +++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/gu.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/mcp.md | 27 +- docs/target-app-parity.md | 1 + docs/type-parity.md | 2 +- 44 files changed, 1023 insertions(+), 140 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b32a4485f4..7b7ef4fee7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -128,7 +128,7 @@ pub use text::{ RubyPosition, RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; -pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts}; +pub use text::{CharClass, CompositeClass, CompositeFont, CompositePart, CompositeParts, CustomCharSet}; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; pub use vectorcraft_color as color; diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 7ff6cdeea2..464e1b3f65 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -105,8 +105,13 @@ impl Document { n += shared(&self.para_styles, &newer.para_styles, styles); n += shared(&self.composite_fonts, &newer.composite_fonts, |c| { let part = |p: &crate::CompositePart| p.font.capacity() + p.style.capacity(); + let custom = |f: &crate::CompositeFont| { + vec_bytes(&f.custom) + f.custom.iter().map(|s| s.name.capacity() + s.characters.capacity() + part(&s.part)).sum::() + }; vec_bytes(c) - + c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::()).sum::() + + c.iter() + .map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::() + custom(f)) + .sum::() }); n += shared(&self.saved_selections, &newer.saved_selections, |s| { vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::() diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 06b71f8fc8..d165a086fb 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -22,8 +22,8 @@ pub use char_style::{ WARICHU_LINES, WARICHU_SCALE, WarichuAlign, }; pub use composite::{ - COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart, - CompositeParts, + COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX, CharClass, + CompositeClass, CompositeClasses, CompositeFont, CompositePart, CompositeParts, CustomCharSet, }; pub use para_style::{ Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, KinsokuShoriType, LeadingModel, diff --git a/crates/doc/src/text/composite.rs b/crates/doc/src/text/composite.rs index e95428c2f5..93a7e27808 100644 --- a/crates/doc/src/text/composite.rs +++ b/crates/doc/src/text/composite.rs @@ -1,11 +1,16 @@ //! Type › Composite Fonts…: a font made of other fonts, one for each class of character (the //! Kanji font is the base; kana, punctuation, symbols, Roman letters and numerals each take a font -//! of their own, sized, raised and scaled against it). Type set in a composite font names it as its -//! family; the layout sets each character in its class's font. +//! of their own, sized, raised and scaled against it). Custom character sets give characters the +//! user picks a font of their own too, ahead of their class; of two sets that list a character, +//! the later one sets it. Type set in a composite font names it as its family; the layout sets each +//! character in its set's or its class's font. //! //! Behavior reference (public documentation): -//! . -//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own. +//! , +//! custom character sets: +//! . +//! Which characters fall in which class, the ranges, the limits and the defaults are VectorCraft's +//! own. use serde::{Deserialize, Serialize}; @@ -179,12 +184,76 @@ impl CompositeParts { } } +/// A custom character set of a composite font: characters chosen by the user, set in a part of +/// their own instead of their class's part. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CustomCharSet { + /// The set's name, unique in its composite font in any case. + pub name: String, + /// The set's characters, each once, in the order they were added. + #[serde(default)] + pub characters: String, + /// The font, style, size, baseline and scales its characters take. + #[serde(flatten)] + pub part: CompositePart, +} + +impl CustomCharSet { + /// An empty set named `name`, its characters set in `part`. + pub fn new(name: impl Into, part: CompositePart) -> Self { + Self { name: name.into(), characters: String::new(), part } + } + + /// Whether `c` can be in a custom character set: not a control character, and in the Basic + /// Multilingual Plane (U+0000 to U+FFFF). + pub fn takes(c: char) -> bool { + !c.is_control() && (c as u32) <= 0xFFFF + } + + /// The character a Unicode code names: hexadecimal digits, optionally after `U+` or `0x` + /// (`3042`, `U+3042`). `None` for anything else or a code [`Self::takes`] refuses. + pub fn char_from_code(code: &str) -> Option { + let code = code.trim(); + let hex = ["U+", "u+", "0x", "0X"].iter().find_map(|p| code.strip_prefix(p)).unwrap_or(code); + if hex.is_empty() || hex.len() > 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + u32::from_str_radix(hex, 16).ok().and_then(char::from_u32).filter(|&c| Self::takes(c)) + } +} + +/// Where a composite font sets a character: the part of one of its six classes, or the part of +/// one of its custom character sets (an index into [`CompositeFont::custom`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CompositeClass { + Fixed(CharClass), + Custom(usize), +} + +/// The custom character set each character of a composite font's sets falls in +/// ([`CompositeFont::classes`]): the last set that lists it. +#[derive(Clone, Debug, Default)] +pub struct CompositeClasses { + custom: std::collections::HashMap, +} + +impl CompositeClasses { + /// Where the composite font sets `c`: the last custom set that lists it, else its class. + pub fn of(&self, c: char) -> CompositeClass { + self.custom.get(&c).map_or(CompositeClass::Fixed(CharClass::of(c)), |&i| CompositeClass::Custom(i)) + } +} + /// A composite font (Type › Composite Fonts…), saved with the document. Its name is the family /// that type set in it names. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct CompositeFont { pub name: String, pub parts: CompositeParts, + /// Custom character sets, checked before the classes. A character two sets list is set in the + /// later set's part. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub custom: Vec, } /// The size a part can take, in percent of the type's size. @@ -197,11 +266,40 @@ pub const COMPOSITE_SCALE: std::ops::RangeInclusive = 10.0..=400.0; pub const COMPOSITE_NAME_MAX: usize = 255; /// The most composite fonts a document or a file keeps. pub const COMPOSITE_FONTS_MAX: usize = 256; +/// The most custom character sets a composite font keeps. +pub const CUSTOM_SETS_MAX: usize = 64; +/// The most characters a custom character set keeps. +pub const CUSTOM_CHARS_MAX: usize = 8192; impl CompositeFont { /// A composite font named `name` setting every class in `font` and `style`. pub fn new(name: impl Into, font: impl Into, style: impl Into) -> Self { - Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)) } + Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)), custom: Vec::new() } + } + + /// The part that sets the characters of `class`, `None` for a custom set the font doesn't have. + pub fn part(&self, class: CompositeClass) -> Option<&CompositePart> { + match class { + CompositeClass::Fixed(c) => Some(self.parts.get(c)), + CompositeClass::Custom(i) => self.custom.get(i).map(|s| &s.part), + } + } + + /// Where this font sets character `c`: the last custom set that lists it, else its class. For + /// many characters, [`Self::classes`] looks each up faster. + pub fn class_of(&self, c: char) -> CompositeClass { + self.custom.iter().rposition(|s| s.characters.contains(c)).map_or(CompositeClass::Fixed(CharClass::of(c)), CompositeClass::Custom) + } + + /// The custom set each character of this font's sets falls in, to look up many characters. + pub fn classes(&self) -> CompositeClasses { + let mut custom = std::collections::HashMap::new(); + for (i, s) in self.custom.iter().enumerate() { + for c in s.characters.chars() { + custom.insert(c, i); + } + } + CompositeClasses { custom } } /// Do `a` and `b` name the same composite font: equal in any case? @@ -223,36 +321,50 @@ impl CompositeFont { } /// Why this composite font can't be used, if it can't: a name [`Self::clean_name`] refuses, a - /// part without a font, a value out of its range, or a scale or Scale at Center on a class - /// that has none. + /// part without a font, a value out of its range, a scale or Scale at Center on a class that + /// has none, or a custom character set [`Self::check_custom`] refuses. pub fn check(&self) -> Result<(), String> { if Self::clean_name(&self.name).as_deref() != Some(self.name.as_str()) { return Err(format!("`{}` can't name a composite font", self.name)); } for class in CharClass::ALL { - let p = self.parts.get(class); - let k = class.key(); - if p.font.trim().is_empty() { - return Err(format!("{k}: give a font")); + check_part(self.parts.get(class), class.key(), class.scales(), class == CharClass::Kana)?; + } + self.check_custom() + } + + /// Why this font's custom character sets can't be used, if they can't: more than + /// [`CUSTOM_SETS_MAX`], a name [`Self::clean_name`] refuses or two sets share (in any case), a + /// character [`CustomCharSet::takes`] refuses or a set lists twice, more than + /// [`CUSTOM_CHARS_MAX`] characters in a set, or a part [`Self::check`] would refuse. A set's + /// characters can be scaled across and up; only kana are scaled at the center. + pub fn check_custom(&self) -> Result<(), String> { + if self.custom.len() > CUSTOM_SETS_MAX { + return Err(format!("a composite font keeps at most {CUSTOM_SETS_MAX} character sets")); + } + let mut names = std::collections::HashSet::new(); + for s in &self.custom { + let name = &s.name; + if Self::clean_name(name).as_deref() != Some(name.as_str()) { + return Err(format!("`{name}` can't name a character set")); } - let within = |v: f64, r: &std::ops::RangeInclusive| v.is_finite() && r.contains(&v); - if !within(p.size, &COMPOSITE_SIZE) { - return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end())); + if !names.insert(name.to_lowercase()) { + return Err(format!("two character sets are named `{name}`")); } - if !within(p.baseline, &COMPOSITE_BASELINE) { - return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end())); - } - for v in [p.h_scale, p.v_scale] { - if !within(v, &COMPOSITE_SCALE) { - return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end())); + let mut seen = std::collections::HashSet::new(); + for (n, c) in s.characters.chars().enumerate() { + if n == CUSTOM_CHARS_MAX { + return Err(format!("`{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters")); } - if v != 100.0 && !class.scales() { - return Err(format!("{k}: only kana, roman and numerals can be scaled")); + let code = c as u32; + if !CustomCharSet::takes(c) { + return Err(format!("`{name}`: U+{code:04X} is a control character or past U+FFFF")); + } + if !seen.insert(c) { + return Err(format!("`{name}`: U+{code:04X} is listed twice")); } } - if p.scale_at_center && class != CharClass::Kana { - return Err(format!("{k}: only kana can be scaled at the center")); - } + check_part(&s.part, name, true, false)?; } Ok(()) } @@ -264,9 +376,41 @@ impl CompositeFont { let mut names = std::collections::HashSet::new(); let mut kept: Vec = fonts.iter().filter(|f| f.check().is_ok() && names.insert(f.name.to_lowercase())).take(COMPOSITE_FONTS_MAX).cloned().collect(); - kept.retain(|f| CharClass::ALL.iter().all(|&c| !names.contains(&f.parts.get(c).font.to_lowercase()))); + kept.retain(|f| f.part_fonts().all(|font| !names.contains(&font.to_lowercase()))); (kept.len() != fonts.len()).then_some(kept) } + + /// The fonts of the parts, each class's then each custom set's. + pub fn part_fonts(&self) -> impl Iterator { + CharClass::ALL.into_iter().map(|c| self.parts.get(c).font.as_str()).chain(self.custom.iter().map(|s| s.part.font.as_str())) + } +} + +/// Why part `p` (of the class or set named `k`) can't be used, if it can't: no font, a value out +/// of its range, a scale when `scales` is false, or Scale at Center when `center` is false. +fn check_part(p: &CompositePart, k: &str, scales: bool, center: bool) -> Result<(), String> { + if p.font.trim().is_empty() { + return Err(format!("{k}: give a font")); + } + let within = |v: f64, r: &std::ops::RangeInclusive| v.is_finite() && r.contains(&v); + if !within(p.size, &COMPOSITE_SIZE) { + return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end())); + } + if !within(p.baseline, &COMPOSITE_BASELINE) { + return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end())); + } + for v in [p.h_scale, p.v_scale] { + if !within(v, &COMPOSITE_SCALE) { + return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end())); + } + if v != 100.0 && !scales { + return Err(format!("{k}: only kana, roman and numerals can be scaled")); + } + } + if p.scale_at_center && !center { + return Err(format!("{k}: only kana can be scaled at the center")); + } + Ok(()) } #[cfg(test)] @@ -341,4 +485,81 @@ mod tests { spaced.name = " Mixed".into(); assert!(spaced.check().is_err()); } + + /// A character a custom set lists falls in that set ahead of its class, the later of two sets + /// that list it wins, and the quick lookup agrees with the one-character one. + #[test] + fn custom_sets_take_their_characters_before_the_classes() { + let mut f = CompositeFont::new("Mixed", "Inter", "Regular"); + let mut a = CustomCharSet::new("Marks", CompositePart::new("Source Sans 3", "Bold")); + a.characters = "※A".into(); + let mut b = CustomCharSet::new("Letters", CompositePart::new("Inter", "Italic")); + b.characters = "AB".into(); + f.custom = vec![a, b]; + let classes = f.classes(); + for (c, want) in [ + ('※', CompositeClass::Custom(0)), + ('A', CompositeClass::Custom(1)), + ('B', CompositeClass::Custom(1)), + ('C', CompositeClass::Fixed(CharClass::Roman)), + ('漢', CompositeClass::Fixed(CharClass::Kanji)), + ] { + assert_eq!(f.class_of(c), want, "{c:?}"); + assert_eq!(classes.of(c), want, "{c:?}"); + } + assert_eq!(f.part(CompositeClass::Custom(0)).map(|p| p.style.as_str()), Some("Bold")); + assert_eq!(f.part(CompositeClass::Custom(2)), None); + assert_eq!(f.check(), Ok(())); + // Read and written with the part's fields beside the set's name and characters. + let json = serde_json::to_value(&f).unwrap(); + assert_eq!(json["custom"][1]["characters"], "AB"); + assert_eq!(json["custom"][1]["style"], "Italic"); + assert_eq!(serde_json::from_value::(json).unwrap(), f); + // A font without sets is written as before. + assert!(serde_json::to_value(CompositeFont::new("Plain", "Inter", "Regular")).unwrap().get("custom").is_none()); + } + + /// Set names, characters and limits are checked; sets scale like Roman letters. + #[test] + fn custom_sets_check_their_names_and_characters() { + let ok = CompositeFont::new("Mixed", "Inter", "Regular"); + let with = |sets: Vec<(&str, &str)>, edit: fn(&mut CompositePart)| { + let mut f = ok.clone(); + for (name, chars) in sets { + let mut s = CustomCharSet::new(name, CompositePart::new("Inter", "Regular")); + s.characters = chars.into(); + edit(&mut s.part); + f.custom.push(s); + } + f.check() + }; + let none = |_: &mut CompositePart| {}; + assert_eq!(with(vec![("Empty", "")], none), Ok(())); + assert_eq!(with(vec![("Wide", "AB")], |p| p.h_scale = 80.0), Ok(())); + assert!(with(vec![("Wide", "AB")], |p| p.scale_at_center = true).is_err()); + assert!(with(vec![("Big", "A")], |p| p.size = 401.0).is_err()); + assert!(with(vec![("Same", "A"), ("same", "B")], none).unwrap_err().contains("two character sets")); + assert!(with(vec![(" Pad", "A")], none).is_err()); + assert!(with(vec![("Twice", "ABA")], none).unwrap_err().contains("U+0041 is listed twice")); + assert!(with(vec![("Tab", "A\t")], none).unwrap_err().contains("U+0009")); + assert!(with(vec![("Astral", "𠀋")], none).unwrap_err().contains("U+2000B")); + let many: String = (0x4E00u32..).filter_map(char::from_u32).take(CUSTOM_CHARS_MAX + 1).collect(); + let mut f = ok.clone(); + f.custom.push(CustomCharSet { name: "Many".into(), characters: many, part: CompositePart::new("Inter", "Regular") }); + assert!(f.check().unwrap_err().contains("at most")); + let mut f = ok.clone(); + f.custom = (0..=CUSTOM_SETS_MAX).map(|i| CustomCharSet::new(format!("S{i}"), CompositePart::new("Inter", "Regular"))).collect(); + assert!(f.check().is_err()); + } + + /// Codes are hexadecimal, with or without `U+` or `0x`, inside the Basic Multilingual Plane. + #[test] + fn custom_set_codes_name_characters() { + assert_eq!(CustomCharSet::char_from_code("3042"), Some('あ')); + assert_eq!(CustomCharSet::char_from_code(" U+203B "), Some('※')); + assert_eq!(CustomCharSet::char_from_code("0x41"), Some('A')); + for bad in ["", "U+", "zz", "20000B", "D800", "0009", "1F600", "1234567", "+41"] { + assert_eq!(CustomCharSet::char_from_code(bad), None, "{bad:?}"); + } + } } diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs index 3925612a14..131695e33e 100644 --- a/crates/engine/src/cmd/compositefonts.rs +++ b/crates/engine/src/cmd/compositefonts.rs @@ -1,15 +1,18 @@ //! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]), //! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a -//! font of its own. Type names a composite font as its family, and the font menus list them first. -//! The font database holds the active document's composite fonts ([`sync`]); a file exported here -//! carries them to another document. +//! font of its own, and the characters of each custom character set in the set's font. Type names +//! a composite font as its family, and the font menus list them first. The font database holds the +//! active document's composite fonts ([`sync`]); a file exported here carries them to another +//! document. //! //! Behavior reference (public documentation): -//! . +//! , +//! custom character sets: +//! . use serde_json::{Map, Value, json}; -use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX}; -use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts}; +use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts, CustomCharSet}; use vectorcraft_text::FontDb; use super::fileio::{read_file_head, write_file}; @@ -26,6 +29,10 @@ const MAX_FILE_BYTES: usize = 4 << 20; /// The keys a part may give. const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"]; +/// The keys a custom character set may give: its name and characters, then its part's values +/// (a set is never scaled at the center). +const SET_KEYS: [&str; 8] = ["name", "characters", "font", "style", "size", "baseline", "hScale", "vScale"]; + pub fn specs() -> Vec { vec![ cmd!( @@ -33,7 +40,7 @@ pub fn specs() -> Vec { "Composite Fonts", [], None, - "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}", + "{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}, custom?: [{name, characters, font, style, size, baseline, hScale, vScale}]}]}", has_doc, list ), @@ -42,7 +49,7 @@ pub fn specs() -> Vec { "Composite Fonts…", ["Type"], None, - "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", + "{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}, custom?: [{name, characters?: string = \"\", font?, style?, size?, baseline?, hScale?, vScale?}]}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. custom lists custom character sets: each sets the characters it lists in its own font, style, size, baseline and scales (values left out are the kanji part's), ahead of their class; a character two sets list is set by the later one. A set's name is trimmed and unique in the font in any case; its characters are each listed once, at most 8192, none a control character or past U+FFFF; at most 64 sets. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}", has_doc, set ), @@ -51,7 +58,7 @@ pub fn specs() -> Vec { "Export Composite Fonts", [], None, - "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}", + "{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads (a file over 4 MB is refused); without path the file's text comes back as data → {path, count} | {data, count}", has_doc, export ), @@ -121,8 +128,8 @@ fn list(s: &mut Session, _: &Value) -> Result { /// copy of the Kanji part, and keys or values a part can't take refused. fn parse_font(v: &Value, c: &str) -> Result { let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?; - if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) { - return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts"))); + if let Some(k) = o.keys().find(|k| !["name", "parts", "custom"].contains(&k.as_str())) { + return Err(bad(c, format!("unknown key `{k}`; a font takes name, parts and custom"))); } let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?; let name = CompositeFont::clean_name(raw) @@ -140,7 +147,17 @@ fn parse_font(v: &Value, c: &str) -> Result { serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key()))) }; let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?; - let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) }; + let custom = match o.get("custom") { + None => Vec::new(), + Some(Value::Array(sets)) => { + if sets.len() > CUSTOM_SETS_MAX { + return Err(bad(c, format!("`{name}`: a composite font keeps at most {CUSTOM_SETS_MAX} character sets"))); + } + sets.iter().map(|s| parse_set(s, &name, &kanji, c)).collect::>>()? + } + Some(_) => return Err(bad(c, format!("`{name}`: custom must be a list of character sets"))), + }; + let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji), custom }; for class in CharClass::ALL.into_iter().skip(1) { if let Some(p) = part(class)? { *f.parts.get_mut(class) = p; @@ -150,6 +167,38 @@ fn parse_font(v: &Value, c: &str) -> Result { Ok(f) } +/// Custom character set `v` of composite font `font` from command parameters or a file: its name +/// trimmed, the part values it leaves out those of `kanji`, and keys or values it can't take +/// refused. [`CompositeFont::check`] checks its characters and values. +fn parse_set(v: &Value, font: &str, kanji: &CompositePart, c: &str) -> Result { + let o = v.as_object().ok_or_else(|| bad(c, format!("`{font}`: each character set must be {{name, characters, font…}}")))?; + if let Some(k) = o.keys().find(|k| !SET_KEYS.contains(&k.as_str())) { + return Err(bad(c, format!("`{font}`: unknown key `{k}` in a character set"))); + } + let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, format!("`{font}`: each character set needs a name")))?; + let name = CompositeFont::clean_name(raw) + .ok_or_else(|| bad(c, format!("`{raw}` can't name a character set (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?; + let characters = match o.get("characters") { + None => String::new(), + Some(Value::String(s)) => s.clone(), + Some(_) => return Err(bad(c, format!("`{font}`: `{name}`: characters must be a string"))), + }; + // Checked here as well as by `check`, so a huge list is refused before it is copied again. + if characters.chars().nth(CUSTOM_CHARS_MAX).is_some() { + return Err(bad(c, format!("`{font}`: `{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters"))); + } + let mut part = serde_json::to_value(kanji).map_err(|e| bad(c, e.to_string()))?; + if let Some(p) = part.as_object_mut() { + for k in &SET_KEYS[2..] { + if let Some(given) = o.get(*k) { + p.insert(k.to_string(), given.clone()); + } + } + } + let part: CompositePart = serde_json::from_value(part).map_err(|e| bad(c, format!("`{font}`: `{name}`: {e}")))?; + Ok(CustomCharSet { name, characters, part }) +} + /// Error unless `fonts` can stand together in a document: names unique in any case, none naming /// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`]. fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { @@ -167,10 +216,10 @@ fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> { } } for f in fonts { - for class in CharClass::ALL { - let font = &f.parts.get(class).font; + let classes = CharClass::ALL.into_iter().map(|class| (class.key(), &f.parts.get(class).font)); + for (part, font) in classes.chain(f.custom.iter().map(|s| (s.name.as_str(), &s.part.font))) { if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() { - return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key()))); + return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, part))); } } } @@ -260,6 +309,9 @@ fn export(s: &mut Session, p: &Value) -> Result { let mut file = Map::new(); file.insert("compositeFonts".into(), json!(fonts)); let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?; + if text.len() > MAX_FILE_BYTES { + return Err(bad(C, "the composite fonts make a file larger than 4 MB, which Import can't read; export fewer of them")); + } match path { Some(path) => { write_file(path, text.as_bytes())?; diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index da310a89b9..751cb49ef0 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -856,3 +856,127 @@ fn composite_fonts_export_and_import() { assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +/// `font(name)` with two custom character sets: "Marks" (x and A) in Inter at 150%, and "A only" +/// (A) in Source Sans 3, which sets A since it comes later. +fn font_with_sets(name: &str) -> Value { + let mut v = font(name); + v["custom"] = json!([ + {"name": " Marks ", "characters": "xA", "font": "Inter", "size": 150, "hScale": 90}, + {"name": "A only", "characters": "A"}, + ]); + v +} + +/// Custom character sets are set with the font as one undo step, take values they leave out from +/// the Kanji part, set the characters they list ahead of their class (the later set winning), are +/// saved with the document, and measure type again when they change. +#[test] +fn custom_character_sets_set_their_characters() { + let mut s = session(); + let before = steps(&s); + let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets")]})).unwrap(); + assert_eq!(r["fonts"][0]["custom"][0]["name"], "Marks"); + assert_eq!(steps(&s), before + 1); + let f = s.doc().unwrap().doc.composite_fonts[0].clone(); + assert_eq!(f.custom.len(), 2); + assert_eq!((f.custom[0].part.font.as_str(), f.custom[0].part.size, f.custom[0].part.h_scale), ("Inter", 150.0, 90.0)); + assert_eq!((f.custom[1].part.font.as_str(), f.custom[1].part.size), ("Source Sans 3", 100.0)); + // The same sets again change nothing. + s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets")]})).unwrap(); + assert_eq!(steps(&s), before + 1); + let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap(); + assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "xAB", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Sets"})).unwrap(); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str| db.face(family, "Regular").unwrap().id(); + let lay = vectorcraft_text::layout(db, &text(&s, id)); + let fonts: Vec = lay.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(fonts, [face("Inter"), face("Source Sans 3"), face("JetBrains Mono")]); + // Taking x out of Marks sets it in its class's font again, measured in the same step. + let wide = text(&s, id).cached_bounds.unwrap().width(); + let mut fewer = font_with_sets("CF Sets"); + fewer["custom"][0]["characters"] = json!("A"); + s.execute("type.compositeFonts.set", &json!({"fonts": [fewer]})).unwrap(); + assert!(text(&s, id).cached_bounds.unwrap().width() < wide - 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6); +} + +/// Bad custom character sets are refused with an error and no undo step. +#[test] +fn bad_custom_character_sets_are_refused() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets Base")]})).unwrap(); + let before = steps(&s); + let with = |f: &dyn Fn(&mut Value)| { + let mut v = font_with_sets("CF Sets Refused"); + f(&mut v); + json!({"fonts": [v]}) + }; + let many: String = (0x4E00u32..).filter_map(char::from_u32).take(8193).collect(); + let sets: Vec = (0..65).map(|i| json!({"name": format!("S{i}")})).collect(); + for bad in [ + with(&|v| v["custom"] = json!("Marks")), + with(&|v| v["custom"] = Value::Null), + with(&|v| v["custom"][0]["characters"] = Value::Null), + with(&|v| v["custom"][0] = json!("Marks")), + with(&|v| v["custom"][0]["name"] = json!(" ")), + with(&|v| { + v["custom"][0].as_object_mut().unwrap().remove("name"); + }), + with(&|v| v["custom"][1]["name"] = json!("marks")), + with(&|v| v["custom"][0]["characters"] = json!(["※"])), + with(&|v| v["custom"][0]["characters"] = json!("※※")), + with(&|v| v["custom"][0]["characters"] = json!("A\n")), + with(&|v| v["custom"][0]["characters"] = json!("😀")), + with(&|v| v["custom"][0]["characters"] = json!(many.clone())), + with(&|v| v["custom"] = json!(sets.clone())), + with(&|v| v["custom"][0]["size"] = json!(5)), + with(&|v| v["custom"][0]["size"] = json!("50")), + with(&|v| v["custom"][0]["scaleAtCenter"] = json!(true)), + with(&|v| v["custom"][0]["scaleAtCenter"] = json!(false)), + with(&|v| v["custom"][0]["font"] = json!("")), + with(&|v| v["custom"][0]["font"] = json!("CF Sets Base")), + with(&|v| v["custom"][0]["width"] = json!(3)), + ] { + let e = s.execute("type.compositeFonts.set", &bad); + assert!(e.is_err(), "accepted {bad}"); + assert_eq!(names(&s), ["CF Sets Base"]); + assert_eq!(steps(&s), before); + } + // A set without characters is kept: it sets nothing. + let ok = with(&|v| v["custom"][0]["characters"] = json!("")); + s.execute("type.compositeFonts.set", &ok).unwrap(); + assert_eq!(s.doc().unwrap().doc.composite_fonts[0].custom[0].characters, ""); +} + +/// Export writes the custom character sets and Import reads them back; a file whose set is bad is +/// refused. +#[test] +fn custom_character_sets_export_and_import() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font_with_sets("CF Sets IO")]})).unwrap(); + let data = s.execute("type.compositeFonts.export", &json!({})).unwrap(); + let mut t = session(); + t.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap(); + assert_eq!(*t.doc().unwrap().doc.composite_fonts, *s.doc().unwrap().doc.composite_fonts); + let text = data["data"].as_str().unwrap().replace("\"A only\"", "\"marks\""); + let mut u = session(); + assert!(u.execute("type.compositeFonts.import", &json!({"data": text})).is_err()); + assert!(names(&u).is_empty()); + // Fonts whose file would be larger than Import reads are refused. + let full: String = (0x4E00u32..).filter_map(char::from_u32).take(8192).collect(); + let sets: Vec = (0..64).map(|i| json!({"name": format!("S{i}"), "characters": full})).collect(); + let big: Vec = (0..3) + .map(|i| { + let mut f = font(&format!("CF Sets Big {i}")); + f["custom"] = json!(sets); + f + }) + .collect(); + let e = s.execute("type.compositeFonts.export", &json!({"fonts": big})).unwrap_err(); + assert!(e.to_string().contains("4 MB"), "{e}"); +} diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index d2d228154f..9174696d20 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -145,7 +145,7 @@ fn composite_font_parts_are_not_highlighted_as_substituted_glyphs() { let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new("JetBrains Mono", "Regular"); parts.numerals = CompositePart::new("Inter", "Regular"); - db.set_composite_font(&CompositeFont { name: "CF Highlight".into(), parts }); + db.set_composite_font(&CompositeFont { name: "CF Highlight".into(), parts, custom: Vec::new() }); let style = CharStyle { size: 40.0, font_family: "CF Highlight".into(), ..Default::default() }; let t = TextObject::point(Point::new(10.0, 60.0), "Ab12", style); let g = text_geom(&t); @@ -175,7 +175,7 @@ fn a_documents_own_composite_font_draws_without_being_registered() { let (mut composite, id) = typed(NAME); let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new("JetBrains Mono", "Regular"); - composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts }); + composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts, custom: Vec::new() }); let (plain, plain_id) = typed("JetBrains Mono"); assert!(db.composite_font(NAME).is_none()); let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; @@ -236,7 +236,7 @@ fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() { let definition = |roman: &str| { let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new(roman, "Regular"); - CompositeFont { name: NAME.into(), parts } + CompositeFont { name: NAME.into(), parts, custom: Vec::new() } }; let typed = |family: &str| { let mut d = Document::new(300.0, 150.0); @@ -319,7 +319,7 @@ fn a_document_draws_type_in_another_documents_composite_font_as_missing() { }; let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new("JetBrains Mono", "Regular"); - db.set_composite_font(&CompositeFont { name: NAME.into(), parts }); + db.set_composite_font(&CompositeFont { name: NAME.into(), parts, custom: Vec::new() }); let (drawn, as_missing, in_front) = (draw(&back, id), draw(&missing, missing_id), draw(&plain, plain_id)); db.remove_composite_font(NAME); assert!(drawn.0 != in_front.0, "not in the front document's definition"); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index f315f2bb69..e96e1d0fae 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -119,6 +119,9 @@ pub struct FontDb { /// type naming one as its family is set in its parts' fonts. A thread's guards can lend others /// in their place ([`FontDb::lend_composite_fonts`]). composites: RwLock>>, + /// The lookups of the custom character sets of the composite fonts held or lent, each built + /// when first used. + custom_classes: Mutex, } /// Where a database's system font scan looks: folders (read with their subfolders) and font files. diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index 79a608788b..7b17fb74de 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -1,15 +1,18 @@ //! Composite fonts (Type › Composite Fonts…) in the font database: each is listed first in the //! font menus and resolves to its base (Kanji) font, and layout sets each character of type in one -//! in the font of the character's class ([`FontDb::composite_split`]). +//! in the font of the custom character set that lists it, else of its class +//! ([`FontDb::composite_split`]). use std::cell::RefCell; use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; use std::marker::PhantomData; +use std::sync::Weak; use std::sync::atomic::{AtomicU64, Ordering}; use unicode_segmentation::UnicodeSegmentation; -use vectorcraft_doc::{CharClass, CompositeFont, TextObject, TextRun}; +use vectorcraft_doc::text::CompositeClasses; +use vectorcraft_doc::{CharClass, CompositeClass, CompositeFont, CompositePart, TextObject, TextRun}; use super::face::FALLBACK_FAMILY; use super::{Arc, FontDb, FontFace, FontMatch}; @@ -17,6 +20,13 @@ use super::{Arc, FontDb, FontFace, FontMatch}; /// A face [`FontDb::resolve`] found and how it matched. type Resolved = Option<(Arc, FontMatch)>; +/// The base class: type without characters and anything no part names takes its part. +const KANJI: CompositeClass = CompositeClass::Fixed(CharClass::Kanji); + +/// Lookups of the custom character sets of composite fonts, by the address of the font's `Arc`, +/// each with a weak reference that tells when that font has gone and the address may be reused. +pub(super) type CustomClasses = HashMap, Arc)>; + thread_local! { /// The composite fonts the guards of this thread lend ([`FontDb::lend_composite_fonts`]), the /// latest loan last. Only the latest loan of a database is looked in. @@ -41,15 +51,25 @@ struct Loan { print: u64, } -/// Feed composite font `f`'s name and parts to `h`. +/// Feed composite font `f`'s name, parts and custom character sets to `h`. fn hash_font(f: &CompositeFont, h: &mut DefaultHasher) { f.name.hash(h); let p = &f.parts; for part in [&p.kanji, &p.kana, &p.punctuation, &p.symbols, &p.roman, &p.numerals] { - (&part.font, &part.style, part.scale_at_center).hash(h); - for v in [part.size, part.baseline, part.h_scale, part.v_scale] { - v.to_bits().hash(h); - } + hash_part(part, h); + } + f.custom.len().hash(h); + for s in &f.custom { + (&s.name, &s.characters).hash(h); + hash_part(&s.part, h); + } +} + +/// Feed part `part`'s font and values to `h`. +fn hash_part(part: &CompositePart, h: &mut DefaultHasher) { + (&part.font, &part.style, part.scale_at_center).hash(h); + for v in [part.size, part.baseline, part.h_scale, part.v_scale] { + v.to_bits().hash(h); } } @@ -136,8 +156,8 @@ impl FontDb { .unwrap_or(g) } - /// The face composite font `name` sets character `c` in (its class's part's), and whether - /// that part's font is missing. `None` when `name` is no composite font. + /// The face composite font `name` sets character `c` in (its custom set's or its class's + /// part's), and whether that part's font is missing. `None` when `name` is no composite font. pub fn composite_face(&self, name: &str, c: char) -> Option<(bool, u32)> { self.composite_resolve(name, c).map(|(face, m)| (m == FontMatch::Missing, face.id())) } @@ -149,30 +169,58 @@ impl FontDb { self.composite_resolve(name, first) } - /// The face composite font `name` sets character `c` in (its class's part's), and how that - /// part's font matched ([`FontDb::resolve`]). `None` when `name` is no composite font, or no - /// font at all is loaded. + /// The face composite font `name` sets character `c` in (its custom set's or its class's + /// part's), and how that part's font matched ([`FontDb::resolve`]). `None` when `name` is no + /// composite font, or no font at all is loaded. pub fn composite_resolve(&self, name: &str, c: char) -> Option<(Arc, FontMatch)> { let f = self.composite_font(name)?; - let (family, style) = self.part_font(&f, CharClass::of(c)); + let (family, style) = self.part_font(&f, self.composite_class(&f, c)); self.resolve(&family, &style) } + /// Where composite font `f` sets character `c`: the last custom set that lists it, else its + /// class. The lookup of `f`'s sets is built once and kept while `f` lives. + fn composite_class(&self, f: &Arc, c: char) -> CompositeClass { + if f.custom.is_empty() { + return CompositeClass::Fixed(CharClass::of(c)); + } + self.custom_lookup(f).of(c) + } + + /// The lookup of composite font `f`'s custom character sets, built when first asked for. + fn custom_lookup(&self, f: &Arc) -> Arc { + if f.custom.is_empty() { + return Arc::default(); + } + let key = Arc::as_ptr(f) as usize; + let mut cache = self.custom_classes.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((font, lookup)) = cache.get(&key) + && font.upgrade().is_some_and(|g| Arc::ptr_eq(&g, f)) + { + return lookup.clone(); + } + cache.retain(|_, (font, _)| font.strong_count() > 0); + let lookup = Arc::new(f.classes()); + cache.insert(key, (Arc::downgrade(f), lookup.clone())); + lookup + } + /// The fonts (family, style) composite font `name` sets `text` in: those of the parts its /// grapheme clusters fall in (each by its first character, as layout splits them), each once, /// in the order first used; the Kanji part's for text without characters. `None` when `name` is /// no composite font. pub fn composite_fonts_for(&self, name: &str, text: &str) -> Option> { let f = self.composite_font(name)?; - let mut classes: Vec = Vec::new(); + let lookup = self.custom_lookup(&f); + let mut classes: Vec = Vec::new(); for g in text.graphemes(true) { - let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let c = g.chars().next().map_or(KANJI, |c| lookup.of(c)); if !classes.contains(&c) { classes.push(c); } } if classes.is_empty() { - classes.push(CharClass::Kanji); + classes.push(KANJI); } let mut fonts: Vec<(String, String)> = Vec::with_capacity(classes.len()); for c in classes { @@ -196,8 +244,9 @@ impl FontDb { FontMatch::Style => 1, FontMatch::Missing => 2, }; - let mut faces: Vec<(CharClass, Resolved)> = Vec::new(); - let mut face_of = |class: CharClass| { + let lookup = self.custom_lookup(&f); + let mut faces: Vec<(CompositeClass, Resolved)> = Vec::new(); + let mut face_of = |class: CompositeClass| { if let Some((_, found)) = faces.iter().find(|(c, _)| *c == class) { return found.clone(); } @@ -213,7 +262,7 @@ impl FontDb { graphemes.push(""); } for g in graphemes { - let class = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let class = g.chars().next().map_or(KANJI, |c| lookup.of(c)); let found = face_of(class); let m = found.as_ref().map_or(FontMatch::Missing, |(_, m)| *m); if worst.as_ref().is_none_or(|(w, _)| rank(m) > rank(*w)) { @@ -307,10 +356,11 @@ impl FontDb { LentCompositeFonts { number: pushed.then_some(number), thread: PhantomData } } - /// The family and style composite font `f` sets characters of `class` in: its part's, or the - /// fallback font's when that part names a composite font. - fn part_font(&self, f: &CompositeFont, class: CharClass) -> (String, String) { - let p = f.parts.get(class); + /// The family and style composite font `f` sets characters of `class` in: its part's (the + /// Kanji part's for a custom set it doesn't have), or the fallback font's when that part names + /// a composite font. + fn part_font(&self, f: &CompositeFont, class: CompositeClass) -> (String, String) { + let p = f.part(class).unwrap_or(&f.parts.kanji); if self.composite_font(&p.font).is_some() { (FALLBACK_FAMILY.to_string(), "Regular".to_string()) } else { (p.font.clone(), p.style.clone()) } } @@ -326,11 +376,13 @@ impl FontDb { /// names a composite font too. `None` when `name` is no composite font. pub(super) fn composite_base(&self, name: &str) -> Option<(String, String)> { let f = self.composite_font(name)?; - Some(self.part_font(&f, CharClass::Kanji)) + Some(self.part_font(&f, KANJI)) } - /// `t` with each run set in a composite font split into runs of one [`CharClass`] each, set in - /// that class's font: its size and baseline shift from the part's size and baseline (in + /// `t` with each run set in a composite font split into runs of one custom character set or + /// [`CharClass`] each (a set listing a character takes it ahead of its class, the font's last + /// such set when several do), set in that set's or class's font: its size and baseline shift + /// from the part's size and baseline (in /// percent of the run's size), its scales multiplied by the part's, and Kana scaled at the /// center raised so the em box's center stays where it was. Each grapheme cluster goes with /// its first character. Also the index of the run of `t` each run came from. `None` when no run @@ -352,10 +404,11 @@ impl FontDb { from.push(i); continue; }; + let lookup = self.custom_lookup(font); let mut start = 0; let mut class = None; for (at, g) in r.text.grapheme_indices(true) { - let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let c = g.chars().next().map_or(KANJI, |c| lookup.of(c)); if let Some(k) = class && k != c { @@ -365,16 +418,16 @@ impl FontDb { } class = Some(c); } - runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(CharClass::Kanji))); + runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(KANJI))); from.push(i); } Some((TextObject { runs, ..t.clone() }, from)) } - /// Characters `text` of run `r` (set in composite font `font`), all of class `class`, as a run - /// set in that class's part. - fn composite_run(&self, text: &str, r: &TextRun, font: &CompositeFont, class: CharClass) -> TextRun { - let p = font.parts.get(class); + /// Characters `text` of run `r` (set in composite font `font`), all of class or set `class`, + /// as a run set in that class's or set's part. + fn composite_run(&self, text: &str, r: &TextRun, font: &CompositeFont, class: CompositeClass) -> TextRun { + let p = font.part(class).unwrap_or(&font.parts.kanji); let mut st = r.style.clone(); let base = st.size; let (family, style) = self.part_font(font, class); @@ -386,7 +439,7 @@ impl FontDb { st.baseline_shift += base * p.baseline / 100.0; st.h_scale *= p.h_scale / 100.0; st.v_scale *= p.v_scale / 100.0; - if class == CharClass::Kana && p.scale_at_center && p.v_scale != 100.0 { + if class == CompositeClass::Fixed(CharClass::Kana) && p.scale_at_center && p.v_scale != 100.0 { let centre = self.style_face(&st).map_or(0.38, |f| f.ideographic_centre()); st.baseline_shift += st.size * centre * (1.0 - p.v_scale / 100.0); } diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 2fda0e79a1..738a158952 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -74,6 +74,7 @@ impl FontDb { stamp: Mutex::new(None), instances: Mutex::new(Instances::default()), composites: RwLock::new(HashMap::new()), + custom_classes: Mutex::new(HashMap::new()), } } diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 0765b88aaf..b31b597ce7 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -55,6 +55,51 @@ fn composite_runs_split_by_class() { assert!(db.composite_split(&TextObject::point(Point::ZERO, "Ab", style("Inter", 12.0))).is_none()); } +/// A custom character set takes the characters it lists ahead of their class, the later of two +/// sets wins a character both list, and the set's part sizes, raises and scales them. The fonts +/// used for the text, how it is covered and the face of one character follow the sets too. +#[test] +fn custom_character_sets_take_their_characters_first() { + use vectorcraft_doc::{CompositePart, CustomCharSet}; + let db = FontDb::with_font_dirs(vec![]); + let mut f = mixed("Custom"); + let mut part = CompositePart::new("Inter", "Regular"); + part.size = 150.0; + part.baseline = -10.0; + part.h_scale = 90.0; + let mut first = CustomCharSet::new("Letters", CompositePart::new("Source Sans 3", "Regular")); + first.characters = "Bか".into(); + let mut second = CustomCharSet::new("B only", part); + second.characters = "B".into(); + f.custom = vec![first, second]; + db.set_composite_font(&f); + let t = TextObject::point(Point::ZERO, "ABか1", style("Custom", 20.0)); + let (split, from) = db.composite_split(&t).expect("split"); + let pieces: Vec<(&str, &str)> = split.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str())).collect(); + assert_eq!(pieces, [("A", "JetBrains Mono"), ("B", "Inter"), ("か", "Source Sans 3"), ("1", "Inter")]); + assert_eq!(from, [0, 0, 0, 0]); + let b = &split.runs[1].style; + assert_eq!((b.size, b.baseline_shift, b.h_scale, b.v_scale), (30.0, -2.0, 90.0, 100.0)); + // か is in a set, so the Kana part's Scale at Center does not apply to it. + let ka = &split.runs[2].style; + assert_eq!((ka.v_scale, ka.baseline_shift), (100.0, 0.0)); + assert_eq!(db.composite_face("Custom", 'B').map(|(_, id)| id), Some(face_id(&db, "Inter"))); + let fonts = db.composite_fonts_for("Custom", "BA").unwrap(); + assert_eq!(fonts, [("Inter".to_string(), "Regular".to_string()), ("JetBrains Mono".to_string(), "Regular".to_string())]); + let l = layout(&db, &t); + let ids: Vec = l.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(ids[1], face_id(&db, "Inter")); + // A changed set is a changed definition: lending it lays the type out again. + let mut changed = f.clone(); + changed.custom[1].characters = "A".into(); + let g0 = db.generation(); + let lent = db.lend_composite_fonts(&[changed]); + assert_ne!(db.generation(), g0); + let (split, _) = db.composite_split(&t).expect("split"); + assert_eq!(split.runs[0].style.font_family, "Inter"); + drop(lent); +} + /// Laid out, each character is drawn in its class's font, sized by its part, and the glyphs still /// name the run of the text they belong to. #[test] diff --git a/crates/ui-egui/src/dialogs/composite_fonts.rs b/crates/ui-egui/src/dialogs/composite_fonts.rs index cc71cc32da..31472503b2 100644 --- a/crates/ui-egui/src/dialogs/composite_fonts.rs +++ b/crates/ui-egui/src/dialogs/composite_fonts.rs @@ -1,19 +1,27 @@ //! Type › Composite Fonts… (with Show East Asian Options): the active document's composite fonts, //! the one picked in Font shown as a table with a row for each class of character (Kanji, Kana, -//! Punctuation, Symbols, Roman, Numerals) and its font, style, size, baseline and scales. New adds -//! a font based on the one picked, Delete Font takes it out, Import… adds the fonts of a file and -//! Export… writes the one picked to a file. Nothing changes in the document until OK, which runs -//! `type.compositeFonts.set` with every font as one undo step; Cancel leaves them as they were. +//! Punctuation, Symbols, Roman, Numerals), then a row for each custom character set, with its font, +//! style, size, baseline and scales. New adds a font based on the one picked, Delete Font takes it +//! out, Import… adds the fonts of a file and Export… writes the one picked to a file. Custom shows +//! the custom character sets of the font picked: New adds a set based on the one picked, Delete Set +//! takes it out, and Add puts the characters typed (Direct Entry) or the character of a Unicode code +//! into it; Delete takes the character picked out. Nothing changes in the document until OK, which +//! runs `type.compositeFonts.set` with every font as one undo step; Cancel leaves them as they were. //! //! Fields: `fonts` (the composite fonts as `type.compositeFonts.set` takes them), `selected` (the -//! index of the one picked) and `name` (its name as typed). +//! index of the one picked) and `name` (its name as typed); `custom` (whether the custom character +//! sets show), `set` (the index of the set picked), `setName` (its name as typed), `entryMode` +//! (`direct` or `unicode`), `entry` (the characters or code typed) and `char` (the index of the +//! character picked in the set). //! //! Behavior reference (public documentation): -//! . +//! , +//! custom character sets: +//! . use serde_json::{Value, json}; -use vectorcraft_doc::text::{COMPOSITE_BASELINE, COMPOSITE_SCALE, COMPOSITE_SIZE}; -use vectorcraft_doc::{CharClass, CompositeFont, CompositePart}; +use vectorcraft_doc::text::{COMPOSITE_BASELINE, COMPOSITE_SCALE, COMPOSITE_SIZE, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CustomCharSet}; use vectorcraft_engine::cmd::compositefonts::EXTS; use super::{DialogSpec, form}; @@ -27,12 +35,19 @@ pub const KIND: &str = "compositeFonts"; /// The command OK runs, and the menu item's. pub const COMMAND: &str = "type.compositeFonts.set"; +/// How many of a custom set's characters each row of its list shows. +const CHARS_PER_ROW: usize = 20; + +/// The tallest the table of parts grows before it scrolls, in points: the header, the six classes +/// and a few custom sets. +const TABLE_HEIGHT: f32 = 340.0; + pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Composite Fonts").into(), body, confirm, min_width: 760.0, ..DialogSpec::FORM }; /// Open Composite Fonts on the active document's composite fonts, the first one picked. pub fn open(app: &mut VectorcraftApp) -> Result { let fonts = app.session.active().ok_or("no document open")?.doc.composite_fonts.to_vec(); - let mut d = Dialog::new(KIND, json!({ "fonts": fonts, "selected": 0 })); + let mut d = Dialog::new(KIND, json!({ "fonts": fonts, "selected": 0, "entryMode": "direct" })); show_selected(&mut d); app.ui.dialog = Some(d); Ok(Value::Null) @@ -47,12 +62,17 @@ fn put_fonts(d: &mut Dialog, fonts: &[CompositeFont]) { d.fields.insert("fonts".into(), json!(fonts)); } -/// The index of the font picked, within the fonts (0 when there are none). -fn selected(d: &Dialog, count: usize) -> usize { - d.fields.get("selected").and_then(Value::as_u64).map_or(0, |i| i as usize).min(count.saturating_sub(1)) +/// The index field `key` holds, within `count` items (0 when there are none). +fn index(d: &Dialog, key: &str, count: usize) -> usize { + d.fields.get(key).and_then(Value::as_u64).map_or(0, |i| i as usize).min(count.saturating_sub(1)) } -/// Pick font `i`: its name goes into Name. +/// The index of the font picked, within the fonts (0 when there are none). +fn selected(d: &Dialog, count: usize) -> usize { + index(d, "selected", count) +} + +/// Pick font `i`: its name goes into Name, and its first custom set is picked. fn select(d: &mut Dialog, i: usize) { d.fields.insert("selected".into(), json!(i)); show_selected(d); @@ -62,6 +82,17 @@ fn show_selected(d: &mut Dialog) { let fonts = fonts(d); let name = fonts.get(selected(d, fonts.len())).map(|f| f.name.clone()).unwrap_or_default(); d.fields.insert("name".into(), json!(name)); + select_set(d, 0); +} + +/// Pick custom set `i` of the font picked: its name goes into the set's Name, and no character is +/// picked. +fn select_set(d: &mut Dialog, i: usize) { + d.fields.insert("set".into(), json!(i)); + d.fields.remove("char"); + let fonts = fonts(d); + let name = fonts.get(selected(d, fonts.len())).and_then(|f| f.custom.get(i)).map(|s| s.name.clone()).unwrap_or_default(); + d.fields.insert("setName".into(), json!(name)); } /// The name of a class's row. @@ -85,12 +116,25 @@ fn new_name(fonts: &[CompositeFont]) -> String { .unwrap_or_default() } +/// "Character Set N", the first N no set in `sets` is named. +fn new_set_name(sets: &[CustomCharSet]) -> String { + (1..).map(|n| format!("Character Set {n}")).find(|n| !sets.iter().any(|s| CompositeFont::same_name(&s.name, n))).unwrap_or_default() +} + /// What a button asked for, done once the table is drawn. enum Action { New, Delete, Import, Export, + /// Custom: show or hide the custom character sets. + Custom, + NewSet, + DeleteSet, + /// Add the characters typed, or the character of the code typed, to the set picked. + AddChars, + /// Take the character picked out of the set picked. + DeleteChar, } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { @@ -122,6 +166,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Export…"), 64.0)).inner.clicked() { act = Some(Action::Export); } + if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Custom"), 64.0)).inner.clicked() { + act = Some(Action::Custom); + } }); if let Some(k) = picked { select(d, k); @@ -136,6 +183,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { f.name = d.str("name"); ui.add_space(8.0); parts_table(app, ui, f); + if d.bool("custom") { + ui.add_space(8.0); + ui.separator(); + if let Some(a) = custom_sets(ui, d, f) { + act = Some(a); + } + } } put_fonts(d, &list); if let Some(a) = act { @@ -151,6 +205,80 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The custom character sets of `f`: Character Set with New and Delete Set, the set's Name, the +/// Character row that adds characters, and the set's characters with Delete. The button pressed. +fn custom_sets(ui: &mut egui::Ui, d: &mut Dialog, f: &mut CompositeFont) -> Option { + let mut act = None; + let k = index(d, "set", f.custom.len()); + let names: Vec<&str> = f.custom.iter().map(|s| s.name.as_str()).collect(); + let mut picked = None; + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Character Set:")); + if names.is_empty() { + widgets::dim_label(ui, tl!("None")); + } else if let Some(n) = widgets::dropdown_names(ui, "composite-fonts-set", names.get(k).copied().unwrap_or_default(), &names, 220.0) { + picked = Some(n); + } + ui.add_space(8.0); + ui.spacing_mut().item_spacing.x = 4.0; + if ui.add_enabled_ui(f.custom.len() < CUSTOM_SETS_MAX, |ui| widgets::flat_button(ui, tl!("New"), 48.0)).inner.clicked() { + act = Some(Action::NewSet); + } + if ui.add_enabled_ui(!f.custom.is_empty(), |ui| widgets::flat_button(ui, tl!("Delete Set"), 84.0)).inner.clicked() { + act = Some(Action::DeleteSet); + } + }); + if let Some(n) = picked { + select_set(d, n); + return None; + } + let Some(set) = f.custom.get_mut(k) else { return act }; + ui.add_space(6.0); + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Name:")); + form::text(ui, d, "setName", 260.0); + }); + set.name = d.str("setName"); + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Character:")); + let modes = [tl!("Direct Entry"), tl!("Unicode")]; + let unicode = d.str("entryMode") == "unicode"; + if let Some(m) = widgets::dropdown(ui, "composite-fonts-entry-mode", modes[usize::from(unicode)], &modes, 120.0) { + d.fields.insert("entryMode".into(), json!(if m == 1 { "unicode" } else { "direct" })); + } + form::text(ui, d, "entry", 160.0); + if widgets::flat_button(ui, tl!("Add"), 48.0).clicked() { + act = Some(Action::AddChars); + } + }); + ui.add_space(4.0); + let chosen = d.fields.get("char").and_then(Value::as_u64).map(|c| c as usize); + let mut pick_char = None; + // Only the rows in view are built, so a set of thousands of characters stays quick to draw. + let chars: Vec = set.characters.chars().collect(); + let rows = chars.len().div_ceil(CHARS_PER_ROW); + egui::ScrollArea::vertical().id_salt("composite-fonts-chars").max_height(120.0).show_rows(ui, 24.0, rows, |ui, range| { + for row in range { + ui.horizontal(|ui| { + for (n, c) in chars.iter().enumerate().skip(row * CHARS_PER_ROW).take(CHARS_PER_ROW) { + let r = ui.add(egui::Button::selectable(chosen == Some(n), c.to_string()).min_size(egui::vec2(24.0, 24.0))); + if r.on_hover_text(format!("U+{:04X}", *c as u32)).clicked() { + pick_char = Some(n); + } + } + }); + } + }); + if let Some(n) = pick_char { + d.fields.insert("char".into(), json!(n)); + } + let can_delete = chosen.is_some_and(|n| n < chars.len()); + if ui.add_enabled_ui(can_delete, |ui| widgets::flat_button(ui, tl!("Delete"), 64.0)).inner.clicked() { + act = Some(Action::DeleteChar); + } + act +} + /// Do what button `act` asks for on the dialog's fonts. fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action) -> Result<(), String> { let mut list = fonts(d); @@ -178,61 +306,151 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action) -> Result<(), Stri let f = list.get(i).ok_or("no composite font")?; crate::io::save_command_output(app, "type.compositeFonts.export", EXTS[0], json!({ "fonts": [f] }))?; } + Action::Custom => { + let open = !d.bool("custom"); + d.fields.insert("custom".into(), json!(open)); + } + Action::NewSet | Action::DeleteSet | Action::AddChars | Action::DeleteChar => { + let f = list.get_mut(i).ok_or("no composite font")?; + let at = edit_sets(d, f, act)?; + put_fonts(d, &list); + if let Some(k) = at { + select_set(d, k); + } + } } Ok(()) } -/// The table of `f`'s parts: a row for each class with its font, style, size, baseline, scales and -/// (Kana) Scale at Center; the values a class can't take are not shown. +/// Do custom set action `act` on font `f`. The set to pick afterwards, when the sets changed. +fn edit_sets(d: &mut Dialog, f: &mut CompositeFont, act: Action) -> Result, String> { + let k = index(d, "set", f.custom.len()); + match act { + Action::NewSet => { + if f.custom.len() >= CUSTOM_SETS_MAX { + return Err(format!("a composite font keeps at most {CUSTOM_SETS_MAX} character sets")); + } + // Based on the set picked: its characters, font and values. + let mut s = f.custom.get(k).cloned().unwrap_or_else(|| CustomCharSet::new("", f.parts.kanji.clone())); + s.name = new_set_name(&f.custom); + f.custom.push(s); + Ok(Some(f.custom.len() - 1)) + } + Action::DeleteSet => { + if k < f.custom.len() { + f.custom.remove(k); + } + Ok(Some(k.min(f.custom.len().saturating_sub(1)))) + } + Action::AddChars => { + let set = f.custom.get_mut(k).ok_or("no character set")?; + let entry = d.str("entry"); + let add: Vec = if d.str("entryMode") == "unicode" { + entry + .split(|c: char| c.is_whitespace() || c == ',') + .filter(|code| !code.is_empty()) + .map(|code| CustomCharSet::char_from_code(code).ok_or_else(|| format!("`{code}` is not a code from U+0000 to U+FFFF"))) + .collect::>()? + } else { + entry.chars().collect() + }; + if let Some(c) = add.iter().find(|&&c| !CustomCharSet::takes(c)) { + return Err(format!("`{}`: U+{:04X} is a control character or past U+FFFF", set.name, *c as u32)); + } + let mut count = set.characters.chars().count(); + for c in add { + if set.characters.contains(c) { + continue; + } + if count == CUSTOM_CHARS_MAX { + return Err(format!("`{}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters", set.name)); + } + set.characters.push(c); + count += 1; + } + d.fields.insert("entry".into(), json!("")); + Ok(None) + } + Action::DeleteChar => { + let set = f.custom.get_mut(k).ok_or("no character set")?; + let n = d.fields.get("char").and_then(Value::as_u64).map(|c| c as usize); + if let Some(n) = n { + set.characters = set.characters.chars().enumerate().filter(|(j, _)| *j != n).map(|(_, c)| c).collect(); + } + d.fields.remove("char"); + Ok(None) + } + Action::New | Action::Delete | Action::Import | Action::Export | Action::Custom => Ok(None), + } +} + +/// The table of `f`'s parts: a row for each class, then for each custom character set, with its +/// font, style, size, baseline, scales and (Kana) Scale at Center; the values a row can't take are +/// not shown. Past [`TABLE_HEIGHT`] the table scrolls, so the controls under it stay on screen. fn parts_table(app: &VectorcraftApp, ui: &mut egui::Ui, f: &mut CompositeFont) { - let db = vectorcraft_text::FontDb::global(); + egui::ScrollArea::vertical().id_salt("composite-fonts-table").max_height(TABLE_HEIGHT).show(ui, |ui| parts_grid(app, ui, f)); +} + +/// The rows of [`parts_table`]. +fn parts_grid(app: &VectorcraftApp, ui: &mut egui::Ui, f: &mut CompositeFont) { egui::Grid::new("composite-fonts-parts").num_columns(8).spacing([8.0, 6.0]).show(ui, |ui| { for h in ["", tl!("Font"), tl!("Style"), tl!("Size"), tl!("Baseline"), tl!("Horizontal Scale"), tl!("Vertical Scale"), ""] { widgets::dim_label(ui, h); } ui.end_row(); for class in CharClass::ALL { - let p: &mut CompositePart = f.parts.get_mut(class); ui.label(class_label(class)); - let id = ("composite-font", class.key()); - if let Some(crate::font_menu::FontPick::Chosen(family, style)) = - crate::font_menu::font_menu(ui, id, &p.font, 190.0, None, crate::font_menu::MenuLook::of(app)) - && db.composite_font(&family).is_none() - { - p.style = style.unwrap_or_else(|| db.styles(&family).first().cloned().unwrap_or_else(|| "Regular".into())); - p.font = family; - } - let styles = db.styles(&p.font); - let style_names: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(k) = widgets::dropdown_names(ui, ("composite-font-style", class.key()), &p.style, &style_names, 120.0) - && let Some(s) = styles.get(k) - { - p.style.clone_from(s); - } - let percent = |ui: &mut egui::Ui, key: &str, v: &mut f64, range: &std::ops::RangeInclusive| { - if let Some(n) = widgets::range_field(ui, ("composite-font", class.key(), key), *v, range.clone(), "%", 1, 64.0) { - *v = n; - } - }; - percent(ui, "size", &mut p.size, &COMPOSITE_SIZE); - percent(ui, "baseline", &mut p.baseline, &COMPOSITE_BASELINE); - if class.scales() { - percent(ui, "hScale", &mut p.h_scale, &COMPOSITE_SCALE); - percent(ui, "vScale", &mut p.v_scale, &COMPOSITE_SCALE); - } else { - ui.label(""); - ui.label(""); - } - if class == CharClass::Kana { - ui.checkbox(&mut p.scale_at_center, tl!("Scale at Center")); - } else { - ui.label(""); - } - ui.end_row(); + part_row(app, ui, class.key(), f.parts.get_mut(class), class.scales(), class == CharClass::Kana); + } + for (n, s) in f.custom.iter_mut().enumerate() { + // A set's name is the user's own, shown as it is. + ui.label(s.name.as_str()); + part_row(app, ui, &format!("custom-{n}"), &mut s.part, true, false); } }); } +/// The cells of part `p`'s row (keyed `key`) after its label: font, style, size, baseline, the +/// scales when `scales`, and Scale at Center when `center`. +fn part_row(app: &VectorcraftApp, ui: &mut egui::Ui, key: &str, p: &mut CompositePart, scales: bool, center: bool) { + let db = vectorcraft_text::FontDb::global(); + let id = ("composite-font", key); + if let Some(crate::font_menu::FontPick::Chosen(family, style)) = + crate::font_menu::font_menu(ui, id, &p.font, 190.0, None, crate::font_menu::MenuLook::of(app)) + && db.composite_font(&family).is_none() + { + p.style = style.unwrap_or_else(|| db.styles(&family).first().cloned().unwrap_or_else(|| "Regular".into())); + p.font = family; + } + let styles = db.styles(&p.font); + let style_names: Vec<&str> = styles.iter().map(String::as_str).collect(); + if let Some(k) = widgets::dropdown_names(ui, ("composite-font-style", key), &p.style, &style_names, 120.0) + && let Some(s) = styles.get(k) + { + p.style.clone_from(s); + } + let percent = |ui: &mut egui::Ui, field: &str, v: &mut f64, range: &std::ops::RangeInclusive| { + if let Some(n) = widgets::range_field(ui, ("composite-font", key, field), *v, range.clone(), "%", 1, 64.0) { + *v = n; + } + }; + percent(ui, "size", &mut p.size, &COMPOSITE_SIZE); + percent(ui, "baseline", &mut p.baseline, &COMPOSITE_BASELINE); + if scales { + percent(ui, "hScale", &mut p.h_scale, &COMPOSITE_SCALE); + percent(ui, "vScale", &mut p.v_scale, &COMPOSITE_SCALE); + } else { + ui.label(""); + ui.label(""); + } + if center { + ui.checkbox(&mut p.scale_at_center, tl!("Scale at Center")); + } else { + ui.label(""); + } + ui.end_row(); +} + /// Import…: the fonts of a picked file join those in the dialog, a font of the same name replaced, /// and the first of them is picked. fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { diff --git a/crates/ui-egui/src/dialogs/composite_fonts/tests.rs b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs index 64a031e043..b4d89c64ea 100644 --- a/crates/ui-egui/src/dialogs/composite_fonts/tests.rs +++ b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs @@ -172,3 +172,90 @@ fn the_dialog_draws_with_and_without_fonts() { frame(&mut app); assert_eq!(fonts(dialog(&mut app)).len(), 1); } + +fn try_press(app: &mut VectorcraftApp, act: Action) -> Result<(), String> { + let mut d = app.ui.dialog.clone().unwrap(); + let r = run(app, &mut d, act); + app.ui.dialog = Some(d); + r +} + +fn sets(app: &mut VectorcraftApp) -> Vec { + fonts(dialog(app)).first().map(|f| f.custom.clone()).unwrap_or_default() +} + +/// Custom shows the font's custom character sets: New adds a set (based on the one picked), Add +/// puts typed characters or the characters of Unicode codes into it once each, Delete takes the +/// picked character out, Delete Set takes the set out; OK sets them with the font as one step. +#[test] +fn custom_character_sets_are_edited_in_the_dialog() { + let mut app = app(); + open(&mut app).unwrap(); + press(&mut app, Action::New); + press(&mut app, Action::Custom); + assert!(dialog(&mut app).bool("custom")); + frame(&mut app); + press(&mut app, Action::NewSet); + frame(&mut app); + assert_eq!(dialog(&mut app).str("setName"), "Character Set 1"); + let kanji = fonts(dialog(&mut app))[0].parts.kanji.clone(); + assert_eq!(sets(&mut app)[0].part, kanji); + // Direct Entry adds each character once. + dialog(&mut app).fields.insert("entry".into(), json!("※A※")); + press(&mut app, Action::AddChars); + assert_eq!(sets(&mut app)[0].characters, "※A"); + assert_eq!(dialog(&mut app).str("entry"), ""); + // Unicode takes codes, with or without U+, and skips characters already in the set. + dialog(&mut app).fields.insert("entryMode".into(), json!("unicode")); + dialog(&mut app).fields.insert("entry".into(), json!("U+3042, 41")); + press(&mut app, Action::AddChars); + assert_eq!(sets(&mut app)[0].characters, "※Aあ"); + for bad in ["zz", "1F600", "0009"] { + dialog(&mut app).fields.insert("entry".into(), json!(bad)); + assert!(try_press(&mut app, Action::AddChars).is_err(), "{bad}"); + assert_eq!(sets(&mut app)[0].characters, "※Aあ"); + } + dialog(&mut app).fields.insert("entryMode".into(), json!("direct")); + dialog(&mut app).fields.insert("entry".into(), json!("B😀")); + assert!(try_press(&mut app, Action::AddChars).is_err()); + assert_eq!(sets(&mut app)[0].characters, "※Aあ"); + frame(&mut app); + // Delete takes the picked character out. + dialog(&mut app).fields.insert("char".into(), json!(0)); + press(&mut app, Action::DeleteChar); + assert_eq!(sets(&mut app)[0].characters, "Aあ"); + // The set's name is edited in Name; New is based on the set picked. + dialog(&mut app).fields.insert("setName".into(), json!("Letters")); + frame(&mut app); + press(&mut app, Action::NewSet); + let list = sets(&mut app); + assert_eq!(list.iter().map(|s| (s.name.as_str(), s.characters.as_str())).collect::>(), [("Letters", "Aあ"), ("Character Set 1", "Aあ")]); + assert_eq!(dialog(&mut app).fields.get("set"), Some(&json!(1))); + press(&mut app, Action::DeleteSet); + assert_eq!(sets(&mut app).len(), 1); + assert_eq!(dialog(&mut app).str("setName"), "Letters"); + frame(&mut app); + dialog(&mut app).fields.insert("name".into(), json!("UI Composite Sets")); + frame(&mut app); + let before = steps(&app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), before + 1); + let f = &app.session.active().unwrap().doc.composite_fonts[0]; + assert_eq!((f.custom.len(), f.custom[0].name.as_str(), f.custom[0].characters.as_str()), (1, "Letters", "Aあ")); +} + +/// Two sets of the same name keep the dialog open and change nothing. +#[test] +fn custom_sets_sharing_a_name_keep_the_dialog_open() { + let mut app = app(); + open(&mut app).unwrap(); + press(&mut app, Action::New); + press(&mut app, Action::NewSet); + press(&mut app, Action::NewSet); + dialog(&mut app).fields.insert("setName".into(), json!("character set 1")); + dialog(&mut app).fields.insert("custom".into(), json!(true)); + frame(&mut app); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + assert!(names(&app).is_empty()); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 948b295e17..ed329c7b63 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3614,6 +3614,8 @@ digits Malayalam Малаялам Kana Кана Numerals Цифри Scale at Center Мащабиране от центъра + Delete Set Изтриване на набор + Direct Entry Директно въвеждане Up/Down: Нагоре/надолу: Left/Right: Наляво/надясно: Tate-chu-yoko Up/Down Тате-чу-йоко нагоре/надолу diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f8d6fa263b..9474dc95bf 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1640,6 +1640,8 @@ digits Malayalam Malajalam Kana Kana Numerals Brojevi Scale at Center Skaliraj od centra + Delete Set Izbriši skup + Direct Entry Direktan unos Up/Down: Gore/dolje: Left/Right: Lijevo/desno: Tate-chu-yoko Up/Down Tate-chu-yoko gore/dolje diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3ed3ca8331..249fd6423f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4685,6 +4685,8 @@ digits Malayalam Malaiàlam Kana Kana Numerals Xifres Scale at Center Escala des del centre + Delete Set Suprimeix el joc + Direct Entry Entrada directa Up/Down: Amunt/avall: Left/Right: Esquerra/dreta: Tate-chu-yoko Up/Down Tate-chu-yoko amunt/avall diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 161e235c56..6559787f36 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1669,6 +1669,8 @@ digits Malayalam Malajálamské Kana Kana Numerals Číslice Scale at Center Měřítko od středu + Delete Set Odstranit sadu + Direct Entry Přímé zadání Up/Down: Nahoru/dolů: Left/Right: Vlevo/vpravo: Tate-chu-yoko Up/Down Tate-chu-yoko nahoru/dolů diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index fd0b7c042e..b58ea9efd2 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2184,6 +2184,8 @@ digits Malayalam Malayalam Kana Kana Numerals Tal Scale at Center Skalér fra midten + Delete Set Slet sæt + Direct Entry Direkte indtastning Up/Down: Op/ned: Left/Right: Venstre/højre: Tate-chu-yoko Up/Down Tate-chu-yoko op/ned diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2a5f32a5d9..121b7342e3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3657,6 +3657,8 @@ effect Points Punkte Kana Kana Numerals Ziffern Scale at Center Von der Mitte aus skalieren + Delete Set Satz löschen + Direct Entry Direkteingabe Up/Down: Oben/Unten: Left/Right: Links/Rechts: Tate-chu-yoko Up/Down Tate-chu-yoko oben/unten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b1b7b4d039..03662f1dcc 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4191,6 +4191,8 @@ digits Malayalam Μαλαγιάλαμ Kana Κάνα Numerals Αριθμοί Scale at Center Κλιμάκωση από το κέντρο + Delete Set Διαγραφή συνόλου + Direct Entry Άμεση καταχώριση Up/Down: Πάνω/Κάτω: Left/Right: Αριστερά/Δεξιά: Tate-chu-yoko Up/Down Tate-chu-yoko πάνω/κάτω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5b3c675f7b..4260a5b74c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3705,6 +3705,8 @@ digits Malayalam Malayálam Kana Kana Numerals Números Scale at Center Escalar desde el centro + Delete Set Eliminar juego + Direct Entry Entrada directa Up/Down: Arriba/abajo: Left/Right: Izquierda/derecha: Tate-chu-yoko Up/Down Tate-chu-yoko arriba/abajo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e77a617c52..aafc2665a2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1642,6 +1642,8 @@ digits Malayalam Malajalam Kana Kana Numerals Numerot Scale at Center Skaalaa keskeltä + Delete Set Poista merkistö + Direct Entry Suora syöttö Up/Down: Ylös/alas: Left/Right: Vasen/oikea: Tate-chu-yoko Up/Down Tate-chu-yoko ylös/alas diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2762d2a954..34ab17bea2 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3697,6 +3697,8 @@ digits Malayalam Malayalam Kana Kana Numerals Chiffres Scale at Center Mettre à l’échelle depuis le centre + Delete Set Supprimer le jeu + Direct Entry Saisie directe Up/Down: Haut/bas : Left/Right: Gauche/droite : Tate-chu-yoko Up/Down Tate-chu-yoko haut/bas diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 1c96900b2b..027b5a539b 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -3257,6 +3257,8 @@ Scale Text Attributes ટેક્સ્ટ ગુણધર્મો સ્કેલ કરો Scale Tool સ્કેલ ટૂલ Scale at Center કેન્દ્રથી સ્કેલ કરો + Delete Set સેટ કાઢી નાખો + Direct Entry સીધી એન્ટ્રી Scale in Perspective પર્સ્પેક્ટિવમાં સ્કેલ કરો Scale to Fit Artboard આર્ટબોર્ડમાં બંધબેસતું સ્કેલ કરો Scale: સ્કેલ: diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 548f961f51..6e6db51116 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5070,6 +5070,8 @@ antiAlias Sharp शार्प Kana काना Numerals अंक Scale at Center केंद्र से आकार बदलें + Delete Set सेट हटाएँ + Direct Entry सीधी प्रविष्टि Technical Drawing तकनीकी ड्राइंग Telugu तेलुगु Temperature तापमान diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 200b45d1b1..be89dbec5a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4373,6 +4373,8 @@ mark-style Japanese Japán Kana Kana Numerals Számjegyek Scale at Center Méretezés középről + Delete Set Készlet törlése + Direct Entry Közvetlen bevitel Up/Down: Fel/le: Left/Right: Balra/jobbra: Tate-chu-yoko Up/Down Tate-chu-yoko fel/le diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f3dcca3874..78f0b7bca3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1575,6 +1575,8 @@ digits Malayalam Malayalam Kana Kana Numerals Angka Scale at Center Skalakan dari tengah + Delete Set Hapus set + Direct Entry Entri langsung Up/Down: Atas/bawah: Left/Right: Kiri/kanan: Tate-chu-yoko Up/Down Tate-chu-yoko atas/bawah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 90ec79404c..3ab540b7ad 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3751,6 +3751,8 @@ digits Malayalam Malayalam Kana Kana Numerals Numeri Scale at Center Ridimensiona dal centro + Delete Set Elimina set + Direct Entry Immissione diretta Up/Down: Su/giù: Left/Right: Sinistra/destra: Tate-chu-yoko Up/Down Tate-chu-yoko su/giù diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d98acfa22d..67c72839e4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4079,6 +4079,8 @@ file-filter Templates テンプレート Kana かな Numerals 数字 Scale at Center 中心から拡大・縮小 + Delete Set セットを削除 + Direct Entry 直接入力 Up/Down: 上下: Left/Right: 左右: Tate-chu-yoko Up/Down 縦中横の上下 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 794e5454a9..cf0a305eb1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1513,6 +1513,8 @@ digits Malayalam მალაიალამური Kana კანა Numerals ციფრები Scale at Center ცენტრიდან მასშტაბირება + Delete Set ნაკრების წაშლა + Direct Entry პირდაპირი შეყვანა Up/Down: ზემოთ/ქვემოთ: Left/Right: მარცხნივ/მარჯვნივ: Tate-chu-yoko Up/Down Tate-chu-yoko ზემოთ/ქვემოთ diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bb8676df71..92e37d3a18 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4067,6 +4067,8 @@ file-filter Templates 템플릿 Kana 가나 Numerals 숫자 Scale at Center 중심에서 크기 조정 + Delete Set 집합 삭제 + Direct Entry 직접 입력 Up/Down: 위/아래: Left/Right: 왼쪽/오른쪽: Tate-chu-yoko Up/Down 다테추요코 위/아래 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a597a03fd0..7a1527d9bc 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2093,6 +2093,8 @@ digits Malayalam Malayalam Kana Kana Numerals Cijfers Scale at Center Schalen vanuit het midden + Delete Set Set verwijderen + Direct Entry Directe invoer Up/Down: Omhoog/omlaag: Left/Right: Links/rechts: Tate-chu-yoko Up/Down Tate-chu-yoko omhoog/omlaag diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 99ee2080b8..670f8f163c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1581,6 +1581,8 @@ digits Malayalam Malajalam Kana Kana Numerals Cyfry Scale at Center Skaluj od środka + Delete Set Usuń zestaw + Direct Entry Wprowadzanie bezpośrednie Up/Down: W górę/w dół: Left/Right: W lewo/w prawo: Tate-chu-yoko Up/Down Tate-chu-yoko w górę/w dół diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 677c8e460f..47f7fe19ec 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5267,6 +5267,8 @@ axis Both Ambos Kana Kana Numerals Números Scale at Center Dimensionar a partir do centro + Delete Set Excluir conjunto + Direct Entry Entrada direta Up/Down: Para Cima/Para Baixo: Left/Right: Esquerda/Direita: Tate-chu-yoko Up/Down Tate-chu-yoko Para Cima/Para Baixo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 106343cddd..e20f7911bf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3737,6 +3737,8 @@ digits Malayalam Малаялам Kana Кана Numerals Цифры Scale at Center Масштабировать от центра + Delete Set Удалить набор + Direct Entry Прямой ввод Up/Down: Вверх/вниз: Left/Right: Влево/вправо: Tate-chu-yoko Up/Down Tate-chu-yoko вверх/вниз diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cc854aa362..0980201f22 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3627,6 +3627,8 @@ digits Malayalam Malayalam Kana Kana Numerals Siffror Scale at Center Skala från mitten + Delete Set Ta bort uppsättning + Direct Entry Direktinmatning Up/Down: Upp/ned: Left/Right: Vänster/höger: Tate-chu-yoko Up/Down Tate-chu-yoko upp/ned diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f2888a30f0..4536ece6ed 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3491,6 +3491,8 @@ digits Malayalam Malayalam Kana Kana Numerals Rakamlar Scale at Center Merkezden Ölçekle + Delete Set Seti Sil + Direct Entry Doğrudan Giriş Up/Down: Yukarı/Aşağı: Left/Right: Sol/Sağ: Tate-chu-yoko Up/Down Tate-chu-yoko Yukarı/Aşağı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 411b2c3baf..73ac584f2d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3744,6 +3744,8 @@ effect Points Точки Kana Кана Numerals Цифри Scale at Center Масштабувати від центру + Delete Set Видалити набір + Direct Entry Пряме введення Up/Down: Вгору/вниз: Left/Right: Ліворуч/праворуч: Tate-chu-yoko Up/Down Tate-chu-yoko вгору/вниз diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1460910197..261dbfb064 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4090,6 +4090,8 @@ mark-style Japanese Kiểu Nhật Kana Kana Numerals Chữ số Scale at Center Co giãn từ tâm + Delete Set Xóa bộ + Direct Entry Nhập trực tiếp Up/Down: Lên/Xuống: Left/Right: Trái/Phải: Tate-chu-yoko Up/Down Tate-chu-yoko lên/xuống diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3fe2f9e009..faf9abad2d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3565,6 +3565,8 @@ digits Malayalam 马拉雅拉姆数字 Kana 假名 Numerals 数字 Scale at Center 从中心缩放 + Delete Set 删除集 + Direct Entry 直接输入 Up/Down: 上/下: Left/Right: 左/右: Tate-chu-yoko Up/Down 纵中横上/下 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ab7f9d9dd2..80d0672d1a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4824,6 +4824,8 @@ digits Malayalam 馬來亞拉姆數字 Kana 假名 Numerals 數字 Scale at Center 從中心縮放 + Delete Set 刪除集 + Direct Entry 直接輸入 Up/Down: 上/下: Left/Right: 左/右: Tate-chu-yoko Up/Down 縱中橫上/下 diff --git a/docs/mcp.md b/docs/mcp.md index 8ca4121032..1549d77362 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9295,8 +9295,19 @@ value out of range, a scale on another class, an unknown class or key, a name th characters, shared by two fonts (in any case) or the name of an installed font, and a part set in a composite font are each refused with an error, and nothing changes. A document keeps at most 256. +`custom` lists a font's custom character sets +([Customize characters in a composite font](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348)), +each `{name, characters?, font?, style?, size?, baseline?, hScale?, vScale?}`. A set sets the characters it lists in +its own font, style, size, baseline and scales, ahead of their class; a value left out is the `kanji` part's. When two +sets list a character, the later set sets it. A set's name is trimmed and unique in its font in any case. Its +characters are each listed once, none a control character or past U+FFFF. A set may list no characters, and then sets +none. A font keeps at most 64 sets and a set at most 8,192 characters, VectorCraft's own limits. A set name that is +empty or shared, a character listed twice or not allowed, `scaleAtCenter` on a set, or an unknown key is refused with +an error, and nothing changes. + Type set in a composite font names it as its family (`text.setStyle {font: "My Composite"}`), and the font menus list -composite fonts first. The layout sets each character in its class's font, a letter and its combining marks together. +composite fonts first. The layout sets each character in its custom set's or its class's font, a letter and its +combining marks together. A composite font taken out of the document leaves its type naming it, and the type shows as missing. Composite fonts are saved with the document. SVG and legacy `.ai` write type in one of the document's composite fonts as outlines, and SVG, PDF, EPS and legacy `.ai` export draw it in the font's parts also when the document was read without an open app. @@ -9309,18 +9320,24 @@ parts its characters are drawn in, and `missingGlyphs` counts the characters tha characters are drawn in. `text.glyphAlternates` lists the alternates of a character from its part's font. `type.compositeFonts.export {path?, names?, fonts?}` writes the document's composite fonts (all, or those `names` -lists), or the `fonts` given, to a `.vccomposite` file; without `path` it returns the file's text as `data`. +lists), or the `fonts` given, to a `.vccomposite` file; without `path` it returns the file's text as `data`. Fonts +whose file would be larger than 4 MB, the most Import reads, are refused with an error. `type.compositeFonts.import {path | data | dataBase64, apply?}` reads a file of at most 4 MB. With `apply` (the default) its fonts join the document's, replacing a font of the same name, as one undo step; with `apply: false` it only returns them. Opening a `.vccomposite` file imports it. Type › Composite Fonts… (shown with Show East Asian Options) opens the Composite Fonts dialog on the document's fonts: -Font picks one, and a table sets each class's font, style, size, baseline and scales. New adds a font based on the one -picked, Delete Font takes it out, Import… adds the fonts of a file and Export… writes the one picked. OK runs -`type.compositeFonts.set` with every font as one undo step; a refused font keeps the dialog open. +Font picks one, and a table sets each class's and each custom set's font, style, size, baseline and scales. New adds a +font based on the one picked, Delete Font takes it out, Import… adds the fonts of a file and Export… writes the one +picked. Custom shows the font's custom character sets: Character Set picks one, New adds a set based on the one picked, +Delete Set takes it out, and Name renames it. Character › Direct Entry adds the characters typed, and Character › +Unicode adds the characters of hexadecimal codes (`3042`, `U+3042`, several separated by spaces or commas); Add skips +characters the set already lists. Delete takes the character picked out of the set. OK runs `type.compositeFonts.set` +with every font as one undo step; a refused font keeps the dialog open. ```json {"name":"run_command","arguments":{"command":"type.compositeFonts.set","params":{"fonts":[{"name":"My Composite","parts":{"kanji":{"font":"Source Han Sans"},"roman":{"font":"Source Sans 3","size":95,"baseline":-2},"numerals":{"font":"Source Sans 3"}}}]}}} +{"name":"run_command","arguments":{"command":"type.compositeFonts.set","params":{"fonts":[{"name":"My Composite","parts":{"kanji":{"font":"Source Han Sans"}},"custom":[{"name":"Marks","characters":"※〒","font":"Source Sans 3","size":90}]}]}}} ``` ## Optical Margin Alignment diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f4424700be..4423a74dcb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -693,6 +693,7 @@ since 2026-10-01. - **Create Outlines keeps the characters' gradients across the type:** Type › Create Outlines and Object › Expand give every glyph, ruby glyph and underline or strikethrough bar the gradient its characters drew with: a gradient with no vector of its own stays fitted to the whole layout rather than to each glyph, and one with a vector keeps its place, both moved, scaled and rotated with the type; the characters' stroke keeps the weight it was drawn at on scaled type. `object.expand.info` measures gradient fills on the same paths. Outlined characters keep their stroke and fill (the help page, ); the gradient fitted across the whole layout is VectorCraft's own rule. Measured by `tests::objexpand::create_outlines_keeps_a_character_gradient_across_the_type` and `tests::objexpand::expand_keeps_a_character_gradient_across_the_type`. Scores unchanged (estimated). - **Composite fonts that two documents define differently:** when two open documents each define a composite font (Type › Composite Fonts…) by the same name with different parts, each document draws and exports its type in its own definition: in a tiled or floating window drawn while the front document draws, when it is placed in a document that defines the name differently, and in an export made while another document is in front. Evidence: `tests::composite::nested_loans_of_one_name_use_each_documents_definition` and `threads_lending_one_name_at_once_use_their_own_definitions` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` (`crates/render`). Scores unchanged (estimated). - **Composite fonts only another document defines:** a document draws, measures and exports its type only in the composite fonts it defines itself. Type naming a composite font that only another open document, or the document a placed document sits in, defines is set as in a missing font, also when the document defines no composite font: in a window, a thumbnail or an export of a document in the back, in a file being opened and in a placed document. A temporary document drawn while a document draws keeps that document's composite fonts. Evidence: `tests::composite::a_lent_document_does_not_use_another_documents_composite_font` and `a_document_without_composite_fonts_uses_none_and_a_preview_is_unchanged` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` and `a_document_draws_type_in_another_documents_composite_font_as_missing` (`crates/render`), `a_document_replaced_in_the_back_measures_a_font_it_lacks_as_missing` (`crates/engine`). Scores unchanged (estimated). +- **Composite fonts' custom character sets:** Type › Composite Fonts… › Custom adds named character sets to a composite font, each setting the characters it lists in its own font, style, size, baseline and scales ahead of their class; the later of two sets that list a character sets it ([the help page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348)). Characters are added by Direct Entry or by Unicode code and deleted one at a time; the sets are saved with the document and in `.vccomposite` files (`custom` in `type.compositeFonts.set`). Evidence: `tests::composite::custom_character_sets_take_their_characters_first` (`crates/text`), `compositefonts::tests::custom_character_sets_set_their_characters` (`crates/engine`), `dialogs::composite_fonts::tests::custom_character_sets_are_edited_in_the_dialog` (`crates/ui-egui`). Scores unchanged (estimated). - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). - **Character-style numeric bounds:** `charStyle.new`, `charStyle.edit`, and `charStyle.setAttrs` accept size from 0.1 to 1296 pt, explicit leading from 0.1 to 5000 pt, horizontal and vertical scale from 1% to 10,000%, and baseline shift and Tate-chu-yoko offsets from -1296 to 1296 pt. Out-of-range input leaves the style and Undo history unchanged; Auto leading remains available. These limits match the Character panel controls. - **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). diff --git a/docs/type-parity.md b/docs/type-parity.md index fa76290e94..8ac7fe6713 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -72,7 +72,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | | CJK: East Asian glyph forms | done | Glyphs panel menu › Traditional Forms, Expert Forms, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms, with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): the selected characters are drawn with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`; [feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), one form at a time (`text.setFormat` / `text.setRangeStyle {glyphForm}`); a form none of the faces that draw the selected text (fallback faces included) has a feature for is disabled, and Revert To Default Forms draws the default glyphs. Saved natively only when set, carried by character styles, drawn on the canvas and in PDF, and written to SVG as `font-feature-settings`. Character Style Options has no field for it. Evidence: `tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `panels::glyphs::tests::east_asian_glyph_forms_in_the_panel_menu_set_the_selected_characters_form` | | CJK: warichu | done | Character panel menu › Warichu and Warichu Settings… (`text.setFormat` / `text.setRangeStyle {warichu, warichuLines, warichuGap, warichuScale, warichuAlign}`, `ui.warichuSettings`), with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): marked characters set at a percentage of their size and stacked on 1 to 6 lines inside their line, one above the other in horizontal type and side by side in vertical type, with a line gap and eight alignments; a block stays whole on one line. Saved natively, carried by character styles, drawn on the canvas and in every export (SVG, PDF and `.ai` write such type as outlines). Type on a path sets the characters at their own size. Evidence: `tests::warichu`, `dialogs::warichu::tests`, `typecmd::area_tests::warichu_is_a_character_attribute_set_saved_and_exported` | -| CJK: composite fonts | partial | Type › Composite Fonts… (`type.compositeFonts.set`), with Show East Asian Options: a font made of a font per class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals), each with its own style, size and baseline in percent of the type's size, Kana, Roman and Numerals with horizontal and vertical scale, and Kana with Scale at Center; New, Delete Font, Import… and Export… (`.vccomposite`, `type.compositeFonts.import` and `.export`) ([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per class; SVG and legacy `.ai` write it as outlines. Not yet: custom character sets, the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `compositefonts::tests`, `dialogs::composite_fonts::tests` | +| CJK: composite fonts | partial | Type › Composite Fonts… (`type.compositeFonts.set`), with Show East Asian Options: a font made of a font per class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals), each with its own style, size and baseline in percent of the type's size, Kana, Roman and Numerals with horizontal and vertical scale, and Kana with Scale at Center; New, Delete Font, Import… and Export… (`.vccomposite`, `type.compositeFonts.import` and `.export`) ([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)); custom character sets (Custom: New, Delete Set, characters added by Direct Entry or Unicode code and deleted), each with its own font, style, size, baseline and scales, ahead of the classes, the later set winning a character two list (`custom` in `type.compositeFonts.set`) ([Customize characters in a composite font](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per set and class; SVG and legacy `.ai` write it as outlines. Not yet: the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `text::composite::tests::custom_sets_take_their_characters_before_the_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `tests::composite::custom_character_sets_take_their_characters_first`, `compositefonts::tests`, `dialogs::composite_fonts::tests` | | CJK: ruby | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`) | #633 | | Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Assamese and Sanskrit added, #4947; Serbian: Cyrillic, Serbian: Latin and Croatian); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). English and eleven Indian languages hyphenate by run language; Sanskrit automatic hyphenation awaits allowlisted patterns (#4947). Not yet: `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set`, `tests::typo::indic_language_runs_hyphenate_with_both_composers`, `hyphen::tests::sanskrit_without_allowlisted_patterns_uses_only_exceptions`; spelling is checked in each run's Language (`text.spelling.check`, #4936) | | Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Serbian: Cyrillic, Serbian: Latin and Croatian; Bulgarian, #5945); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). Not yet: hyphenation by it in languages other than English and the eleven Indian languages above, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set`; spelling is checked in each run's Language (`text.spelling.check`, #4936) | From d8ba795824f32c8f26bfb8039f95ab1cd874b3ee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:29:59 -0800 Subject: [PATCH 16/24] Docs: the opaque 16-bit CMYK TIFF line links the PNG rescaling section --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bfd2fb1779..30ca2796d3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1845,7 +1845,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - A 16-bit CMYK or grayscale TIFF with an alpha channel is converted from its 16-bit samples, and only the resulting color and alpha are rounded to 8 bits; associated alpha is divided out at 16 bits, and a pixel whose associated alpha is zero has no color. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives extra samples the depth in BitsPerSample, and its Associated Alpha Handling section states the arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. Each sample was cut to its high byte first, so a faint ink or a low alpha could be lost (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - - An opaque 16-bit CMYK TIFF, chunky or planar, shows each ink rounded to the nearest of 256 levels, `round(s × 255 / 65535)`, as the [PNG specification's sample depth rescaling](https://www.w3.org/TR/png-3/#13.12-sample-depth-rescaling) gives it; [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) sets 0 and 65535 as no ink and full ink, and using that rounding for TIFF inks is VectorCraft's own rule. Each ink was cut to its high byte before, so an ink of 200 showed as none. The stored image keeps its 16-bit samples (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). + - An opaque 16-bit CMYK TIFF, chunky or planar, shows each ink rounded to the nearest of 256 levels, `round(s × 255 / 65535)`, as the [PNG specification's sample depth rescaling](https://www.w3.org/TR/png-3/#13Sample-depth-rescaling) gives it; [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) sets 0 and 65535 as no ink and full ink, and using that rounding for TIFF inks is VectorCraft's own rule. Each ink was cut to its high byte before, so an ink of 200 showed as none. The stored image keeps its 16-bit samples (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels. A CMYK TIFF with two or more extra channels past its ExtraSamples values, or with no ExtraSamples tag, opens opaque when none of them is marked as alpha, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MiB, as long as its samples, the copy a planar file needs, the row the decoder reads each strip or tile into and the RGBA pixels together fit the decoding allowance; a TIFF whose SamplesPerPixel is more than 64 is not read, which is VectorCraft's own limit, and both checks run on the file's tags before any decoding. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. From 53249e406cdb8ec11fcda7687c2b5550e874699d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:31:22 -0800 Subject: [PATCH 17/24] Align: Distribute's docs and params text describe the key anchor with spacing and to object.distribute uses spacing with a key anchor, and to: "selection" or "artboard" makes the key anchor an ordinary point. docs/mcp.md and the object.distribute and object.distributeSpacing params text still said both commands ignore to and spacing with anchor points selected. --- crates/engine/src/cmd/object/commands.rs | 4 ++-- docs/mcp.md | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index e89c8fd208..3ea501c775 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -269,7 +269,7 @@ pub fn specs() -> Vec { "Distribute", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key, or with anchor points selected and no key anchor), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636); with spacing and a key anchor (select.key {id, anchor}), the key anchor stays put and the others are placed spacing apart, outward from it, in their order along the axis", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key, or with anchor points selected and no key anchor), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; with anchor points selected, with \"selection\" or \"artboard\" the key anchor is not the key and all the points are spaced as without one; not used with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636); with spacing and a key anchor (select.key {id, anchor}), the key anchor stays put and the others are placed spacing apart, outward from it, in their order along the axis", can_distribute, distribute ), @@ -278,7 +278,7 @@ pub fn specs() -> Vec { "Distribute Spacing", ["Window", "Align"], None, - "{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636), or with a key anchor (select.key {id, anchor}) `spacing` apart outward from the key anchor, which stays put", + "{axis?: \"horizontal\"|\"vertical\" (default: \"horizontal\"), spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing, and with a key guide; with anchor points selected, with \"selection\" or \"artboard\" the key anchor is not the key and all the points are spaced as without one; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636), or with a key anchor (select.key {id, anchor}) `spacing` apart outward from the key anchor, which stays put", can_distribute, distribute_spacing ), diff --git a/docs/mcp.md b/docs/mcp.md index ce22248575..544483cf26 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2711,6 +2711,7 @@ It returns `{key}`, the key object's id or null, with `guide`, the key guide's i `select.anchors` with mode `add` or `toggle` and one point makes that point the key anchor when it selects it; a Shift-click on an anchor with Direct Selection runs it, and a plain click still selects that point alone. Aligning to the key, `object.align` moves the other selected points to the key anchor's x or y and the key anchor stays put; a pixel-aligned path is then snapped to the pixel grid, as after any other edit. `object.distribute {spacing}` and `object.distributeSpacing {spacing}` keep the key anchor in place and put the other points `spacing` apart, outward from it, in their order along the axis. +With `to: "selection"` or `"artboard"`, `object.align`, `object.distribute` and `object.distributeSpacing` do not treat the key anchor as the key: it moves with the other points, as without a key anchor. The key anchor goes when it is deselected or deleted, when it is the only point left selected, when `select.key {}` cancels it, and when a key object or key guide replaces it. An anchor that is not a selected anchor point (a selected segment's end is not one) is an error, and the key stays as it was. The result has `keyAnchor: {id, anchor}` with a key anchor, and `document.inspect` reports it as `keyAnchor`. @@ -5939,14 +5940,15 @@ With `to: "selection"` or `"key"`, or without `to`, `object.distribute` and `obj keep the first and last objects where they are, and `object.distribute` without `spacing` moves nothing with fewer than three objects. With `spacing` and a key object, `object.distribute` and `object.distributeSpacing` do not use `to`; with a key guide, `to` decides whether the guide is the key. With anchor points selected (Direct Selection), both commands space the points -among themselves and do not use `to` or `artboard`. +among themselves and do not use `artboard`; with `to: "selection"` or `"artboard"`, the key anchor is not the key, and +all the points are spaced as without one. `object.distribute {spacing}` (the Distribute Objects buttons while the selection has a key object, `select.key`) keeps the key object where it is, with the compound path or selected group that contains it. It places the objects' lines (left edges, centers or right edges; top edges, centers or bottom edges) `spacing` points apart, outward from the key object's line, in their order on that line. With `spacing: 0`, every object ends on the key object's line. Two objects -are enough. Without a key object or key guide, or with anchor points selected, `object.distribute` does not use -`spacing`. A `spacing` that is not a number is an error. +are enough. Without a key object, key guide or key anchor, `object.distribute` does not use `spacing`; with anchor +points selected, it uses `spacing` only with a key anchor (`select.key {id, anchor}`). A `spacing` that is not a number is an error. `object.distribute` and `object.distributeSpacing` space the selected ruler guides across the axis (vertical guides for the horizontal options, horizontal guides for the vertical ones) with the selected objects, as items with no size From 1284dc6927b47a5f977e84052e35746f5110a0c3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:38:00 -0800 Subject: [PATCH 18/24] PDF export: a spot stop that prints nothing keeps a gradient image in CMYK A linear or radial gradient of CMYK stops drawn as an image (a dithered fill, or a Perceptual gradient that is not written as a spot shading) was written as an RGB image whenever any stop was linked to a spot swatch, even a stop that colors nothing: one inside fully transparent stretches, one out of order between stretches with no width, or one at a tint of 0 without process plates. The spot color warning already skipped those stops. The image now falls back to RGB only when a stop prints a spot ink, the same test the warning and the spot shadings use. A drawn spot stop at a tint above 0, or with process plates, still gives an RGB image with the warning. --- crates/pdf/src/export/paint.rs | 18 +++++++++--------- crates/pdf/src/tests/cmykimages.rs | 29 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 6baffa48a8..2855a61ff1 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -529,18 +529,18 @@ impl Exporter<'_> { /// their own. fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) { let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points(); - let prints_spot = || g.gradient.stops.iter().zip(g.gradient.drawn_stops()).any(|(s, d)| self.printed_spot(s, d).is_some()); - if from_stops && prints_spot() { + if from_stops && self.prints_spot(&g.gradient) { self.warn( "spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", ); } } - /// Is a stop of `g` linked to a spot swatch? - fn has_spot_stop(&self, g: &vectorcraft_color::Gradient) -> bool { - let doc = self.doc; - g.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) + /// Does a stop of `g` print a spot swatch ([`Self::printed_spot`]): color something that draws + /// ([`vectorcraft_color::Gradient::drawn_stops`]) at a tint above 0 or with process plates? A + /// stop linked to a spot swatch that colors nothing adds no spot ink. + fn prints_spot(&self, g: &vectorcraft_color::Gradient) -> bool { + g.stops.iter().zip(g.drawn_stops()).any(|(s, d)| self.printed_spot(s, d).is_some()) } /// Draw diamond gradient `g`, placed on `bounds`, over `area` (inside the current clip) as four @@ -638,8 +638,8 @@ impl Exporter<'_> { /// scaled type gets its pixels too): a freeform gradient's colour field (at most /// [`MAX_FIELD_PX`] a side), or a linear, radial, conic or diamond gradient (at most [`MAX_GRADIENT_PX`] a side), /// with dither's noise when it paints a fill (`fill`). A linear or radial gradient whose stops - /// are all CMYK and not linked to a spot swatch is a CMYK image, unless its Perceptual curve - /// has too many stops; any other is an RGB image. + /// are all CMYK and none of which prints a spot swatch ([`Self::prints_spot`]) is a CMYK image, + /// unless its Perceptual curve has too many stops; any other is an RGB image. pub(super) fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) { use vectorcraft_color::cms::Model; use vectorcraft_color::freeform::{painted_box, spread_scale}; @@ -667,7 +667,7 @@ impl Exporter<'_> { } else if !freeform { self.warn("linear and radial gradients drawn as pixels are exported as images"); } - let ramp_inks = if freeform || self.has_spot_stop(&g.gradient) { None } else { g.ink_pixels(b, area, cols, rows, fill) }; + let ramp_inks = if freeform || self.prints_spot(&g.gradient) { None } else { g.ink_pixels(b, area, cols, rows, fill) }; let ink_image = |inks, alpha| { let convert = if self.convert { self.out.cmyk_pixels() } else { None }; crate::images::from_inks(inks, alpha, cols.into(), rows.into(), self.interpolate, convert) diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 4719beec70..dcdfe500ec 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -962,6 +962,35 @@ fn gradients_with_a_stop_that_is_not_process_cmyk_stay_rgb_images() { assert_eq!(w[0].space, "DeviceCMYK", "{w:?}"); } +/// A stop linked to a spot swatch that prints no ink (it colors only clear stretches, sits out of +/// order where it colors nothing, or has a tint of 0 and no process plates) leaves a dithered +/// gradient of CMYK stops a CMYK image without the spot warning; a drawn stop with process plates +/// at a tint of 0 still prints, so its gradient stays an RGB image with the warning. +#[test] +fn gradients_whose_spot_stops_print_nothing_stay_cmyk_images() { + let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1); + let stop = |offset: f32, color: Color, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, color) }; + let spot = |offset: f32, opacity: f32| GradientStop { swatch: Some("Gold".into()), ..stop(offset, gold, opacity) }; + let (cyan, black) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let clear = vec![stop(0.0, cyan, 1.0), stop(0.5, black, 1.0), stop(0.6, black, 0.0), spot(0.8, 0.0), stop(1.0, black, 0.0)]; + let out_of_order = vec![stop(0.0, cyan, 1.0), stop(0.6, black, 1.0), spot(0.3, 1.0), stop(0.6, black, 1.0), stop(1.0, black, 1.0)]; + let no_tint = vec![stop(0.0, cyan, 1.0), GradientStop { tint: 0.0, ..spot(1.0, 1.0) }]; + let plates = vec![stop(0.0, cyan, 1.0), GradientStop { tint: 0.0, plates: Some([0.0, 0.0, 0.0, 0.5]), ..spot(1.0, 1.0) }]; + for (name, stops, prints) in + [("clear", clear, false), ("out of order", out_of_order, false), ("tint 0", no_tint, false), ("plates", plates, true)] + { + let mut d = gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, stops, true)); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: true }); + let out = export_with_report(&d, &opts(json!({}))).unwrap(); + let w = written(&out.bytes); + let warned = out.warnings.iter().any(|m| m.contains("written in process colors")); + let space = if prints { "DeviceRGB" } else { "DeviceCMYK" }; + assert!(w.iter().any(|i| i.space == space), "{name}: {w:?}"); + assert!(!w.iter().any(|i| i.space == if prints { "DeviceCMYK" } else { "DeviceRGB" }), "{name}: {w:?}"); + assert_eq!(warned, prints, "{name}: {:?}", out.warnings); + } +} + #[test] fn freeform_gradients_of_lab_points_are_written_as_their_lab_mix() { let d = freeform_doc(ColorMode::Rgb, Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0), 1.0); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4deb1e8ea7..86b03cf50c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1724,6 +1724,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A CMYK document, or an export that converts colors to a CMYK destination profile, writes RGB and Lab stops in CMYK first (unless the export converts colors to an RGB destination profile), so there such a gradient, Classic or Perceptual, is written as a DeviceN shading that keeps the spot ink. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). PDF and EPS export name a spot ink in a gradient's Separation or DeviceN space, and EPS in `%%DocumentCustomColors`, only when a stop linked to it at a tint above 0, or with process plates, colors part of the gradient (VectorCraft's own rule): a stop out of order whose neighboring stretches have no width, and in PDF a stop whose stretches are fully transparent, adds no ink and no empty plate. EPS writes every stop opaque, so there a transparent spot stop keeps its ink (`crates/pdf/src/tests/output.rs`, `crates/eps/src/tests.rs`). + PDF export applies the same rule to a linear or radial gradient drawn as an image: a spot stop that colors only fully transparent stretches, sits out of order where its neighboring stretches have no width, or has a tint of 0 without process plates, leaves a gradient of CMYK stops a CMYK image with no spot color warning. A drawn spot stop at a tint above 0, or with process plates, still makes it an RGB image with the warning (VectorCraft's own rule; `pdf/src/tests/cmykimages.rs`, `gradients_whose_spot_stops_print_nothing_stay_cmyk_images`). PDF export writes a spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4) as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients, colorized images and Lab alternates; Registration still prints on every plate (`pdf/src/tests/output.rs`). - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). Preferences › File Handling › Data Recovery › Folder has a Choose… button on the desktop that picks the folder Data Recovery keeps its copies in ([Illustrator reference, Set Data Recovery preferences](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)); a picked folder that Data Recovery cannot create its folders in or list is refused with a status message, and the field keeps its value. OK applies the same check to a folder typed into the field (VectorCraft's own rule): when it fails, the dialog stays open, the status bar says why and no preference changes; an empty field (the default folder) and the folder already saved are not checked. Preferences › Plug-ins & Scratch Disks is where additional plug-ins folders are set ([Illustrator preferences help](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html)); its Additional Plug-ins Folder has a Choose… button on the desktop as well, VectorCraft's own, matching Data Recovery's, and VectorCraft's own rule refuses a picked or typed folder whose contents can't be listed. Additional Fonts Folder and Templates Folder stay typed only. From d999d861de69dff0faa4846264c9537216086df4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:43:08 -0800 Subject: [PATCH 19/24] Color Settings: Convert to Working Space compensates CMYK black points - CMYK colors converted between two loaded CMYK profiles now use the Color Settings black point compensation, as RGB colors already did: the conversion goes through linear ProPhoto RGB and maps the source profile's black onto the destination's. - The edit.colorSettings, document.open and file.newFromTemplate params text and docs/mcp.md name file.newFromTemplate with the commands that apply the policies, and say that colorMode on open converts after the policies, through the profiles the document keeps. - A test covers New from Template under the policies, and another the CMYK black point compensation. --- crates/color/src/cms.rs | 8 +-- crates/color/src/cms/icc.rs | 80 +++++++++++++++++------- crates/color/src/cms/tests_icc.rs | 43 ++++++++++++- crates/engine/src/cmd/colormgmt.rs | 2 +- crates/engine/src/cmd/fileio/commands.rs | 2 +- crates/engine/src/cmd/fileio/save.rs | 2 +- crates/engine/tests/color_settings.rs | 14 ++++- crates/ui-egui/src/panels/separations.rs | 4 +- docs/mcp.md | 13 ++-- docs/target-app-parity.md | 2 +- 10 files changed, 128 insertions(+), 42 deletions(-) diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index eb7ddf9b7c..3ad4820ce1 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -578,9 +578,9 @@ impl Cms { /// An RGB or CMYK colour of this context's space as the colour of the same model in `dest`'s /// space that looks the same, converted with `intent` (Edit → Color Settings › Convert to - /// Working Space). RGB converts directly from profile to profile, with this context's - /// black-point compensation. CMYK converts between two loaded profiles through ProPhoto RGB - /// ([`IccProfile::to_same_model_of`]), and through Lab when either space is built in. Grey and + /// Working Space). RGB converts directly from profile to profile, and CMYK between two loaded + /// profiles through ProPhoto RGB ([`IccProfile::to_same_model_of`]), both with this context's + /// black-point compensation; CMYK goes through Lab when either space is built in. Grey and /// Lab colours stay as they are. pub fn convert_into(&self, dest: &Cms, c: &Color, intent: Intent) -> Color { match *c { @@ -598,7 +598,7 @@ impl Cms { Color::Cmyk { c, m, y, k } => { let inks = [c, m, y, k].map(|v| v.clamp(0.0, 1.0)); let direct = match (&self.cmyk, &dest.cmyk) { - (CmykSpace::Icc(s), CmykSpace::Icc(d)) => s.to_same_model_of(d, &inks, intent), + (CmykSpace::Icc(s), CmykSpace::Icc(d)) => s.to_same_model_of(d, &inks, intent, self.settings.bpc), _ => None, }; let [c, m, y, k] = match direct.as_deref() { diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 2ee6293c9e..fe4afb6e02 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -34,8 +34,16 @@ type Xf = Option>; /// generation only lets the cache drop what reloads left behind. type DirectKey = (u64, u64, usize, bool); -/// The two transforms of [`IccProfile::to_same_model_of`]. -type SameModel = (Arc, Arc); +/// The transforms of [`IccProfile::to_same_model_of`]: into ProPhoto RGB's primaries with linear +/// tone curves, where black-point compensation is a linear remap, and from there into the +/// destination. +struct SameModel { + into: Arc, + out_of: Arc, + /// The source's and the destination's black as luminance in the linear space (both 0 without + /// compensation). + black: [f32; 2], +} /// A parsed ICC profile plus lazily-built transforms to/from sRGB. pub struct IccProfile { @@ -53,9 +61,8 @@ pub struct IccProfile { /// and black-point compensation. direct: Mutex>)>>, /// Transforms into other profiles of the same model ([`Self::to_same_model_of`]), keyed as - /// [`Self::direct`] is (black-point compensation always off): into ProPhoto RGB, and from it - /// into the destination. - same_model: Mutex)>>, + /// [`Self::direct`] is. + same_model: Mutex>)>>, } /// A transform from one profile into an RGB profile without passing through sRGB: into the @@ -259,38 +266,67 @@ impl IccProfile { /// Device values of this profile → device values of `dest`, a profile of the same model, /// converted with `intent` through ProPhoto RGB rather than sRGB, so colours outside sRGB - /// keep their place. `None` when `dest` is of another model or no transform can be built. - pub fn to_same_model_of(&self, dest: &IccProfile, v: &[f32], intent: Intent) -> Option> { + /// keep their place. With `bpc` (and an intent other than absolute), this profile's black + /// maps onto the destination's black (black-point compensation). `None` when `dest` is of + /// another model or no transform can be built. + pub fn to_same_model_of(&self, dest: &IccProfile, v: &[f32], intent: Intent, bpc: bool) -> Option> { if dest.kind != self.kind || v.len() != self.channels() { return None; } + let bpc = bpc && intent != Intent::AbsoluteColorimetric; let generation = profiles_generation(); - let key = (dest.id, generation, intent_index(intent), false); - let (into, out_of) = { + let key = (dest.id, generation, intent_index(intent), bpc); + let t = { let mut cache = self.same_model.lock().unwrap_or_else(|e| e.into_inner()); cache.retain(|(k, _)| k.1 == generation); match cache.iter().find(|(k, _)| *k == key) { - Some((_, xf)) => xf.clone(), + Some((_, t)) => t.clone(), None => { - let wide = rgb_space(super::PROPHOTO_RGB); - let xf = wide.and_then(|w| { - let into = self.profile.create_transform_f32(self.layout(), &w, Layout::Rgb, Self::opts(intent)).ok()?; - let out_of = w.create_transform_f32(Layout::Rgb, &dest.profile, dest.layout(), Self::opts(intent)).ok()?; - Some((into, out_of)) - }); - cache.push((key, xf.clone())); - xf + let t = self.build_same_model(dest, intent, bpc).map(Arc::new); + cache.push((key, t.clone())); + t } } }?; - let mut rgb = [0.0f32; 3]; - into.transform(v, &mut rgb).ok()?; - let rgb = rgb.map(|x| x.clamp(0.0, 1.0)); + let mut lin = [0.0f32; 3]; + t.into.transform(v, &mut lin).ok()?; + let [from, to] = t.black; + if from > 0.0 || to > 0.0 { + lin = lin.map(|x| to + (x - from) * (1.0 - to) / (1.0 - from)); + } + let lin = lin.map(|x| x.clamp(0.0, 1.0)); let mut out = vec![0.0f32; dest.channels()]; - out_of.transform(&rgb, &mut out).ok()?; + t.out_of.transform(&lin, &mut out).ok()?; Some(out.into_iter().map(|x| x.clamp(0.0, 1.0)).collect()) } + fn build_same_model(&self, dest: &IccProfile, intent: Intent, bpc: bool) -> Option { + let mut linear = rgb_space(super::PROPHOTO_RGB)?; + for trc in [&mut linear.red_trc, &mut linear.green_trc, &mut linear.blue_trc] { + *trc = Some(ToneReprCurve::Parametric(vec![1.0])); + } + linear.cicp = None; + let into = self.profile.create_transform_f32(self.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?; + let out_of = linear.create_transform_f32(Layout::Rgb, &dest.profile, dest.layout(), Self::opts(intent)).ok()?; + let black = if bpc { + // Each profile's black: the darkest colour it reproduces, found as `build_direct` + // finds it, as luminance in the linear space. + let luminance = |p: &IccProfile, xf: &Arc| -> Option { + let inks = p.from_srgb([0.0; 3], Intent::RelativeColorimetric)?; + let mut lin = [0.0f32; 3]; + xf.transform(&inks, &mut lin).ok()?; + let y = |c: &moxcms::Xyzd| c.y as f32; + let lum = lin[0] * y(&linear.red_colorant) + lin[1] * y(&linear.green_colorant) + lin[2] * y(&linear.blue_colorant); + Some(lum.clamp(0.0, 0.5)) + }; + let back = dest.profile.create_transform_f32(dest.layout(), &linear, Layout::Rgb, Self::opts(intent)).ok()?; + [luminance(self, &into)?, luminance(dest, &back)?] + } else { + [0.0; 2] + }; + Some(SameModel { into, out_of, black }) + } + /// Whether transforms can be built for this profile (checked at registration). pub fn usable(&self) -> bool { self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some() diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index 2b8a82a36e..1b99035c36 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -261,9 +261,46 @@ fn cmyk_profiles_convert_into_each_other_directly() { let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap(); let srgb = IccProfile::from_bytes(None, &icc_bytes(SRGB).unwrap()).unwrap(); let inks = [0.2, 0.4, 0.6, 0.1]; - let out = generic.to_same_model_of(&device, &inks, Intent::RelativeColorimetric).unwrap(); + let out = generic.to_same_model_of(&device, &inks, Intent::RelativeColorimetric, false).unwrap(); assert_eq!(out.len(), 4); assert!(out.iter().zip(inks).any(|(a, b)| (a - b).abs() > 0.01), "{out:?}"); - assert!(generic.to_same_model_of(&srgb, &inks, Intent::RelativeColorimetric).is_none()); - assert!(generic.to_same_model_of(&device, &inks[..3], Intent::RelativeColorimetric).is_none()); + assert!(generic.to_same_model_of(&srgb, &inks, Intent::RelativeColorimetric, false).is_none()); + assert!(generic.to_same_model_of(&device, &inks[..3], Intent::RelativeColorimetric, false).is_none()); +} + +/// With black-point compensation, a CMYK profile's darkest ink converts into an ink as dark as a +/// darker profile's darkest ink, and a dark grey of a darker profile converts lighter into a +/// profile whose black is lighter; absolute colorimetric never compensates. +#[test] +fn cmyk_to_cmyk_compensates_the_black_point() { + let generic = IccProfile::from_bytes(Some("A".into()), &icc_bytes(GENERIC_CMYK).unwrap()).unwrap(); + let device = IccProfile::from_bytes(Some("B".into()), &icc_bytes(DEVICE_CMYK).unwrap()).unwrap(); + let rel = Intent::RelativeColorimetric; + let lightness = |p: &IccProfile, inks: &[f32]| lab::srgb_to_lab(p.to_srgb(inks, rel).unwrap()).l; + let black = |p: &IccProfile| p.from_srgb([0.0; 3], rel).unwrap(); + // Generic CMYK's black is a dark grey, the uncalibrated profile's is black. + let (light, dark) = (lightness(&generic, &black(&generic)), lightness(&device, &black(&device))); + assert!(light > dark + 5.0, "{light} vs {dark}"); + let into_device = |bpc| lightness(&device, &generic.to_same_model_of(&device, &black(&generic), rel, bpc).unwrap()); + let (on, off) = (into_device(true), into_device(false)); + assert!((on - dark).abs() < 1.5 && off > dark + 5.0, "with {on}, without {off}, the black {dark}"); + let grey = device.from_srgb([0.2; 3], rel).unwrap(); + let into_generic = |bpc| lightness(&generic, &device.to_same_model_of(&generic, &grey, rel, bpc).unwrap()); + let (on, off) = (into_generic(true), into_generic(false)); + assert!(on > off + 1.0, "with {on}, without {off}"); + for (from, to, inks) in [(&generic, &device, black(&generic)), (&device, &generic, grey)] { + let abs = |bpc| from.to_same_model_of(to, &inks, Intent::AbsoluteColorimetric, bpc).unwrap(); + assert_eq!(abs(true), abs(false)); + } + // Convert to Working Space (Cms::convert_into) compensates as Color Settings say. + let device_name = register_icc(&icc_bytes(DEVICE_CMYK).unwrap(), Some("Test ICC Device CMYK".into())).unwrap().name; + let rich = Color::Cmyk { c: 0.9, m: 0.8, y: 0.8, k: 1.0 }; + let convert = |bpc| { + let dest = Cms::new(&ColorSettings { cmyk: device_name.clone(), bpc, ..ColorSettings::default() }).unwrap(); + icc_cmyk(bpc).convert_into(&dest, &rich, rel) + }; + assert_ne!(convert(true), convert(false)); + // White is untouched. + let white = generic.to_same_model_of(&device, &[0.0; 4], rel, true).unwrap(); + assert!(white.iter().all(|v| *v < 0.01), "{white:?}"); } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 38b5cffea0..b53cdc89b4 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "Color Settings…", ["Edit"], Some("Cmd+Shift+K"), - "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool, rgbPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\", cmykPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\"|\"preserveNumbers\"} set the working spaces and the color management policies → {rgb, cmyk, intent, bpc, rgbPolicy, cmykPolicy, profiles: [{name, kind, builtin}]}. A policy decides what opening a document (document.open, File › Revert) does with an RGB or CMYK profile it is tagged with that is not the working space: preserveEmbedded (default) keeps it; off and preserveNumbers take it off and keep the color numbers; convert converts the colors of that model into the working space with the intent and takes it off. The open reply's warnings name each profile taken off or converted, and a document a policy changed opens with unsaved changes. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", + "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool, rgbPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\", cmykPolicy?: \"off\"|\"preserveEmbedded\"|\"convert\"|\"preserveNumbers\"} set the working spaces and the color management policies → {rgb, cmyk, intent, bpc, rgbPolicy, cmykPolicy, profiles: [{name, kind, builtin}]}. A policy decides what opening a document (document.open, file.newFromTemplate, file.revert) does with an RGB or CMYK profile it is tagged with that is not the working space: preserveEmbedded (default) keeps it; off and preserveNumbers take it off and keep the color numbers; convert converts the colors of that model into the working space with the intent and bpc and takes it off. The open reply's warnings name each profile taken off or converted, and a document a policy changed opens with unsaved changes. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults", always, color_settings ), diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index a46f46492a..4a58cf3721 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; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), 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, swatches, brushes, symbols, variables and data sets, and data sets' text values; 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), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {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), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .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) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. 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). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{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; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), 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, swatches, brushes, symbols, variables and data sets, and data sets' text values; 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, through the profiles the document keeps after the color management policies of edit.colorSettings, 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), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds), in a row, and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; the color management policies of edit.colorSettings keep, take off or convert from the profiles the file is tagged with, their notes go to warnings, and a document they changed opens with unsaved changes; 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), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .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) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. 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). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 065d6618dd..a767b3b5c4 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1352,7 +1352,7 @@ pub(super) fn specs() -> Vec { "New from Template…", ["File"], Some("Cmd+Shift+N"), - "{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document → {index, title, format, warnings}", + "{path} or {name, dataBase64}: open a template (or any readable file) as a new untitled document; the color management policies of edit.colorSettings apply as at document.open → {index, title, format, warnings (with the policies' notes)}", always, super::load::new_from_template ), diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index 988278b211..8ea057cd12 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -877,7 +877,8 @@ fn saved_color_settings_that_cannot_be_used_leave_the_defaults() { /// Off and Preserve Numbers take them off and keep the color numbers, and Convert to Working Space /// converts the colors into the working spaces, keeping how they look, and takes them off. A /// document a policy changed opens with unsaved changes, and the open reply names each profile. -/// A profile that is the working space stays, and File › Revert applies the policies too. +/// A profile that is the working space stays, and File › Revert and File › New from Template apply +/// the policies too. #[test] fn color_management_policies_keep_take_off_or_convert_a_tagged_profile() { let _g = settings(); @@ -989,6 +990,17 @@ fn color_management_policies_keep_take_off_or_convert_a_tagged_profile() { let r = exec(&mut reverted, "file.revert", json!({"confirmed": true})); assert_eq!(doc_profiles(&reverted.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), None)); assert_eq!((fill(&reverted, blue), notes(&r)), (blue0, 1)); + + // File › New from Template applies the policies as opening does. + let mut from_template = session(); + exec(&mut from_template, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "off"})); + let r = exec(&mut from_template, "file.newFromTemplate", json!({"path": tagged.to_str().unwrap()})); + assert_eq!(doc_profiles(&from_template.doc().unwrap().doc), (None, None)); + assert_eq!(notes(&r), 2); + assert_eq!(fill(&from_template, ink), ink0, "Off keeps the CMYK numbers"); + let blue2 = fill(&from_template, blue); + assert!(blue2 != blue0 && cms::delta_e2000(source.lab(&blue0), working.lab(&blue2)) < 1.0, "{blue0:?} → {blue2:?}"); + assert!(from_template.doc().unwrap().is_dirty()); } /// The policies are checked, come back in the reply and in `Color Settings.json`, and a saved file diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 6b7ce6b0e0..16bd7eaa2c 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -102,8 +102,8 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { } /// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, the RGB -/// and CMYK color management policies, Load -/// Profile… and Gamut Check, and the profile the active document is assigned. +/// and CMYK color management policies, Load Profile… and Gamut Check, and the profile the active +/// document is assigned. fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { let st = cms::working_settings(); widgets::section_header(ui, tl!("Color Settings")); diff --git a/docs/mcp.md b/docs/mcp.md index 5d545ef823..912006fb62 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1621,12 +1621,13 @@ preferences (the folder the desktop app uses, so both see the same ones; `VECTOR settings again at the next start, after the profiles above are registered (#6335). Saved settings that name a profile that is no longer there, or a damaged file, leave the defaults (logged), and the next change writes over the file; settings saved without policies take the default, `preserveEmbedded`. `rgbPolicy` (`off`, - `preserveEmbedded`, `convert`) and `cmykPolicy` (the same and `preserveNumbers`) decide what `document.open` and - `file.revert` do with an RGB or CMYK profile the file is tagged with that is not the working space: keep it, take - it off and keep the color numbers (`off`, `preserveNumbers`), or convert the colors of that model into the working - space with the intent and take it off (`convert`; a profile that is not available stays). The reply's `warnings` - name each profile, and a document a policy changed opens with unsaved changes. A profile that is the working space - stays whatever the policy. + `preserveEmbedded`, `convert`) and `cmykPolicy` (the same and `preserveNumbers`) decide what `document.open`, + `file.newFromTemplate` and `file.revert` do with an RGB or CMYK profile the file is tagged with that is not the + working space: keep it, take it off and keep the color numbers (`off`, `preserveNumbers`), or convert the colors + of that model into the working space with the intent and black point compensation and take it off (`convert`; a + profile that is not available stays). The reply's `warnings` name each profile, and a document a policy changed + opens with unsaved changes. A profile that is the working space stays whatever the policy. `document.open`'s + `colorMode` converts the colors after the policies, through the profiles the document keeps. `edit.assignProfile` tags the document only: colors show and convert through the profiles it assigns, and the working spaces and the file stay as they are. The profiles in use are those of the document in front, after `file.new`, `document.open`, `document.activate`, `file.close`, `file.revert`, `edit.undo`, `edit.redo` and a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0dee2a464f..7be890c57d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -745,7 +745,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Canvas through the display's profile on Windows and X11:** Preferences › Performance › Use Monitor Profile for Canvas (on by default) converts the canvas's art, artboard paper and transparency grid from sRGB into the ICC profile of the display that shows most of the main window, after any proof, as the help page describes the monitor's profile translating the document's colors for the monitor ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). Windows gives the display's default profile, and a display with Advanced Color on is left to Windows ([Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/wcs/advanced-color-icc-profiles)); X11 gives colord's profile for the output or the `_ICC_PROFILE` property. macOS converts the window itself, and a Wayland session stays sRGB. `ui.inspect` reports `displayProfile`. Tests in `color/src/cms/display.rs`, `ui-egui/src/display_profile.rs` and `canvas::tests::the_canvas_is_drawn_through_the_display_profile_while_the_preference_is_on`. - **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. - **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. With Window › Arrange › Tile or floating document windows, each document behind the front one draws its art, paper and transparency grid through its own assigned profiles, and loading or removing a profile redraws the documents assigned it; placed documents inside it still draw through the front document's profiles. How documents behind the front one draw is VectorCraft's own rule. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. -- **Color management policies:** Edit › Color Settings… (the Separations panel's Color Settings section and `edit.colorSettings`) has an RGB and a CMYK policy: Off, Preserve Embedded Profiles and Convert to Working Space, and Preserve Numbers for CMYK. Opening or reverting a document tagged with a profile other than the working space keeps it, takes it off with the color numbers kept, or converts that model's colors into the working space and takes it off. The policies are kept in `Color Settings.json`. Preserve Embedded Profiles as the default for both, and the per-model policy for a document tagged with both an RGB and a CMYK profile, are VectorCraft's own rules. Public reference: [Color management policy options](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf). Tests in `engine/tests/color_settings.rs` and `color/src/cms/tests.rs`. +- **Color management policies:** Edit › Color Settings… (the Separations panel's Color Settings section and `edit.colorSettings`) has an RGB and a CMYK policy: Off, Preserve Embedded Profiles and Convert to Working Space, and Preserve Numbers for CMYK. When a document tagged with a profile other than the working space is opened, reverted or opened as a template, the policy keeps the profile, takes it off with the color numbers kept, or converts that model's colors into the working space, with black point compensation as Color Settings set it, and takes it off. The policies are kept in `Color Settings.json`. Preserve Embedded Profiles as the default for both, and the per-model policy for a document tagged with both an RGB and a CMYK profile, are VectorCraft's own rules; placed images and the Profile Mismatches and Missing Profiles prompts are not covered. Public reference: [Color management policy options](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=157). Tests in `engine/tests/color_settings.rs`, `color/src/cms/tests.rs` and `color/src/cms/tests_icc.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `profile_errors_name_the_file_by_its_name_outside_the_read_roots`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). From 1f841dbeec08ba9f26fb2a1d63075fc3c7eab91a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:46:09 -0800 Subject: [PATCH 20/24] Variables: Variable Options changes a variable's kind as well as its name Variable Options in the Variables panel only renamed a variable, and no command changed a variable's kind. variable.rename now takes {name, newName?, kind?} and renames the variable, changes its kind, or both, in one undo step, with none when nothing changes. A null or empty value, an unknown kind, a taken name and locked variables are refused. A kind change unbinds the objects that can't hold the new kind (text keeps type with no other text variable, image keeps image objects, graph keeps graph roots, visibility keeps every object) and drops the variable's values in every data set. The dialog shows the variable's name and kind and sends both. --- crates/engine/src/cmd/variablecmds.rs | 87 ++++++++++--- crates/engine/src/tests/variables.rs | 104 ++++++++++++++++ crates/ui-egui/src/dialogs/variables.rs | 159 +++++++++++++++++++++--- crates/ui-egui/src/panels/variables.rs | 2 +- docs/mcp.md | 7 +- docs/target-app-parity.md | 1 + 6 files changed, 321 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index a73c16448b..a49800ff32 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -245,39 +245,92 @@ fn define(s: &mut Session, p: &Value) -> Result { Ok(json!({"name": name, "kind": kind.label()})) } +/// Whether the object `id`, bound to the variable `name`, can stay bound to it once the variable +/// is of `kind`: a text variable drives type that holds no other text variable, an image variable +/// drives an image object, a graph variable drives a graph root, and a visibility variable drives +/// any object. Locks and hidden layers don't count, as they don't unbind anything else either. +fn keeps_binding(doc: &Document, id: NodeId, name: &str, kind: VariableKind) -> bool { + let Some(n) = doc.node(id) else { return false }; + match kind { + VariableKind::Text => { + matches!(n.kind, NodeKind::Text(_)) + && !doc.variables.bindings.get(&id).is_some_and(|bound| { + doc.variables.variables.iter().any(|v| v.kind == VariableKind::Text && v.name != name && bound.contains(&v.name)) + }) + } + VariableKind::Visibility => true, + VariableKind::Image => matches!(n.kind, NodeKind::Image(_)), + VariableKind::Graph => n.extras().graph.is_some(), + VariableKind::Unknown => false, + } +} + +/// Variable Options: rename a variable (`newName`), change its kind (`kind`), or both, in one undo +/// step, carrying its bindings and its values in every data set to the new name. A kind change +/// unbinds the objects that can't hold the new kind ([`keeps_binding`]) and drops the variable's +/// value in every data set. A call that changes nothing adds no undo step. fn rename(s: &mut Session, p: &Value) -> Result { const C: &str = "variable.rename"; let from = var_name(p, C)?; - let to = named(p.get("newName").and_then(Value::as_str), "newName", C)?; - let st = s.doc()?; - if st.doc.variables.variable(&from).is_none() { - return Err(bad(C, format!("no variable named `{from}`"))); + // An omitted key is no change; a null, like any other value that isn't a name or a kind, is refused. + let to = match p.get("newName") { + None => None, + Some(v) => Some(named(v.as_str(), "newName", C)?), + }; + let kind = match p.get("kind") { + None => None, + Some(v) => Some(v.as_str().and_then(VariableKind::parse).ok_or_else(|| bad(C, "`kind` must be `text`, `visibility`, `image` or `graph`"))?), + }; + if to.is_none() && kind.is_none() { + return Err(bad(C, "missing `newName` or `kind`")); } - if st.doc.variables.variable(&to).is_some() { + let doc = &s.doc()?.doc; + let Some(old) = doc.variables.variable(&from) else { + return Err(bad(C, format!("no variable named `{from}`"))); + }; + let to = to.unwrap_or_else(|| from.clone()); + if to != from && doc.variables.variable(&to).is_some() { return Err(bad(C, format!("variable `{to}` already exists"))); } - s.edit("Rename Variable", |d, _| { - let v = d.variables.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?; + let kind = kind.unwrap_or(old.kind); + let retyped = kind != old.kind; + let unbound: Vec = + if retyped { doc.variables.objects_of(&from).into_iter().filter(|id| !keeps_binding(doc, *id, &from, kind)).collect() } else { Vec::new() }; + let label = if retyped { "Variable Options" } else { "Rename Variable" }; + let dropped = edit_variables(s, label, |vars| { + let v = vars.variables.iter_mut().find(|v| v.name == from).ok_or_else(|| bad(C, format!("no variable named `{from}`")))?; v.name = to.clone(); - for bound in d.variables.bindings.values_mut() { + v.kind = kind; + for id in &unbound { + vars.unbind(*id, &from); + } + for bound in vars.bindings.values_mut() { if bound.remove(&from) { bound.insert(to.clone()); } } - for ds in &mut d.variables.datasets { - if let Some(value) = ds.values.remove(&from) { - ds.values.insert(to.clone(), value); + let mut dropped = 0; + for ds in &mut vars.datasets { + match ds.values.remove(&from) { + // A rename alone carries every value, one that doesn't fit the kind included; a kind + // change drops them all, as none was given for the new kind. + Some(value) if !retyped => { + ds.values.insert(to.clone(), value); + } + Some(_) => dropped += 1, + None => {} } } - Ok(()) + Ok(dropped) })?; - // The highlight names a variable, so it follows the rename rather than going stale. + // The highlight names a variable, so it follows the rename rather than going stale. A call + // that leaves the name as it was leaves the revision alone too. let st = s.doc_mut()?; - if st.variables_highlight.as_deref() == Some(from.as_str()) { + if to != from && st.variables_highlight.as_deref() == Some(from.as_str()) { st.variables_highlight = Some(to.clone()); + st.revision += 1; } - st.revision += 1; - Ok(json!({"from": from, "name": to})) + Ok(json!({"from": from, "name": to, "kind": kind.label(), "unbound": unbound.iter().map(|i| i.0).collect::>(), "droppedValues": dropped})) } /// Make Text / Visibility / Image / Graph Dynamic: define a variable of that kind and bind the @@ -722,7 +775,7 @@ pub fn specs() -> Vec { "Rename Variable", ["Window", "Variables"], None, - "{name, newName} rename a variable, with its bindings and its values in every dataset; refused while the variables are locked", + "{name, newName?, kind?: text|visibility|image|graph} Variable Options: rename a variable, with its bindings and its values in every dataset, change its kind, or both, in one undo step (none when the name and the kind are the ones it has). A kind change unbinds every object that can't hold the new kind (a text variable keeps type that has no other text variable, an image variable keeps image objects, a graph variable keeps graph roots, a visibility variable keeps every object) and drops the variable's values in every dataset; locked and hidden objects keep their binding when they can hold it. Needs `newName` or `kind`, and a null or empty one is refused; refused while the variables are locked → {from, name, kind, unbound: [id…], droppedValues}", variables_unlocked, rename ), diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index bb5b73b2f2..622c5e3809 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -1063,6 +1063,110 @@ fn renaming_a_missing_name_onto_a_taken_one_reports_the_missing_name() { assert!(e.contains("no dataset named `Nope`"), "{e}"); } +/// Variable Options changes a variable's kind: the objects that can't hold the new kind are +/// unbound, the ones that can (locked ones too) stay bound, every value of the old kind goes, and +/// the change is one undo step. +#[test] +fn variable_options_changes_the_kind_and_unbinds_what_cant_hold_it() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + let other = text(&mut s, "Held"); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [t.0, r.0, other.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Title", "ids": [other.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A", "values": {"Show": false, "Title": "Bob"}})).unwrap(); + s.execute("dataset.new", &json!({"name": "B", "values": {"Show": true}})).unwrap(); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).unwrap(); + // The rectangle isn't type, and the other type object already has the text variable Title. + assert_eq!(v["unbound"], json!([r.0, other.0])); + assert_eq!((v["name"].clone(), v["kind"].clone(), v["droppedValues"].clone()), (json!("Show"), json!("text"), json!(2))); + assert_eq!(steps(&s), before + 1, "one undo step"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!(vars.objects_of("Show"), [t], "the locked type object stays bound"); + assert_eq!(vars.objects_of("Title"), [other], "the other variable keeps its binding"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!((sets["datasets"][0]["values"].clone(), sets["datasets"][1]["values"].clone()), (json!({"Title": {"text": "Bob"}}), json!({}))); + + s.execute("edit.undo", &json!({})).unwrap(); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!(vars.variable("Show").map(|v| v.kind), Some(vectorcraft_doc::VariableKind::Visibility)); + assert_eq!(vars.objects_of("Show"), [t, r, other]); + assert_eq!(vars.dataset("A").and_then(|d| d.values.get("Show")), Some(&vectorcraft_doc::DataValue::Visible(false))); + + // A new name and a new kind together are one undo step, and every visibility binding stays. + s.execute("variable.rename", &json!({"name": "Title", "newName": "Heading", "kind": "visibility"})).unwrap(); + assert_eq!(steps(&s), before + 1, "one step after the undo"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!( + (vars.variable("Heading").map(|v| v.kind), vars.objects_of("Heading")), + (Some(vectorcraft_doc::VariableKind::Visibility), vec![other]) + ); + // An image variable keeps only image objects. + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "image"})).unwrap(); + assert_eq!(v["unbound"], json!([t.0, r.0, other.0])); +} + +/// Variable Options with the variable's own name and kind changes nothing and adds no undo step; +/// a bad kind, a taken name, no change asked for and locked variables are refused. +#[test] +fn variable_options_refuses_bad_params_and_skips_a_change_that_changes_nothing() { + let mut s = session(); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + for p in [ + json!({"name": "Show", "newName": "Show"}), + json!({"name": "Show", "kind": "visibility"}), + json!({"name": "Show", "newName": "Show", "kind": "visible"}), + ] { + let v = s.execute("variable.rename", &p).unwrap(); + assert_eq!((v["unbound"].clone(), v["droppedValues"].clone()), (json!([]), json!(0)), "{p}"); + assert_eq!(steps(&s), before, "{p}: no undo step"); + } + for (p, want) in [ + (json!({"name": "Show", "kind": "unknown"}), "`kind` must be"), + (json!({"name": "Show", "kind": "number"}), "`kind` must be"), + (json!({"name": "Show", "kind": 1}), "`kind` must be"), + (json!({"name": "Show", "newName": "New", "kind": null}), "`kind` must be"), + (json!({"name": "Show", "newName": null, "kind": "text"}), "missing `newName`"), + (json!({"name": "Show", "newName": " ", "kind": "text"}), "missing `newName`"), + (json!({"name": "Show"}), "missing `newName` or `kind`"), + (json!({"name": "Show", "newName": "Other", "kind": "text"}), "already exists"), + (json!({"name": "Ghost", "kind": "text"}), "no variable named `Ghost`"), + ] { + let e = s.execute("variable.rename", &p).unwrap_err().to_string(); + assert!(e.contains(want), "{p}: {e}"); + } + // A rename alone carries a value that doesn't fit the kind, as a file may hold one. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc) + .variables + .datasets + .push(vectorcraft_doc::DataSet { name: "Odd".into(), values: [("Show".to_string(), vectorcraft_doc::DataValue::Text("x".into()))].into() }); + s.execute("variable.rename", &json!({"name": "Show", "newName": "Shown"})).unwrap(); + let odd = s.doc().unwrap().doc.variables.dataset("Odd").and_then(|d| d.values.get("Shown")).cloned(); + assert_eq!(odd, Some(vectorcraft_doc::DataValue::Text("x".into()))); + s.execute("edit.undo", &json!({})).unwrap(); + // A kind change drops it, though it fits the new kind: nobody gave it for that kind. + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).unwrap(); + assert_eq!(v["droppedValues"], 1); + assert!(s.doc().unwrap().doc.variables.dataset("Odd").is_some_and(|d| d.values.is_empty())); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("variable.lock", &json!({"locked": true})).unwrap(); + assert!(s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).is_err(), "refused while the variables are locked"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!((vars.variable("Show").map(|v| v.kind), vars.objects_of("Show")), (Some(vectorcraft_doc::VariableKind::Visibility), vec![r])); +} + /// `dataset.set` is how a value changes after the row exists: replacing it must not need the /// dataset to be deleted and rebuilt (which loses its place in the list and the active one). #[test] diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index a18fc8597c..dad6e3f43b 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -1,5 +1,7 @@ -//! The Variables dialogs (`variable.define`, `variable.bind`, `dataset.new`, `dataset.set` -//! and `dataset.select`), opened from Window › Variables or the command palette. +//! The Variables dialogs (`variable.define`, `variable.rename` as Variable Options, `variable.bind`, +//! `dataset.new`, `dataset.set`, `dataset.rename` and `dataset.select`), opened from Window › +//! Variables or the command palette. Variable Options shows the variable's name and kind, and OK +//! changes either or both. //! //! One dialog covers all of them: what it shows is what the command takes. A variable is a //! name and a kind; a dataset is a name and one field per variable — a text field for a @@ -24,6 +26,10 @@ pub const KIND: &str = "variables"; /// renames. const EDITED: &str = "__dataset"; +/// The field of Variable Options that names the variable its Name and Kind fields belong to. +/// When the Variable field names another one, the next frame fills them from that one. +const FILLED: &str = "__filled"; + /// The kinds a variable can have, as `variable.define` names them. const KINDS: [&str; 4] = ["text", "visibility", "image", "graph"]; @@ -50,7 +56,7 @@ fn opened(id: &str) -> Option<&'static str> { } /// The row `id` renames, which the dialog offers as a dropdown when the document has more than -/// one. Variable Options renames the one the panel has highlighted, so it starts there. +/// one. Variable Options edits the one the panel has highlighted, so it starts there. fn rename_of(id: &str) -> Option<&'static str> { Some(match id { "variable.rename" => "variable", @@ -116,8 +122,14 @@ pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() }); return true; } - fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default())); - fields.insert("newName".into(), json!("")); + let row = preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default(); + fields.insert(key.into(), json!(row)); + if key == "variable" { + // Variable Options starts on the variable's own name and kind. + fill_variable_options(&vars, row, &mut fields); + } else { + fields.insert("newName".into(), json!("")); + } } } fields.insert("__command".into(), json!(id)); @@ -126,6 +138,16 @@ pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool true } +/// Variable Options' Name and Kind fields for the variable `name`: its name, and its kind as +/// `variable.rename` takes it (empty for a variable of unknown kind, which OK leaves as it is). +/// [`FILLED`] records which variable they were filled for. +fn fill_variable_options(vars: &Variables, name: &str, fields: &mut serde_json::Map) { + let kind = vars.variable(name).map(|v| v.kind).filter(|k| *k != VariableKind::Unknown).map(VariableKind::label).unwrap_or_default(); + fields.insert("newName".into(), json!(name)); + fields.insert("kind".into(), json!(kind)); + fields.insert(FILLED.into(), json!(name)); +} + /// Whether `id` opens one of these dialogs. pub fn opens(id: &str) -> bool { opened(id).is_some() @@ -181,13 +203,25 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { text(ui, d, "name", 220.0); ui.end_row(); label(ui, tl!("Kind")); - // The kinds by their translated names; the field keeps the command's own word. - let labels = kind_labels(); - let shown = KINDS.iter().position(|k| *k == d.str("kind")).and_then(|i| labels.get(i)).copied().unwrap_or_default(); - if let Some(kind) = widgets::dropdown_names(ui, ("variables-dd", "kind"), shown, &labels, 220.0).and_then(|i| KINDS.get(i)) { - d.fields.insert("kind".into(), json!(kind)); + kind_dropdown(ui, d); + ui.end_row(); + } + // Variable Options: which variable, and its name and kind; picking another variable + // starts its fields over from that one. + "variable.rename" => { + label(ui, tl!("Variable")); + dropdown(ui, d, "variable", &variable_names, 220.0); + let picked = d.str("variable"); + if picked != d.str(FILLED) { + fill_variable_options(vars, &picked, &mut d.fields); } ui.end_row(); + label(ui, tl!("Name")); + text(ui, d, "newName", 220.0); + ui.end_row(); + label(ui, tl!("Kind")); + kind_dropdown(ui, d); + ui.end_row(); } "variable.bind" => { label(ui, tl!("Variable")); @@ -199,15 +233,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { dropdown(ui, d, "name", &dataset_names, 220.0); ui.end_row(); } - // A rename: which row, and its new name. - "variable.rename" | "dataset.rename" => { - let (key, label_text, names) = if d.str("__command") == "variable.rename" { - ("variable", tl!("Variable").to_string(), &variable_names) - } else { - ("name", tl!("Data Set").to_string(), &dataset_names) - }; - label(ui, &label_text); - dropdown(ui, d, key, names, 220.0); + // Rename Data Set: which row, and its new name. + "dataset.rename" => { + label(ui, tl!("Data Set")); + dropdown(ui, d, "name", &dataset_names, 220.0); ui.end_row(); label(ui, tl!("New Name")); text(ui, d, "newName", 220.0); @@ -245,6 +274,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The Kind dropdown: the kinds by their translated names, while the `kind` field keeps the +/// command's own word. An empty field (a variable of unknown kind) shows no kind. +fn kind_dropdown(ui: &mut egui::Ui, d: &mut Dialog) { + let labels = kind_labels(); + let shown = KINDS.iter().position(|k| *k == d.str("kind")).and_then(|i| labels.get(i)).copied().unwrap_or_default(); + if let Some(kind) = widgets::dropdown_names(ui, ("variables-dd", "kind"), shown, &labels, 220.0).and_then(|i| KINDS.get(i)) { + d.fields.insert("kind".into(), json!(kind)); + } +} + /// A text field for `key`, written back as it is typed. fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response { let mut s = d.str(key); @@ -263,8 +302,26 @@ fn set_text(d: &mut Dialog, key: &str, s: String) { /// Records that `field` was typed in or set by `ui.dialog.set`: it no longer holds the row's /// empty text value, so emptying it unsets the variable. +/// +/// In Variable Options, setting the Variable field clears Name and Kind, which OK reads as the +/// variable's own name and kind, until a frame fills them from it; setting Name or Kind ties them +/// to the variable already named, so no frame fills them over. pub(super) fn note_set(d: &mut Dialog, field: &str) { d.fields.remove(&empty_marker(field)); + if d.str("__command") == "variable.rename" { + match field { + // Null rather than removed, so `ui.dialog.set` can still set them. + "variable" => { + d.fields.insert("newName".into(), Value::Null); + d.fields.insert("kind".into(), Value::Null); + } + "newName" | "kind" => { + let row = d.str("variable"); + d.fields.insert(FILLED.into(), json!(row)); + } + _ => {} + } + } } /// A checkbox for `key`, written back as it is toggled, which screen readers call `name`. @@ -329,7 +386,18 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let p = match cmd.as_str() { "variable.define" => json!({"name": name, "kind": d.str("kind")}), "variable.bind" => json!({"variable": d.str("variable")}), - "variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}), + // A Name the Variable field just cleared keeps the variable's name (an emptied one is sent, + // and refused), and a cleared or empty Kind (a variable of unknown kind) keeps its kind. + "variable.rename" => { + let variable = d.str("variable"); + let new_name = if matches!(d.fields.get("newName"), Some(Value::Null) | None) { variable.clone() } else { d.str("newName") }; + // Any other kind value goes to the command, which refuses one it doesn't know. + match d.fields.get("kind") { + None | Some(Value::Null) => json!({"name": variable, "newName": new_name}), + Some(Value::String(k)) if k.is_empty() => json!({"name": variable, "newName": new_name}), + Some(k) => json!({"name": variable, "newName": new_name, "kind": k}), + } + } "dataset.rename" => json!({"name": name, "newName": d.str("newName")}), "dataset.new" | "dataset.set" => { let values = app.session.active().map(|st| values_of(d, &st.doc.variables)).unwrap_or_else(|| json!({})); @@ -550,4 +618,55 @@ mod tests { open(&mut app, "dataset.rename", Some("Row 1")); assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Row 1"); } + + #[test] + fn variable_options_shows_the_name_and_kind_and_changes_either() { + let mut app = app_with_variables(); + let kind_of = |app: &VectorcraftApp, name: &str| app.session.active().and_then(|st| st.doc.variables.variable(name).map(|v| v.kind)); + open(&mut app, "variable.rename", Some("Badge")); + { + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("newName"), d.str("kind")), ("Badge".to_string(), "visibility".to_string())); + } + app.ui.dialog.as_mut().unwrap().fields.insert("kind".into(), json!("text")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(kind_of(&app, "Badge"), Some(VariableKind::Text)); + + // OK with nothing changed adds no undo step, and an emptied Name is refused. + let undo = app.session.active().unwrap().history.undo.len(); + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "newName".into(), json!("")); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(kind_of(&app, "Title").is_some(), "Title keeps its name"); + + // Setting the Variable field keeps that variable's own name and kind until Name or Kind is + // set, and a frame fills them from it. + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "variable".into(), json!("Badge")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((kind_of(&app, "Title"), kind_of(&app, "Badge")), (Some(VariableKind::Text), Some(VariableKind::Text))); + open(&mut app, "variable.rename", Some("Title")); + { + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::set_field(d, "variable".into(), json!("Badge")); + assert!(crate::dialogs::accepts_field(d, "newName") && crate::dialogs::accepts_field(d, "kind"), "Name and Kind stay settable"); + crate::dialogs::set_field(d, "newName".into(), json!("Flag")); + crate::dialogs::set_field(d, "kind".into(), json!("visibility")); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((kind_of(&app, "Flag"), kind_of(&app, "Title")), (Some(VariableKind::Visibility), Some(VariableKind::Text))); + + // A frame after the Variable field changes fills Name and Kind from that variable. + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "variable".into(), json!("Flag")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("newName"), d.str("kind")), ("Flag".to_string(), "visibility".to_string())); + } } diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 3ab5fc2484..ac3254048a 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -272,7 +272,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::menu_item(ui, tl!("New Variable…"), !locked, false) { crate::menus::invoke(app, "variable.define", json!({})); } - // Variable Options renames the highlighted variable, else the first. + // Variable Options edits the name and kind of the highlighted variable, else the first. if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty() && !locked, false) { let name = highlighted.clone().or_else(|| vars.first().cloned()).unwrap_or_default(); crate::dialogs::variables::open(app, "variable.rename", Some(&name)); diff --git a/docs/mcp.md b/docs/mcp.md index 8ca4121032..67150a63ed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3331,7 +3331,12 @@ already walks Image Trace. Short agent entry points: `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic` (`variable.bind` and `variable.makeTextDynamic` refuse type that already has another text variable and name it), `variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph - values CSV as `graph.setData`). `variable.lock {locked?}` (Lock Variables) locks or unlocks the variables; + values CSV as `graph.setData`). `variable.rename {name, newName?, kind?}` is Variable Options: it renames a + variable, changes its kind, or both, in one undo step, and adds none when the name and kind are the ones it has. + A kind change unbinds each object that can't hold the new kind (a text variable keeps type with no other text + variable, an image variable keeps image objects, a graph variable keeps graph roots, a visibility variable keeps + every object; locks and hidden layers don't unbind anything) and drops the variable's values in every data set. + That rule is VectorCraft's own. `variable.lock {locked?}` (Lock Variables) locks or unlocks the variables; without `locked` it toggles, and the lock is saved with the document. While the variables are locked, `variable.define`, `variable.delete`, `variable.rename`, the four `variable.make…Dynamic` commands and a `variable.loadLibrary` that would delete or define a variable (below) fail with "variables are locked"; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f4424700be..08c8102458 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -824,6 +824,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: Import Data Sets… (`dataset.import`) defines the variables a file's header names that the document lacks, unbound. `#Shown` defines a visibility variable, `@Photo` defines an image variable, and a header without a prefix defines a text variable. A header that is a variable's exact name keeps naming it, and any other `%` header (graph data files) is refused. - Variables: Load Variable Library… in the Variables panel menu (`variable.loadLibrary`) replaces the document's variables and data sets with a variable library's, read from its XML file, in one undo step: every data set is deleted, and every variable is deleted with its bindings and values, except a variable the library also declares with the same kind, which stays bound so the library's data sets change its objects (#4136); then the library's other variables are defined, unbound: text, visibility, image (linked file) and graph variables, and variables without a known `trait` as unknown. Its data sets take text values from each value's `p` elements, one paragraph each, visibility values from `true` or `false`, image values from a file's absolute path or `file:` URL, and graph values from a `data` element of series names and rows. - Variables: Save Variable Library… in the Variables panel menu (`variable.saveLibrary`) writes the document's variables of every kind and its data sets as a variable library's XML file, which Load Variable Library reads back with the same variables, kinds, data set names and values. + - Variables: Variable Options… in the Variables panel menu, or a double click on a variable, shows the variable's name and kind, and OK changes either or both in one undo step (`variable.rename {name, newName?, kind?}`), as the [Work with variables](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/work-with-variables.html) page describes. VectorCraft's own rule for a kind change: objects that can't hold the new kind are unbound, and the variable's values in every data set are dropped. - JSON-lines control channel with real egui pointer and keyboard injection. - Variables: a type object holds one text variable. `variable.bind` and Make Text Dynamic refuse a second one and name the text variable the object has; a visibility variable can still share the object, and a document saved with two text variables on one object opens with both. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). From 37052dd07a2ad97e99a9feb4484470609945c4e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 23:04:09 -0800 Subject: [PATCH 21/24] Type: Composite Fonts takes custom, entry and char from ui.dialog.set on open The dialog opened without the custom, entry and char fields, so ui.dialog.set refused them until Custom was clicked or an entry typed. The spec now lists them in its inputs, and the dialog opens with custom off and an empty entry. --- crates/ui-egui/src/dialogs/composite_fonts.rs | 15 ++++++++++--- .../src/dialogs/composite_fonts/tests.rs | 21 +++++++++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/composite_fonts.rs b/crates/ui-egui/src/dialogs/composite_fonts.rs index 31472503b2..6e3b6f84e1 100644 --- a/crates/ui-egui/src/dialogs/composite_fonts.rs +++ b/crates/ui-egui/src/dialogs/composite_fonts.rs @@ -42,12 +42,21 @@ const CHARS_PER_ROW: usize = 20; /// and a few custom sets. const TABLE_HEIGHT: f32 = 340.0; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Composite Fonts").into(), body, confirm, min_width: 760.0, ..DialogSpec::FORM }; +/// `char` is held only while a character is picked, so `ui.dialog.set` takes it from `inputs`. +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Composite Fonts").into(), + body, + confirm, + min_width: 760.0, + inputs: |_| &["custom", "entry", "char"], + ..DialogSpec::FORM +}; -/// Open Composite Fonts on the active document's composite fonts, the first one picked. +/// Open Composite Fonts on the active document's composite fonts, the first one picked, with the +/// custom character sets hidden and nothing typed. pub fn open(app: &mut VectorcraftApp) -> Result { let fonts = app.session.active().ok_or("no document open")?.doc.composite_fonts.to_vec(); - let mut d = Dialog::new(KIND, json!({ "fonts": fonts, "selected": 0, "entryMode": "direct" })); + let mut d = Dialog::new(KIND, json!({ "fonts": fonts, "selected": 0, "custom": false, "entryMode": "direct", "entry": "" })); show_selected(&mut d); app.ui.dialog = Some(d); Ok(Value::Null) diff --git a/crates/ui-egui/src/dialogs/composite_fonts/tests.rs b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs index b4d89c64ea..e50ce1b566 100644 --- a/crates/ui-egui/src/dialogs/composite_fonts/tests.rs +++ b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs @@ -244,6 +244,27 @@ fn custom_character_sets_are_edited_in_the_dialog() { assert_eq!((f.custom.len(), f.custom[0].name.as_str(), f.custom[0].characters.as_str()), (1, "Letters", "Aあ")); } +/// `ui.dialog.set` takes `custom`, `entry` and `char` as soon as the dialog opens, before Custom is +/// clicked or anything is typed, and Add then adds the characters it set. +#[test] +fn custom_set_fields_are_set_right_after_open() { + let mut app = app(); + let ctx = egui::Context::default(); + let font = + json!({"name": "UI Composite Open", "parts": {"kanji": {"font": "Inter"}}, "custom": [{"name": "Marks", "characters": "", "font": "Inter"}]}); + app.run(COMMAND, json!({ "fonts": [font] })).unwrap(); + open(&mut app).unwrap(); + for (field, value) in [("custom", json!(true)), ("entry", json!("※〒")), ("char", json!(0))] { + let (req, _) = crate::control::ControlRequest::new("ui.dialog.set", json!({"field": field, "value": value})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert!(r.get("error").is_none(), "{field}: {r}"); + assert_eq!(dialog(&mut app).fields.get(field), Some(&value), "{field}"); + } + frame(&mut app); + press(&mut app, Action::AddChars); + assert_eq!(sets(&mut app)[0].characters, "※〒"); +} + /// Two sets of the same name keep the dialog open and change nothing. #[test] fn custom_sets_sharing_a_name_keep_the_dialog_open() { diff --git a/docs/mcp.md b/docs/mcp.md index 1549d77362..8a0651f067 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9333,7 +9333,8 @@ picked. Custom shows the font's custom character sets: Character Set picks one, Delete Set takes it out, and Name renames it. Character › Direct Entry adds the characters typed, and Character › Unicode adds the characters of hexadecimal codes (`3042`, `U+3042`, several separated by spaces or commas); Add skips characters the set already lists. Delete takes the character picked out of the set. OK runs `type.compositeFonts.set` -with every font as one undo step; a refused font keeps the dialog open. +with every font as one undo step; a refused font keeps the dialog open. `ui.dialog.set` takes `custom`, `entry` and +`char` (the index of the character picked in the set) as soon as the dialog opens. ```json {"name":"run_command","arguments":{"command":"type.compositeFonts.set","params":{"fonts":[{"name":"My Composite","parts":{"kanji":{"font":"Source Han Sans"},"roman":{"font":"Source Sans 3","size":95,"baseline":-2},"numerals":{"font":"Source Sans 3"}}}]}}} From 5e4794a65ccc315a4e9fd65f47cf331af5d97370 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 23:04:17 -0800 Subject: [PATCH 22/24] Align: a rejected select.key keeps the key anchor that was set select.key {id, anchor} set the new key anchor before checking it, so a point that is in the selection but not on the path cleared the key anchor and then returned an error. The point is now checked first: an anchor the path does not have is not a selected anchor point, and the other conditions are checked on a copy of the selection. --- crates/engine/src/cmd/select.rs | 14 ++++++++++---- crates/engine/src/tests/keyanchor.rs | 13 +++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 9aa304e96e..7c8bbdda4d 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -670,14 +670,20 @@ fn key_anchor(s: &mut Session, id: Option, guide: Option, v: &Val return Err(bad(C, "give `anchor` or `guide`, not both")); } let id = id.ok_or_else(|| bad(C, "`anchor` needs `id`, the path the point is on"))?; - let sel = &s.doc()?.selection; - if sel.segments.contains_key(&id) || !sel.anchors.get(&id).is_some_and(|set| set.contains(&r)) { + let st = s.doc()?; + let sel = &st.selection; + let on_path = st.doc.node(id).and_then(|n| n.path_data()).is_some_and(|p| p.subpaths.get(r.0).is_some_and(|sp| r.1 < sp.anchors.len())); + if !on_path || sel.segments.contains_key(&id) || !sel.anchors.get(&id).is_some_and(|set| set.contains(&r)) { return Err(bad(C, format!("anchor [{}, {}] of object {id} is not a selected anchor point", r.0, r.1))); } - s.select(|_, sel| sel.key_anchor = Some((id, r)))?; - if s.doc()?.selection.key_anchor.is_none() { + // Check the new key on a copy first, so a request that fails leaves the key as it was. + let mut trial = sel.clone(); + trial.key_anchor = Some((id, r)); + trial.prune(&st.doc); + if trial.key_anchor.is_none() { return Err(bad(C, "select another anchor point to align to the key anchor")); } + s.select(|_, sel| sel.key_anchor = Some((id, r)))?; key_result(s) } diff --git a/crates/engine/src/tests/keyanchor.rs b/crates/engine/src/tests/keyanchor.rs index afa321ff73..0dc6675f2a 100644 --- a/crates/engine/src/tests/keyanchor.rs +++ b/crates/engine/src/tests/keyanchor.rs @@ -147,3 +147,16 @@ fn select_key_rejects_an_anchor_that_cannot_be_the_key() { assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("is not a selected anchor point")); assert_eq!(key_anchor(&s), None, "the key stays as it was"); } + +#[test] +fn a_rejected_select_key_keeps_the_key_anchor() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0], [0, 9]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((a, (0, 1)))); + // [0, 9] is in the selection but not on the path. + let err = s.execute("select.key", &json!({"id": a.0, "anchor": [0, 9]})).expect_err("a point the path does not have").to_string(); + assert!(err.contains("is not a selected anchor point"), "{err}"); + assert_eq!(key_anchor(&s), Some((a, (0, 1))), "the key stays as it was"); +} From a11bd64909190f36bfab40d076b95c690d54265c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 23:04:26 -0800 Subject: [PATCH 23/24] Guides: a container's ruler guides keep their order through its effects A layer or group with a Pathfinder effect or geometry effects moves its ruler guides above the evaluated art. Its trailing guides were split off first and its guides among the art were added after them, so [guide A, art, guide B] came out as B, A. All of its direct guides are now gathered in one pass, in document order. --- crates/effects/src/group.rs | 12 +++++++----- crates/effects/src/group/tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 0e37be9ded..d2a6687dd7 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -832,13 +832,15 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { return None; } let mut m = n.clone(); - // Its ruler guides take no part: they go back above the evaluated art at the end. - let mut guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); - // So do its other guides and those in its sublayers and groups, which would otherwise go with - // the art the effects replace or copy: they stay on `n`, hidden and locked as their containers - // were. + // Its ruler guides take no part: they go back above the evaluated art at the end. With effects + // that replace or copy the art, so do its guides among the art and those in its sublayers and + // groups, which would otherwise go with that art: they stay on `n`, in order, hidden and + // locked as their containers were. + let mut guides = vec![]; if pathfinder || !fx.is_empty() { take_sublayer_guides(&mut m, &mut guides, &Node::is_ruler_guide); + } else if let Some(ch) = m.children_mut() { + guides = ch.split_off(Node::guide_run_start(ch)); } if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index b2b802cc44..50fbf8f759 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -1998,6 +1998,35 @@ fn a_ruler_guide_in_a_group_survives_its_containers_effects() { } } +/// The ruler guides of a layer or group with a Pathfinder effect or Transform copies, one among +/// its art and one after it, go after the evaluated art in the order they were in. +#[test] +fn a_containers_ruler_guides_keep_their_order_through_its_effects() { + use vectorcraft_doc::LayerColor; + let guide = |id| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None })); + for (effect, params) in [("pathfinder.add", serde_json::json!({})), ("distort.transform", serde_json::json!({"moveH": 30, "copies": 2}))] { + let members = || vec![sq(1, 0.0, Color::BLACK), guide(20), sq(2, 5.0, Color::BLACK), guide(21)]; + let fx = Effect { id: effect.into(), params: params.clone(), visible: true }; + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = members(); + } + l.appearance.effects.push(fx.clone()); + let mut g = Node::group(NodeId(11), members()); + g.appearance.effects.push(fx); + for (what, n) in [("layer", l), ("group", g)] { + let art = evaluate_container(None, &n).unwrap(); + let mut all = vec![]; + ruler_guides_in(&art, &mut all); + let ids = all.iter().map(|(id, ..)| *id).collect::>(); + assert_eq!(ids, [NodeId(20), NodeId(21)], "{effect} on a {what}: the guides, in order"); + let ch = art.children().unwrap(); + let tail = ch[ch.len() - 2..].iter().map(|c| c.id).collect::>(); + assert_eq!(tail, [NodeId(20), NodeId(21)], "{effect} on a {what}: last, in order"); + } + } +} + /// A group's own Fill + Offset Path below its contents follows the outline of a clip group among /// its members, its clipping path, not the clipped art it hides outside that path (#7289). #[test] From 63dab14ee72d65bfb0d992fc2f52fd3a8801a054 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 22:49:32 -0800 Subject: [PATCH 24/24] Image Trace: Abutting traces draw seam fills under their paths An Abutting trace with Fills now holds a "Seam Fills" group under its paths. For each color it has one stroke, 1.5 image pixels wide, along the edges where a filled path of that color meets a filled path drawn after it, in that color. A renderer that anti-aliases each path on its own no longer shows a light hairline along those edges. The traced paths and their shared edges do not change, and edges against the image's border, a transparent area or a color left out get no stroke. - vectorcraft-trace: `seam_fills` finds the segments two filled paths share and strokes them in the lower path's color (one stroke per gradient path). Overlapping, traces without Fills and Flat Logo get none. - Image Trace commands: the group comes under every path (right above the hidden image in a live trace). Expand keeps it, Expand by color leaves it under the color groups, and a trace whose paths have more than half of the anchor limit gets no seam fills. Docs: docs/mcp.md, the params text, docs/target-app-parity.md. --- crates/engine/src/cmd/buildcmds.rs | 2 +- crates/engine/src/cmd/buildcmds/trace.rs | 108 ++++++++- crates/engine/src/tests/build.rs | 110 +++++++-- crates/trace/src/lib.rs | 2 + crates/trace/src/seam.rs | 277 +++++++++++++++++++++++ docs/mcp.md | 10 + docs/target-app-parity.md | 1 + 7 files changed, 479 insertions(+), 31 deletions(-) create mode 100644 crates/trace/src/seam.rs diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index f694a2b144..7784c8b2b4 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\" (abutting with fills, not mode logo: a \"Seam Fills\" group under the paths holds 1.5 px strokes along the edges two filled paths share, in the color of the lower path, so that no light hairline shows along those edges; the paths are not changed), ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index a117bd453e..e9288918e8 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -22,6 +22,22 @@ fn is_trace_group(n: &Node) -> bool { && n.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) } +/// The name of the group that holds an Abutting trace's seam fills (see [`tr::TraceResult::seams`]). +const SEAM_FILLS_NAME: &str = "Seam Fills"; + +/// Whether `n` can be a trace's seam fills group: a group named as one, or a group of stroked paths +/// without fills, so that it is still found after it is renamed or its strokes are edited. (Auto +/// Grouping groups hold filled paths.) +fn is_seam_fills(n: &Node) -> bool { + let stroke_only = |m: &Arc| { + matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && m.appearance.fill_paint() == Paint::None + && m.appearance.stroke_paint() != Paint::None + }; + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && (n.name.as_deref() == Some(SEAM_FILLS_NAME) || n.children().is_some_and(|c| !c.is_empty() && c.iter().all(stroke_only))) +} + const LIVE_PATHFINDER_BY_COLOR: &str = "byColor can't regroup a trace with a live Pathfinder effect: expand without byColor"; /// The `view` param ([`TraceView`] id), if given. @@ -393,11 +409,15 @@ fn by_color_param(cmd: &str, p: &Value) -> Result { /// (by the boxes of everything they paint, effects included, within one point); otherwise it /// starts another group of that paint, which keeps the stacking order of those items. Items /// farther apart can still share antialiased pixels at small view or raster-export scales, so -/// their edge pixels may change. A group among `items` (Auto Grouping) is refused. +/// their edge pixels may change. The seam fills' group, when it is the lowest item, stays as it is, +/// under every other group. Any other group among `items` (Auto Grouping) is refused. fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result>> { use std::collections::{BTreeMap, HashMap}; use std::ops::Bound::{Excluded, Unbounded}; use vectorcraft_color::tint_percent; + // The seam fills come first, under every path: kept there, they move under nothing. + let mut items = items; + let seams: Vec> = if items.first().is_some_and(|n| is_seam_fills(n)) { items.drain(..1).collect() } else { vec![] }; // Grouped by the paint: unlinked (its #rrggbb), linked (the swatch and its exact tint), a // gradient, or another paint (its label). The name is apart from it: a swatch may be named as // a color's `#rrggbb` or as "Gradients". @@ -517,14 +537,15 @@ fn color_groups(cmd: &str, d: &mut Document, items: Vec>) -> Result> = groups .into_iter() .map(|run| { let mut g = Node::group(d.alloc_id(), run.members); g.name = Some(run.name); Arc::new(g) }) - .collect()) + .collect(); + Ok(seams.into_iter().chain(groups).collect()) } /// What a trace depends on: the document it is made in, the object traced (an image, or an Image @@ -608,11 +629,13 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { Ok(Prepared { inputs, trace: params, view, params_json, preset_label, library }) } -/// What a trace made of an image: the size in pixels it was decoded at, and the paths. +/// What a trace made of an image: the size in pixels it was decoded at, the paths, and their seam +/// fills ([`seams_within`]). pub struct Traced { inputs: TraceInputs, raster: (u32, u32), res: tr::TraceResult, + seams: Vec, } impl Traced { @@ -636,7 +659,18 @@ fn trace_now(inputs: &TraceInputs, params: &tr::TraceParams, steps: &(dyn Fn(u64 tr::TraceError::Cancelled => EngineError::Cancelled, e => EngineError::Other(e.to_string()), })?; - Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res }) + let seams = seams_within(&res, params, MAX_TRACE_ANCHORS); + Ok(Traced { inputs: inputs.clone(), raster: (raster.width, raster.height), res, seams }) +} + +/// The seam fills of `res`, traced with `params` ([`tr::seam_fills`]), while the paths and the seam +/// fills together stay within `max` anchors: the seam fills have at most as many anchors as the +/// paths, so a trace whose paths have more than half of `max` gets none. +fn seams_within(res: &tr::TraceResult, params: &tr::TraceParams, max: usize) -> Vec { + if res.anchor_count() > max / 2 { + return vec![]; + } + tr::seam_fills(&res.paths, params) } /// An Image Trace made away from its command, off the UI thread (#3098). [`TraceRun::prepare`] @@ -716,7 +750,7 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result traced, _ => trace_now(&inputs, &trace, &|_, _| true)?, }; @@ -729,6 +763,13 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Result> = seam_fills + .iter() + .filter_map(|tp| { + let width = tp.stroke?; + let mut n = shape_node(d, tp.path.transformed(xf), None); + n.appearance = Appearance { + items: vec![AppearanceItem::Stroke(StrokeLayer { + cap: LineCap::Butt, + join: LineJoin::Round, + ..StrokeLayer::new(paint_of(tp), width * seam_unit) + })], + ..Default::default() + } + .into(); + Some(Arc::new(n)) + }) + .collect(); + if !seams.is_empty() { + let mut g = Node::group(d.alloc_id(), seams); + g.name = Some(SEAM_FILLS_NAME.into()); + children.push(Arc::new(g)); + } let mut traced = Vec::with_capacity(res.paths.len()); for tp in res.paths { + let paint = paint_of(&tp); let path = tp.path.transformed(xf); let mut n = shape_node(d, path, None); // Shapes: a path the tracer found to be a shape is that live shape. @@ -764,10 +835,6 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Appearance { @@ -1103,9 +1170,28 @@ mod color_group_tests { #[cfg(test)] mod trace_limit_tests { - use super::{MAX_TRACE_ANCHORS, keep_warnings, trace_warnings}; + use super::{MAX_TRACE_ANCHORS, keep_warnings, seams_within, trace_warnings}; use vectorcraft_trace as tr; + #[test] + fn seam_fills_count_against_the_anchor_limit() { + // Two colors side by side, the edge between them anti-aliased: one seam fill along it. + let img = tr::Raster::from_fn(24, 16, |x, _| { + let share = (10.4 - f64::from(x)).clamp(0.0, 1.0); + let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8; + [mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255] + }); + let params = tr::TraceParams { mode: tr::Mode::Color, colors: 2, method: tr::Method::Abutting, ..tr::TraceParams::default() }; + let res = tr::trace(&img, ¶ms); + let all = tr::seam_fills(&res.paths, ¶ms); + assert!(!all.is_empty(), "{res:?}"); + let anchors = res.anchor_count(); + // Twice the paths' anchors leaves room for any seam fills. + assert_eq!(seams_within(&res, ¶ms, 2 * anchors), all); + // One anchor short of that: none. + assert_eq!(seams_within(&res, ¶ms, 2 * anchors - 1), []); + } + #[test] fn keep_warnings_name_matched_colors_that_produce_no_paths() { let params = tr::TraceParams { keep_colors: vec!["#ff0000".into()], ..tr::TraceParams::default() }; diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 243dce52e8..2b2578a85e 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -5341,8 +5341,9 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node .unwrap() } +/// The traced paths of an Image Trace object: its children but the image and the seam fills. fn traced_paths(n: &Node) -> Vec> { - n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect() + n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_)) && c.name.as_deref() != Some("Seam Fills")).cloned().collect() } #[test] @@ -5961,6 +5962,73 @@ fn image_trace_release_and_expand() { assert!(s.execute("imageTrace.release", &json!({})).is_err()); } +/// Abutting with Fills: the strokes along the edges the paths share sit in a "Seam Fills" group +/// under the paths, in the lower path's color, and stay under them through Expand by color. +#[test] +fn image_trace_abutting_seam_fills_sit_under_the_paths() { + // Red beside blue, the edge between them anti-aliased. + let art = vectorcraft_trace::Raster::from_fn(40, 20, |x, _| { + let share = (16.4 - f64::from(x)).clamp(0.0, 1.0); + let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8; + [mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255] + }); + let seams = |n: &Node| n.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Seam Fills")).cloned(); + let made = |method: &str, cmd: &str, by_color: bool| { + let mut s = session(); + let img = add_image(&mut s, &art, Affine::scale(2.0)); + let params = json!({"mode": "color", "palette": "limited", "colors": 2, "method": method}); + let mut p = json!({ "params": params }); + if by_color { + p["byColor"] = json!(true); + } + let r = s.execute(cmd, &p).unwrap(); + (s, img, r) + }; + let (mut s, _, r) = made("abutting", "imageTrace.make", false); + assert_eq!(r["paths"], 2, "{r}"); + let g = NodeId(r["id"].as_u64().unwrap()); + let n = node(&s, g); + let kids = n.children().unwrap(); + assert_eq!(kids.len(), 4, "the image, the seam fills and two paths"); + assert!(matches!(kids[0].kind, NodeKind::Image(_))); + let group = seams(&n).unwrap(); + assert!(Arc::ptr_eq(&group, &kids[1]), "the seam fills come under the paths"); + let strokes = group.children().unwrap(); + assert_eq!(strokes.len(), 1); + assert_eq!(strokes[0].appearance.fill_paint(), Paint::None); + let AppearanceItem::Stroke(layer) = &strokes[0].appearance.items[0] else { panic!("{:?}", strokes[0].appearance) }; + assert_eq!((layer.cap, layer.join, layer.width), (LineCap::Butt, LineJoin::Round, 3.0), "1.5 image pixels, the image scaled 2×"); + assert_eq!(layer.paint, kids[2].appearance.fill_paint(), "the lower path's color"); + // The stroke runs along the edge, from the image's top to its bottom. + let bb = outline(&strokes[0]).0.bounds().unwrap(); + assert!((bb.center().x - 32.8).abs() < 1.0 && bb.y0.abs() < 1e-9 && (bb.y1 - 40.0).abs() < 1e-9, "{bb:?}"); + // Expand by color keeps the seam fills under the color groups, renamed too; Release gives the + // image back. + s.execute("layer.setProps", &json!({"id": group.id.0, "name": "Edges"})).unwrap(); + s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); + let n = node(&s, g); + let names: Vec<_> = n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect(); + assert_eq!(names.len(), 3, "{names:?}"); + assert_eq!(names[0], "Edges", "{names:?}"); + assert_eq!(n.children().unwrap()[0].id, group.id); + let (mut s, img, _) = made("abutting", "imageTrace.make", false); + assert_eq!(ids(&s.execute("imageTrace.release", &json!({})).unwrap()), vec![img]); + // Make and Expand by color too. + let (s, _, r) = made("abutting", "imageTrace.makeAndExpand", true); + let n = node(&s, NodeId(r["id"].as_u64().unwrap())); + assert_eq!(n.children().unwrap()[0].name.as_deref(), Some("Seam Fills")); + // Overlapping paths are stacked: no seam fills. + let (s, _, r) = made("overlapping", "imageTrace.make", false); + assert!(seams(&node(&s, NodeId(r["id"].as_u64().unwrap()))).is_none()); + // An image stretched 3× across and squeezed to half height: 1.5 pixels at the larger scale. + let mut s = session(); + add_image(&mut s, &art, Affine::scale_non_uniform(3.0, 0.5)); + let r = s.execute("imageTrace.make", &json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "method": "abutting"}})).unwrap(); + let group = seams(&node(&s, NodeId(r["id"].as_u64().unwrap()))).unwrap(); + let AppearanceItem::Stroke(layer) = &group.children().unwrap()[0].appearance.items[0] else { panic!("{group:?}") }; + assert!((layer.width - 4.5).abs() < 1e-9, "{}", layer.width); +} + /// Expand by color: each color's paths in a group of their own, named by the color, the groups /// stacked by the lowest path in each and the art drawn as before; Release to Layers then gives a /// sublayer for each color. @@ -5995,7 +6063,11 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { }; let (flat, fg) = expand(false); let (mut s, g) = expand(true); - let paths = node(&flat, fg).children().unwrap().clone(); + let kids = node(&flat, fg).children().unwrap().clone(); + // Abutting: the seam fills' group comes first, under every path, with byColor too. + let seams = usize::from(method == "abutting"); + assert!(kids.iter().take(seams).all(|c| c.name.as_deref() == Some("Seam Fills")), "{method}"); + let paths = kids[seams..].to_vec(); // Without byColor (the default) the paths sit in the trace's group, with no group of their own. assert!(paths.iter().all(|p| !matches!(p.kind, NodeKind::Group { .. })), "{method}"); let (default, dg) = { @@ -6004,7 +6076,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { let r = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); (s, NodeId(r["id"].as_u64().unwrap())) }; - assert!(*node(&default, dg).children().unwrap() == paths, "{method}: byColor is false when not given"); + assert!(*node(&default, dg).children().unwrap() == kids, "{method}: byColor is false when not given"); // The colors in the order they first come in, bottom to top. let mut order: Vec = vec![]; for p in &paths { @@ -6019,10 +6091,11 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { // Overlapping fills the ring whole and draws the field inside it above the ring: that path starts a second // group of the field's color, above the black one. let want: Vec = if method == "overlapping" { order.iter().chain(order.first()).cloned().collect() } else { order.clone() }; - assert_eq!(names(&n), want, "{method}"); + assert_eq!(names(&n)[seams..], want, "{method}"); + assert!(n.children().unwrap().iter().take(seams).all(|c| *c == kids[0]), "{method}: the seam fills stay as they are"); // Each group holds its color's paths, and in turn they are the paths of the trace without // byColor, in their order. - let groups = n.children().unwrap(); + let groups = &n.children().unwrap()[seams..]; for c in groups { assert!(matches!(c.kind, NodeKind::Group { .. }), "{method}"); assert!(c.children().unwrap().iter().all(|m| Some(hex(m)) == c.name), "{method}: {:?}", c.name); @@ -6033,8 +6106,8 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { // Release to Layers: a sublayer for each color, in the same order. let r = s.execute("layer.releaseToLayers", &json!({"id": g.0})).unwrap(); let subs = ids(&r); - assert_eq!(subs.len(), want.len(), "{method}"); - for (sub, name) in subs.iter().zip(&want) { + assert_eq!(subs.len(), seams + want.len(), "{method}"); + for (sub, name) in subs[seams..].iter().zip(&want) { let l = node(&s, *sub); assert!(matches!(l.kind, NodeKind::Layer { .. }), "{method}"); assert_eq!(names(&l), std::slice::from_ref(name), "{method}"); @@ -6051,7 +6124,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); let mut got = names(&node(&s, g)); got.sort(); - assert_eq!(got, ["#000000", "#c82832", "#f0f0dc"]); + assert_eq!(got, ["#000000", "#c82832", "#f0f0dc", "Seam Fills"]); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); @@ -6086,7 +6159,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { }); add_image(&mut s, &stripes, Affine::IDENTITY); let g = NodeId(s.execute("imageTrace.make", &json!({"params": grouped})).unwrap()["id"].as_u64().unwrap()); - assert!(node(&s, g).children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Group { .. }))); + assert!(traced_paths(&node(&s, g)).iter().all(|c| !matches!(c.kind, NodeKind::Group { .. }))); let e = s.execute("imageTrace.expand", &json!({"byColor": true})).unwrap_err().to_string(); assert!(e.contains("Auto Grouping"), "{e}"); assert_eq!(node(&s, g).name.as_deref(), Some("Image Trace")); @@ -6103,8 +6176,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { add_image(&mut s, &r, Affine::IDENTITY); let limited = json!({"mode": "color", "palette": "limited", "colors": 3}); let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); - let paths: Vec<(u64, String)> = - node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| (c.id.0, hex(c))).collect(); + let paths: Vec<(u64, String)> = traced_paths(&node(&s, g)).iter().map(|c| (c.id.0, hex(c))).collect(); let mut colors: Vec<&str> = paths.iter().map(|(_, h)| h.as_str()).collect(); colors.sort(); colors.dedup(); @@ -6116,14 +6188,14 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); let mut got = names(&node(&s, g)); got.sort(); - assert_eq!(got, ["#28a03c", "Ink", "Sky 50%"]); + assert_eq!(got, ["#28a03c", "Ink", "Seam Fills", "Sky 50%"]); // A swatch named as another color's #rrggbb still gets a group of its own. s.execute("edit.undo", &json!({})).unwrap(); s.execute("swatch.edit", &json!({"name": "Ink", "newName": "#28a03c"})).unwrap(); s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); let mut got = names(&node(&s, g)); got.sort(); - assert_eq!(got, ["#28a03c", "#28a03c", "Sky 50%"]); + assert_eq!(got, ["#28a03c", "#28a03c", "Seam Fills", "Sky 50%"]); // Gradient fills share one group, and a stroked line goes by its stroke. let ramp = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| { @@ -6192,7 +6264,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { add_image(&mut s, &stripes, Affine::IDENTITY); let limited = json!({"mode": "color", "palette": "limited", "colors": 3, "method": "abutting"}); let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); - let paths: Vec> = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect(); + let paths = traced_paths(&node(&s, g)); assert_eq!(paths.len(), 3); let (first, middle, last) = (&paths[0], &paths[1], &paths[2]); s.execute("paint.setFill", &json!({"ids": [last.id.0], "color": hex(first)})).unwrap(); @@ -6220,7 +6292,7 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { for how in ["move", "transform", "shadow", "beside"] { let (flat, _) = edited(false, how); let (s, g) = edited(true, how); - assert_eq!(names(&node(&s, g)).len(), 3, "{how}"); + assert_eq!(names(&node(&s, g)).len(), 4, "{how}: the seam fills and three colors"); assert!(shot(&s) == shot(&flat), "{how}: the edited art changed"); } @@ -6230,14 +6302,14 @@ fn image_trace_expand_by_color_groups_each_colors_paths() { add_image(&mut s, &stripes, Affine::IDENTITY); let limited = json!({"mode": "color", "palette": "limited", "colors": 3}); let g = NodeId(s.execute("imageTrace.make", &json!({"params": limited})).unwrap()["id"].as_u64().unwrap()); - let paths: Vec = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| c.id.0).collect(); + let paths: Vec = traced_paths(&node(&s, g)).iter().map(|c| c.id.0).collect(); assert_eq!(paths.len(), 3); s.execute("paint.setFill", &json!({"ids": [paths[0]], "swatch": "Sky", "tint": 50.1})).unwrap(); s.execute("paint.setFill", &json!({"ids": [paths[1], paths[2]], "swatch": "Sky", "tint": 50.4})).unwrap(); s.execute("imageTrace.expand", &json!({"id": g.0, "byColor": true})).unwrap(); let groups = node(&s, g).children().unwrap().clone(); - assert_eq!(names(&node(&s, g)), ["Sky 50%", "Sky 50%"]); - assert_eq!(groups.iter().map(|c| c.children().unwrap().len()).collect::>(), [1, 2]); + assert_eq!(names(&node(&s, g)), ["Seam Fills", "Sky 50%", "Sky 50%"]); + assert_eq!(groups.iter().skip(1).map(|c| c.children().unwrap().len()).collect::>(), [1, 2]); } #[test] @@ -6289,7 +6361,7 @@ fn image_trace_make_and_expand_and_color_presets() { assert_eq!(res["paths"], 4); let g = node(&s, NodeId(res["id"].as_u64().unwrap())); assert!(g.name.is_none()); - assert_eq!(g.children().unwrap().len(), 4); + assert_eq!(traced_paths(&g).len(), 4); assert!(s.execute("imageTrace.make", &json!({"preset": "No Such Preset"})).is_err()); assert!(s.execute("imageTrace.make", &json!({"params": {"mode": "rainbow"}})).is_err()); } diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 0d99a2acfc..b489e4dd82 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -57,6 +57,7 @@ mod logo; mod mosaic; mod opaque; mod quantize; +mod seam; mod shapes; mod smooth; mod subpixel; @@ -71,6 +72,7 @@ pub use gradient::TracedGradient; pub use mosaic::mosaic; pub use opaque::{ALPHA_MAX_DECODE_BYTES, OUTLINE_MAX_ANCHORS, OUTLINE_MAX_PIXELS, decode_alpha, opaque_outline}; pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize}; +pub use seam::{SEAM_WIDTH, seam_fills}; pub use shapes::TracedShape; use subpixel::Crack; diff --git a/crates/trace/src/seam.rs b/crates/trace/src/seam.rs new file mode 100644 index 0000000000..5a1ffa96c5 --- /dev/null +++ b/crates/trace/src/seam.rs @@ -0,0 +1,277 @@ +//! Abutting with Fills: seam fills, thin strokes under the filled paths along the edges they share. +//! +//! Abutting paths meet edge to edge, so a renderer that anti-aliases each path on its own leaves +//! the pixels along a shared edge partly covered by each path and lets what is behind them show +//! through as a light hairline. A stroke along the edge in the color of the path drawn first, +//! under every path, fills those pixels with that color: drawing the two paths over it then gives +//! each pixel the colors of the two paths in the shares each covers. The paths themselves are not +//! changed, and an edge a path shares with no other path (the image's border, a transparent area, +//! a color left out) gets no stroke. + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use vectorcraft_geom::{Anchor, PathData, Point, SubPath}; + +use super::params::{Method, Mode, TraceParams}; +use super::pipeline::TracedPath; + +/// How wide each seam fill's stroke is, in pixels: it covers every pixel the shared edge passes +/// through, at any angle (a pixel's corner is up to 0.71 px from a line through it). +pub const SEAM_WIDTH: f64 = 1.5; + +/// A segment, the same for the segment and the segment run backwards: two 64-bit hashes of its +/// points' bits, 16 bytes a segment instead of 64. Two different segments with the same key would +/// only give the lower one's color a stroke along a piece it does not share. +type Key = u128; + +fn key(a: &Anchor, b: &Anchor) -> Key { + let bits = |[p, q, r, s]: [Point; 4]| [p.x, p.y, q.x, q.y, r.x, r.y, s.x, s.y].map(f64::to_bits); + let points = bits([a.p, a.h_out, b.h_in, b.p]).min(bits([b.p, b.h_in, a.h_out, a.p])); + let hash = |seed: u8| { + let mut h = DefaultHasher::new(); + (seed, points).hash(&mut h); + h.finish() + }; + (u128::from(hash(0)) << 64) | u128::from(hash(1)) +} + +/// The seam fills of a trace made with `params`: for Abutting with Fills (not Flat Logo), thin +/// strokes to draw under every path of `paths` (bottom-most first). For each color, one path +/// stroked [`SEAM_WIDTH`] pixels wide along the edges where a filled path of that color meets a +/// filled path drawn after it, with `color` and `gradient` that path's; the paths with a gradient +/// get one each. Bottom-most first, as the paths they lie under. The stroke has at most as many +/// anchors as `paths` (each of its segments is a segment of a path), and finding them keeps about +/// 40 bytes for each segment of `paths` while it runs. Empty for any other trace. +pub fn seam_fills(paths: &[TracedPath], params: &TraceParams) -> Vec { + if params.method != Method::Abutting || !params.fills || params.mode == Mode::Logo { + return vec![]; + } + fills(paths) +} + +/// The segments of `sp`: each anchor and the one after it (back to the first, if closed). +fn segments(sp: &SubPath) -> impl Iterator { + let n = sp.anchors.len(); + let count = if sp.closed { n } else { n.saturating_sub(1) }; + (0..count).filter_map(move |k| Some((sp.anchors.get(k)?, sp.anchors.get((k + 1) % n)?))) +} + +/// [`seam_fills`] of Abutting `paths`, whatever the settings. +fn fills(paths: &[TracedPath]) -> Vec { + // For each segment, the first path it is in, and whether another path has it too. + let mut owner: HashMap = HashMap::new(); + for (i, p) in paths.iter().enumerate().filter(|(_, p)| p.stroke.is_none()) { + for sp in &p.path.subpaths { + for (a, b) in segments(sp).filter(|(a, b)| a.p != b.p) { + let e = owner.entry(key(a, b)).or_insert((i, false)); + if e.0 != i { + e.1 = true; + } + } + } + } + // One fill per color (per path, for a gradient), in the order of the first path it lies under. + let mut out: Vec = vec![]; + let mut by_color: HashMap<[u8; 3], usize> = HashMap::new(); + for (i, p) in paths.iter().enumerate().filter(|(_, p)| p.stroke.is_none()) { + let mut runs: Vec = vec![]; + for sp in &p.path.subpaths { + let lower = |a: &Anchor, b: &Anchor| a.p != b.p && owner.get(&key(a, b)) == Some(&(i, true)); + let marks: Vec = segments(sp).map(|(a, b)| lower(a, b)).collect(); + let n = sp.anchors.len(); + if marks.is_empty() { + continue; + } + if sp.closed && marks.iter().all(|&m| m) { + runs.push(SubPath::new(sp.anchors.clone(), true)); + continue; + } + // Start after an unmarked segment, so that a run across the subpath's first anchor + // stays one run. + let start = if sp.closed { marks.iter().position(|&m| !m).map_or(0, |k| k + 1) } else { 0 }; + let mut run: Vec = vec![]; + for j in 0..marks.len() { + let k = (start + j) % marks.len(); + let (Some(&a), Some(&b)) = (sp.anchors.get(k), sp.anchors.get((k + 1) % n)) else { continue }; + if marks.get(k).copied().unwrap_or(false) { + if run.is_empty() { + run.push(Anchor { h_in: a.p, ..a }); + } + run.push(b); + } else if !run.is_empty() { + runs.push(open_run(std::mem::take(&mut run))); + } + } + if !run.is_empty() { + runs.push(open_run(run)); + } + } + if runs.is_empty() { + continue; + } + let slot = if p.gradient.is_some() { None } else { by_color.get(&p.color).copied() }; + match slot.and_then(|s| out.get_mut(s)) { + Some(fill) => fill.path.subpaths.extend(runs), + None => { + if p.gradient.is_none() { + by_color.insert(p.color, out.len()); + } + out.push(TracedPath { + path: PathData::new(runs), + color: p.color, + pixels: 0, + stroke: Some(SEAM_WIDTH), + shape: None, + gradient: p.gradient.clone(), + square_caps: false, + }); + } + } + } + out +} + +/// An open run of anchors: the last one has no handle out. +fn open_run(mut run: Vec) -> SubPath { + if let Some(last) = run.last_mut() { + last.h_out = last.p; + } + SubPath::new(run, false) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Method, Mode, Raster, TraceParams, trace}; + + fn polygon(rings: &[&[(f64, f64)]], color: u8) -> TracedPath { + let subpaths = rings.iter().map(|r| SubPath::polyline(&r.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), true)).collect(); + TracedPath { path: PathData::new(subpaths), color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None, square_caps: false } + } + + fn points(sp: &SubPath) -> Vec<(f64, f64)> { + sp.anchors.iter().map(|a| (a.p.x, a.p.y)).collect() + } + + /// Each segment of `p`'s subpaths, by [`key`]. + fn keys(p: &TracedPath) -> Vec { + p.path.subpaths.iter().flat_map(|sp| segments(sp).map(|(a, b)| key(a, b)).collect::>()).collect() + } + + #[test] + fn a_shared_edge_gets_a_stroke_in_the_color_of_the_path_drawn_first() { + let left = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10); + let right = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0)]], 20); + let fills = fills(&[left.clone(), right.clone()]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!((fills[0].color, fills[0].stroke, fills[0].gradient.as_ref()), ([10; 3], Some(SEAM_WIDTH), None)); + assert_eq!(fills[0].path.subpaths.len(), 1); + let sp = &fills[0].path.subpaths[0]; + assert!(!sp.closed); + assert_eq!(points(sp), [(2.0, 0.0), (2.0, 2.0)]); + // Drawn the other way round, the stroke takes the other color. + let swapped = super::fills(&[right, left]); + assert_eq!(swapped.len(), 1); + assert_eq!((swapped[0].color, points(&swapped[0].path.subpaths[0])), ([20; 3], vec![(2.0, 2.0), (2.0, 0.0)])); + } + + #[test] + fn an_island_gets_a_closed_stroke_in_the_color_around_it() { + let hole: &[(f64, f64)] = &[(2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0)]; + let around = polygon(&[&[(0.0, 0.0), (6.0, 0.0), (6.0, 6.0), (0.0, 6.0)], hole], 10); + let island = polygon(&[&[(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0)]], 20); + let fills = fills(&[around, island]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!(fills[0].color, [10; 3]); + assert_eq!(fills[0].path.subpaths.len(), 1); + assert!(fills[0].path.subpaths[0].closed); + assert_eq!(points(&fills[0].path.subpaths[0]), hole); + } + + #[test] + fn a_shared_run_across_a_subpaths_first_anchor_stays_one_stroke() { + // The left square starts halfway along the edge it shares; the right one splits that edge + // at the same point. + let left = polygon(&[&[(2.0, 1.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0), (2.0, 0.0)]], 10); + let right = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0), (2.0, 1.0)]], 20); + let fills = fills(&[left, right]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!(fills[0].path.subpaths.len(), 1); + assert_eq!(points(&fills[0].path.subpaths[0]), [(2.0, 0.0), (2.0, 1.0), (2.0, 2.0)]); + } + + #[test] + fn edges_shared_with_no_other_filled_path_get_no_stroke() { + let square = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10); + assert_eq!(fills(std::slice::from_ref(&square)), []); + // A stroked line along the square's edge is not a filled neighbour. + let mut line = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0)]], 20); + line.stroke = Some(1.0); + assert_eq!(fills(&[square, line]), []); + } + + #[test] + fn paths_of_one_color_share_one_stroke_and_gradients_keep_their_own() { + let a = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10); + let b = polygon(&[&[(0.0, 4.0), (2.0, 4.0), (2.0, 6.0), (0.0, 6.0)]], 10); + let c = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 6.0), (2.0, 6.0), (2.0, 4.0), (2.0, 2.0)]], 20); + let fills = fills(&[a, b, c]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!(fills[0].path.subpaths.iter().map(points).collect::>(), [vec![(2.0, 0.0), (2.0, 2.0)], vec![(2.0, 4.0), (2.0, 6.0)]]); + } + + /// Two colors side by side, the edge between them anti-aliased at `x = 10.4`. + fn two_colors() -> Raster { + Raster::from_fn(24, 16, |x, _| { + let share = (10.4 - f64::from(x)).clamp(0.0, 1.0); + let mix = |a: f64, b: f64| (a * share + b * (1.0 - share)).round() as u8; + [mix(200.0, 30.0), mix(40.0, 90.0), mix(50.0, 200.0), 255] + }) + } + + fn color(method: Method) -> TraceParams { + TraceParams { mode: Mode::Color, colors: 2, method, ignore_white: false, ..TraceParams::default() } + } + + #[test] + fn an_abutting_trace_strokes_the_edges_its_filled_paths_share() { + let params = color(Method::Abutting); + let res = trace(&two_colors(), ¶ms); + assert_eq!(res.paths.len(), 2, "{res:?}"); + let seams = seam_fills(&res.paths, ¶ms); + assert_eq!(seams.len(), 1, "{seams:?}"); + let seam = &seams[0]; + assert_eq!((seam.color, seam.stroke), (res.paths[0].color, Some(SEAM_WIDTH))); + // Every piece of the stroke is a piece of both paths, and it runs from the image's top to + // its bottom along the edge, not along the image's border. + let (lower, upper) = (keys(&res.paths[0]), keys(&res.paths[1])); + assert!(keys(seam).iter().all(|k| lower.contains(k) && upper.contains(k))); + assert!(seam.path.anchor_count() <= res.anchor_count()); + for a in seam.path.subpaths.iter().flat_map(|sp| &sp.anchors) { + assert!((a.p.x - 10.4).abs() < 0.5, "{a:?}"); + } + let ys: Vec = seam.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p.y)).collect(); + assert_eq!((ys.iter().copied().fold(f64::INFINITY, f64::min), ys.iter().copied().fold(f64::NEG_INFINITY, f64::max)), (0.0, 16.0)); + } + + #[test] + fn only_abutting_fills_get_seam_strokes() { + let seams = |img: &Raster, params: &TraceParams| seam_fills(&trace(img, params).paths, params); + let img = two_colors(); + assert_eq!(seams(&img, &color(Method::Overlapping)), []); + assert_eq!(seams(&img, &TraceParams { fills: false, strokes: true, ..color(Method::Abutting) }), []); + // Flat Logo's shapes are stacked, whatever the method says. + let abutting = trace(&img, &color(Method::Abutting)); + assert_eq!(seam_fills(&abutting.paths, &TraceParams { mode: Mode::Logo, ..color(Method::Abutting) }), []); + // A color left out leaves no neighbour: a dark bar on white, with Ignore White. + let bar = Raster::from_fn(24, 16, |x, _| if (8..16).contains(&x) { [0, 0, 0, 255] } else { [255; 4] }); + let bw = TraceParams { mode: Mode::BlackAndWhite, method: Method::Abutting, ignore_white: true, ..TraceParams::default() }; + let res = trace(&bar, &bw); + assert_eq!(res.paths.len(), 1, "{res:?}"); + assert_eq!(seam_fills(&res.paths, &bw), []); + // Both colors traced, the bar's two sides are shared. + let both = seams(&bar, &TraceParams { ignore_white: false, ..bw }); + assert_eq!(both.iter().map(|s| s.path.subpaths.len()).sum::(), 2, "{both:?}"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 9336ea35ca..224067dc33 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3691,6 +3691,16 @@ already walks Image Trace. Short agent entry points: black ring around a white disc around a black dot, black gets two groups. `layer.releaseToLayers` on the expanded group then gives each group a sublayer. A trace made with `autoGrouping` is refused. Expanding by color also refuses a trace with a live Pathfinder effect. + An Abutting trace with Fills (not Flat Logo) also holds a group named `Seam Fills` under its paths: for each color, a + stroke 1.5 image pixels wide (at the image's larger scale, when it is stretched more one way) with butt caps and round joins along the edges where a filled path of that color meets a + filled path drawn after it, in that color (a gradient path's stroke takes its gradient). Without it, a renderer that + anti-aliases each path on its own shows a light hairline along every shared edge. The traced paths and their shared + edges are the same with or without it, and edges against the image's border, a transparent area or a color left out + get no stroke. A trace whose paths have more than half of the trace's anchor limit (8,000,000, 2,000,000 on the web) + has no seam fills, so that paths and seam fills together stay within the limit. Both Expand commands keep the group, and `byColor` leaves it under every color's group. The strokes + are separate objects: after Expand, recoloring, moving or deleting a traced path leaves them as they are, and deleting + the group removes them. The reply's + `paths` and `anchors` count the traced paths only. With `keepColors` in `params`, the reply's `warnings` name each listed color that no traced color matches, and say when nothing was traced. `imageTrace.presets.save {name?, newName?, preset?, enhanced?, params?}` saves tracing settings as a preset kept with diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 90d82894db..cd96cc3ead 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1476,6 +1476,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. - **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`. - **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`. +- **Image Trace, Abutting seam fills:** an Abutting trace with Fills holds a `Seam Fills` group under its paths, with 1.5 px strokes along the edges two filled paths share, in the lower path's color (VectorCraft's own width), so renderers that anti-alias each path on its own show no light hairline along those edges. The paths and their shared edges are unchanged. Tests: `seam::tests` in `crates/trace`, `image_trace_abutting_seam_fills_sit_under_the_paths` in `crates/engine`. Scores and hours unchanged. - **Image Trace, Auto Color rare colours:** the Automatic palette keeps colours too rare for its area count when they are distinct (Oklab) and drawn as coherent regions, at most six, protected from mixed-colour merging and Noise; code-drawn stem, highlight, low-contrast and scattered-noise tests (see [quality notes](image-trace-quality.md#auto-color-keeps-rare-colours)). - **Image Trace, nearly flat dark grounds:** with a Limited palette of 9 to 16 colors (16 Colors, and Document Library without swatches), by VectorCraft's own rule, a cut whose two sides both have a dark gray mean (Oklab lightness under 0.30, chroma under 0.03) and lie less than 0.03 apart in Oklab has its gain scaled by (distance / 0.03)². A near-black ground with faint noise then traces in one color, and the color it would have taken goes to another cut. Code-drawn tests `a_nearly_flat_dark_ground_keeps_one_color`, `only_a_limited_palette_of_nine_to_sixteen_colors_weighs_alike_sides` and `a_dark_colored_figure_on_a_nearly_flat_dark_ground_keeps_its_color` in `crates/trace`. - **Image Trace, Document Library keeps neutral tones neutral:** each pixel takes the library color nearest in Oklab with differences in hue and chroma counted three times, so a gray or a muted brown traces as a neutral library color instead of a saturated one of similar lightness; a code-drawn test of gray and brown patches with a black, light and blue library in `crates/trace`.