Merge main into fix/separations-spot-stop-plates

This commit is contained in:
dotCooCoo
2026-11-14 04:47:00 -08:00
93 changed files with 2156 additions and 370 deletions
Generated
+1
View File
@@ -6823,6 +6823,7 @@ dependencies = [
"skrifa 0.47.0",
"svgtypes 0.16.1",
"thiserror 2.0.21",
"unicode-bidi",
"usvg 0.48.1",
"vectorcraft-brush",
"vectorcraft-color",
+3 -26
View File
@@ -108,37 +108,14 @@ impl eframe::App for App {
self.app.start_pending_update();
#[cfg(target_os = "macos")]
super::mac_cursor::release();
// Dropping the app can wait for the clipboard handles to let go: the windows leave the
// screen first (`quit_windows`).
#[cfg(not(any(target_os = "macos", windows)))]
hide_before_teardown(self.winit_window.as_deref());
super::quit_windows::before_teardown(self.winit_window.as_deref());
end_before_teardown(self.frames);
}
}
/// The main window as [`hide_before_teardown`] sees it.
#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) trait QuittingWindow {
fn hide(&self);
}
#[cfg(not(any(target_os = "macos", windows)))]
impl QuittingWindow for winit::window::Window {
fn hide(&self) {
self.set_visible(false);
}
}
/// On Linux and the BSDs, hide the main window before eframe drops the app. Dropping the app waits
/// up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the clipboard handles to let go, and
/// the window would stay on screen for that time. The window is hidden, not closed: the Wayland
/// clipboard reads text on the window's connection. winit hides X11 windows (XWayland included) and
/// cannot hide a Wayland window.
#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) fn hide_before_teardown<W: QuittingWindow + ?Sized>(window: Option<&W>) {
if let Some(window) = window {
window.hide();
}
}
impl App {
/// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a
/// focused, shown window whose windows had input waits for the renderer and the next frame,
+7 -3
View File
@@ -60,6 +60,8 @@ mod portal_request;
mod portal_save;
mod prefs_dir;
mod printing;
#[cfg(not(any(target_os = "macos", windows)))]
mod quit_windows;
#[cfg(not(target_os = "macos"))]
mod single_instance;
#[cfg(windows)]
@@ -90,8 +92,6 @@ mod titlebar;
use app::bring_window_forward;
use app::native_input_hook;
use app::open_or_hold;
#[cfg(all(test, not(any(target_os = "macos", windows))))]
use app::{QuittingWindow, hide_before_teardown};
use host_services::services;
#[cfg(all(test, feature = "wgpu"))]
use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes};
@@ -122,7 +122,7 @@ struct App {
/// Keeps the custom title bar's window borderless (Windows, #1758).
window_frame: Option<window_frame::WindowFrameFn>,
/// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635),
/// and to hide the window on quit before the clipboard handles let go (`app::hide_before_teardown`).
/// and to take the window off screen on quit before the clipboard handles let go (`quit_windows`).
winit_window: Option<std::sync::Arc<winit::window::Window>>,
/// A saved or fitted geometry asked for maximize once the window is mapped (Linux).
pending_maximize: bool,
@@ -418,6 +418,10 @@ fn main() -> std::process::ExitCode {
}));
options
};
// Quitting takes the X11 windows of floating panels and documents off screen with the main
// window, so eframe's windows are recorded as it makes and drops them (`quit_windows`).
#[cfg(target_os = "linux")]
let options = eframe::NativeOptions { viewport_hooks: Some(std::sync::Arc::new(quit_windows::Hooks)), ..options };
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
+6 -5
View File
@@ -242,7 +242,7 @@ fn parent(window: &winit::window::Window) -> (String, Option<()>) {
/// (<https://wayland.app/protocols/xdg-foreign-unstable-v2>), made as rfd's `window_identifier`
/// makes it.
#[cfg(target_os = "linux")]
mod wayland {
pub(crate) mod wayland {
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::protocol::wl_surface::WlSurface;
@@ -308,9 +308,10 @@ mod wayland {
}
}
/// `window`'s Wayland connection and surface, from its raw handles.
/// `window`'s Wayland connection and surface, from its raw handles. The caller holds `window`
/// while it uses them (`quit_windows` unmaps the main window's surface through them).
#[allow(unsafe_code)]
fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
pub(crate) fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
use wayland_backend::sys::client::{Backend, ObjectId};
use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle};
let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) =
@@ -319,8 +320,8 @@ mod wayland {
return None;
};
// SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller
// holds it until the export is dropped); a backend made from a foreign display does not
// close it when dropped.
// holds it while it uses the connection, and the export holds it until it is dropped); a
// backend made from a foreign display does not close it when dropped.
let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) };
let conn = Connection::from_backend(backend);
// SAFETY: the pointer is the window's live `wl_surface` proxy on that display.
+280
View File
@@ -0,0 +1,280 @@
//! Takes the app's windows off screen when it quits on Linux and the BSDs, before eframe drops the
//! app. Dropping the app waits up to [`vectorcraft_ui_egui::CLIPBOARD_PROBE_STOP_WAIT`] for the
//! clipboard handles to let go, and eframe closes the windows only after that wait.
//!
//! The main window is hidden on X11 (XWayland included). winit cannot hide a Wayland window, so on
//! Linux a Wayland main window is unmapped instead: its surface gets no buffer, and the window stays
//! open with the connection the Wayland clipboard copies and reads on. On Linux the X11 windows of
//! floating panels and documents are recorded as eframe creates and drops them ([`Hooks`]), and
//! quitting unmaps them through an X11 connection of its own. On native Wayland, floating panels and
//! documents are normally drawn inside the main window.
#[cfg(target_os = "linux")]
use std::collections::HashMap;
#[cfg(target_os = "linux")]
use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError};
#[cfg(target_os = "linux")]
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
/// The main window as quitting sees it.
pub(crate) trait QuittingWindow {
/// The window is a Wayland surface.
fn is_wayland(&self) -> bool;
/// Hides an X11 window.
fn hide(&self);
/// Takes a Wayland window off screen and keeps it open.
fn unmap(&self);
}
impl QuittingWindow for winit::window::Window {
#[cfg(target_os = "linux")]
fn is_wayland(&self) -> bool {
matches!(self.window_handle().map(|h| h.as_raw()), Ok(RawWindowHandle::Wayland(_)))
}
/// The BSDs have no Wayland unmap: their windows are hidden through winit.
#[cfg(not(target_os = "linux"))]
fn is_wayland(&self) -> bool {
false
}
fn hide(&self) {
self.set_visible(false);
}
#[cfg(target_os = "linux")]
fn unmap(&self) {
unmap_wayland(self);
}
#[cfg(not(target_os = "linux"))]
fn unmap(&self) {
self.set_visible(false);
}
}
/// Takes the main window and the floating windows `x11` lists off screen: a Wayland main window is
/// unmapped and any other main window hidden, and `unmap_x11` unmaps the floating windows.
pub(crate) fn off_screen<W: QuittingWindow + ?Sized>(main: Option<&W>, x11: &[u32], unmap_x11: impl FnOnce(&[u32])) {
if let Some(window) = main {
if window.is_wayland() {
window.unmap();
} else {
window.hide();
}
}
if !x11.is_empty() {
unmap_x11(x11);
}
}
/// [`off_screen`] with the running app's main window and the floating windows [`Hooks`] recorded.
/// Runs on quit, before eframe drops the app.
pub(crate) fn before_teardown(main: Option<&winit::window::Window>) {
#[cfg(target_os = "linux")]
off_screen(main, &CHILD_WINDOWS.x11(), unmap_x11);
#[cfg(not(target_os = "linux"))]
off_screen(main, &[], |_| {});
}
/// The X11 windows of floating panels and documents, by viewport, while eframe holds them.
#[cfg(target_os = "linux")]
#[derive(Default)]
pub(crate) struct ChildWindows(Mutex<HashMap<egui::ViewportId, u32>>);
#[cfg(target_os = "linux")]
impl ChildWindows {
/// eframe made `viewport`'s window, with `handle`. The main window, and windows of other window
/// systems, are not recorded; a window made again for the same viewport replaces its record.
pub(crate) fn created(&self, viewport: egui::ViewportId, handle: Option<RawWindowHandle>) {
let mut windows = self.lock();
match handle.and_then(x11_window) {
Some(id) if viewport != egui::ViewportId::ROOT => windows.insert(viewport, id),
_ => windows.remove(&viewport),
};
}
/// eframe dropped `viewport`'s window.
pub(crate) fn destroyed(&self, viewport: egui::ViewportId) {
self.lock().remove(&viewport);
}
/// The recorded windows, in id order.
pub(crate) fn x11(&self) -> Vec<u32> {
let mut ids: Vec<u32> = self.lock().values().copied().collect();
ids.sort_unstable();
ids.dedup();
ids
}
fn lock(&self) -> MutexGuard<'_, HashMap<egui::ViewportId, u32>> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// The X11 window id of `handle`, None for another window system.
#[cfg(target_os = "linux")]
fn x11_window(handle: RawWindowHandle) -> Option<u32> {
match handle {
RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok(),
RawWindowHandle::Xcb(h) => Some(h.window.get()),
_ => None,
}
}
/// The running app's floating windows, which [`Hooks`] records.
#[cfg(target_os = "linux")]
static CHILD_WINDOWS: LazyLock<ChildWindows> = LazyLock::new(ChildWindows::default);
/// Records the windows eframe creates and drops in [`CHILD_WINDOWS`]; set as
/// [`eframe::NativeOptions::viewport_hooks`].
#[cfg(target_os = "linux")]
pub(crate) struct Hooks;
#[cfg(target_os = "linux")]
impl eframe::ViewportHooks for Hooks {
fn window_created(&self, viewport: egui::ViewportId, window: &winit::window::Window) {
CHILD_WINDOWS.created(viewport, window.window_handle().ok().map(|h| h.as_raw()));
}
fn window_destroyed(&self, viewport: egui::ViewportId) {
CHILD_WINDOWS.destroyed(viewport);
}
}
/// Unmaps the X11 `windows` through a connection of its own, and waits until the server has done so.
/// An id whose window is gone gets an error, which is ignored.
#[cfg(target_os = "linux")]
fn unmap_x11(windows: &[u32]) {
use x11rb::protocol::xproto::ConnectionExt as _;
let conn = match x11rb::connect(None) {
Ok((conn, _)) => conn,
Err(e) => {
log::warn!("couldn't reach the X server to hide the floating windows on quit: {e}");
return;
}
};
for &window in windows {
if let Ok(cookie) = conn.unmap_window(window) {
cookie.ignore_error();
}
}
// The server answers once it has handled the requests before this one.
if let Ok(cookie) = conn.get_input_focus() {
let _ = cookie.reply();
}
}
/// Attaches no buffer to `window`'s Wayland surface and commits it, which unmaps the window: the
/// compositor stops showing it, and the window and its connection stay open.
#[cfg(target_os = "linux")]
fn unmap_wayland(window: &winit::window::Window) {
let Some((conn, surface)) = super::portal_save::wayland::surface(window) else { return };
surface.attach(None, 0, 0);
surface.commit();
if let Err(e) = conn.flush() {
log::warn!("couldn't reach the compositor to hide the window on quit: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
/// A main window that records what quitting did to it.
struct Window<'a> {
wayland: bool,
done: &'a RefCell<Vec<String>>,
}
impl QuittingWindow for Window<'_> {
fn is_wayland(&self) -> bool {
self.wayland
}
fn hide(&self) {
self.done.borrow_mut().push("hide".into());
}
fn unmap(&self) {
self.done.borrow_mut().push("unmap".into());
}
}
fn quit(wayland: Option<bool>, x11: &[u32]) -> Vec<String> {
let done = RefCell::new(Vec::new());
let window = wayland.map(|wayland| Window { wayland, done: &done });
off_screen(window.as_ref(), x11, |ids| done.borrow_mut().push(format!("unmap x11 {ids:?}")));
done.into_inner()
}
/// winit cannot hide a Wayland window: quitting unmaps it, and never only asks winit to hide it.
#[test]
fn quitting_unmaps_a_wayland_main_window() {
assert_eq!(quit(Some(true), &[]), ["unmap"]);
}
/// An X11 main window (XWayland included) is hidden, and the floating panels' and documents' X11
/// windows are unmapped with it.
#[test]
fn quitting_hides_an_x11_main_window_and_its_floating_windows() {
assert_eq!(quit(Some(false), &[]), ["hide"]);
assert_eq!(quit(Some(false), &[7, 9]), ["hide", "unmap x11 [7, 9]"]);
}
/// Without a main window (none was made), the floating windows still go, and with none at all
/// nothing is done.
#[test]
fn quitting_without_a_main_window() {
assert_eq!(quit(None, &[3]), ["unmap x11 [3]"]);
assert!(quit(None, &[]).is_empty());
}
#[cfg(target_os = "linux")]
fn xlib(id: std::os::raw::c_ulong) -> Option<RawWindowHandle> {
Some(RawWindowHandle::Xlib(winit::raw_window_handle::XlibWindowHandle::new(id)))
}
#[cfg(target_os = "linux")]
fn viewport(name: &str) -> egui::ViewportId {
egui::ViewportId::from_hash_of(name)
}
/// The floating windows eframe made are recorded until it drops them; the main window, which
/// quitting reaches through winit, is not.
#[cfg(target_os = "linux")]
#[test]
fn floating_x11_windows_are_recorded_while_they_exist() {
let windows = ChildWindows::default();
windows.created(egui::ViewportId::ROOT, xlib(1));
windows.created(viewport("Layers"), xlib(9));
let xcb = winit::raw_window_handle::XcbWindowHandle::new(std::num::NonZeroU32::new(5).expect("not zero"));
windows.created(viewport("Untitled-2"), Some(RawWindowHandle::Xcb(xcb)));
windows.created(viewport("Color"), xlib(7));
assert_eq!(windows.x11(), [5, 7, 9]);
windows.destroyed(viewport("Color"));
assert_eq!(windows.x11(), [5, 9]);
// A viewport that eframe has already dropped, or never made, is no error.
windows.destroyed(viewport("Color"));
windows.destroyed(viewport("Swatches"));
assert_eq!(windows.x11(), [5, 9]);
}
/// A window made again for a viewport replaces its record, and one of another window system, or
/// one whose handle could not be read, removes it.
#[cfg(target_os = "linux")]
#[test]
fn a_window_made_again_replaces_its_record() {
let windows = ChildWindows::default();
windows.created(viewport("Layers"), xlib(9));
windows.created(viewport("Layers"), xlib(11));
assert_eq!(windows.x11(), [11]);
let surface = std::ptr::NonNull::<std::ffi::c_void>::dangling();
let wayland = winit::raw_window_handle::WaylandWindowHandle::new(surface);
windows.created(viewport("Layers"), Some(RawWindowHandle::Wayland(wayland)));
assert!(windows.x11().is_empty());
windows.created(viewport("Layers"), xlib(11));
windows.created(viewport("Layers"), None);
assert!(windows.x11().is_empty());
}
}
-18
View File
@@ -515,21 +515,3 @@ fn the_linux_desktop_entry_opens_every_readable_format() {
}
}
}
/// On Linux and the BSDs, quitting hides the main window before eframe drops the app, whose
/// clipboard handles may take up to `CLIPBOARD_PROBE_STOP_WAIT` to let go.
#[cfg(not(any(target_os = "macos", windows)))]
#[test]
fn quitting_hides_the_window_before_the_clipboards_let_go() {
struct Window(std::cell::Cell<u32>);
impl QuittingWindow for Window {
fn hide(&self) {
self.0.set(self.0.get() + 1);
}
}
let window = Window(std::cell::Cell::new(0));
hide_before_teardown(Some(&window));
assert_eq!(window.0.get(), 1, "the window was not hidden");
// Without a window (none was made), there is nothing to hide.
hide_before_teardown::<Window>(None);
}
+1 -1
View File
@@ -18,7 +18,7 @@ mod wrap;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{
CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES,
CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, MAX_AKI, MAX_TSUME, MAX_WARICHU_GAP, ScriptMetrics, WARICHU_LINES,
WARICHU_SCALE, WarichuAlign,
};
pub use composite::{
+15 -1
View File
@@ -44,6 +44,14 @@ pub struct CharStyle {
/// Insert Aki Right: space added after the character in reading order, as [`Self::aki_left`].
#[serde(default, rename = "akiRight", skip_serializing_if = "crate::skip::is_default")]
pub aki_right: f64,
/// Tsume (Character panel, East Asian options): the percentage of the blank space on each side
/// of the character's ink, inside its own width, that is taken away, 0 to [`MAX_TSUME`]. 50
/// halves that space and 100 sets the ink edge to edge; the glyph itself is not scaled. A
/// cluster takes the value of its first character. Tracking, kerning and Insert Aki are added
/// after it. Horizontal type only.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub tsume: f64,
/// Use glyph shapes for automatic pair spacing; false uses font metrics.
#[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")]
pub optical_kerning: bool,
@@ -440,6 +448,9 @@ fn hundred() -> f64 {
/// The largest Insert Aki Left or Right ([`CharStyle::aki_left`]), in 1/1000 em: one em.
pub const MAX_AKI: f64 = 1000.0;
/// The largest Tsume ([`CharStyle::tsume`]), in percent: all the blank space beside the ink.
pub const MAX_TSUME: f64 = 100.0;
/// The number of lines a warichu block may be stacked on ([`CharStyle::warichu_lines`]).
pub const WARICHU_LINES: std::ops::RangeInclusive<u8> = 1..=6;
@@ -534,6 +545,7 @@ impl Default for CharStyle {
kerning_before: 0.0,
aki_left: 0.0,
aki_right: 0.0,
tsume: 0.0,
optical_kerning: false,
baseline_shift: 0.0,
h_scale: 100.0,
@@ -588,7 +600,8 @@ impl CharStyle {
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
/// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right
/// offsets are kept as the baseline shift is. Insert Aki Left and Right are kept from 0 to
/// [`MAX_AKI`], and one that is not a number becomes 0. The Warichu Settings Lines and Scale
/// [`MAX_AKI`], and one that is not a number becomes 0; Tsume is kept from 0 to [`MAX_TSUME`] the
/// same way. The Warichu Settings Lines and Scale
/// are kept to [`WARICHU_LINES`] and [`WARICHU_SCALE`]; a gap or scale that is not a number becomes its default. The gap is kept to 0 to
/// [`crate::MAX_COORD`], as the baseline shift is, since a scaled type object holds it in its
/// own units.
@@ -606,6 +619,7 @@ impl CharStyle {
let aki = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_AKI) };
self.aki_left = aki(self.aki_left);
self.aki_right = aki(self.aki_right);
self.tsume = if self.tsume.is_nan() { 0.0 } else { self.tsume.clamp(0.0, MAX_TSUME) };
self.warichu_lines = self.warichu_lines.clamp(*WARICHU_LINES.start(), *WARICHU_LINES.end());
self.warichu_gap = if self.warichu_gap.is_nan() { default.warichu_gap } else { self.warichu_gap.clamp(0.0, crate::MAX_COORD) };
self.warichu_scale =
+2
View File
@@ -507,6 +507,7 @@ impl crate::Document {
st.tcy_left_right = num("tcyLeftRight").unwrap_or(st.tcy_left_right);
st.aki_left = num("akiLeft").unwrap_or(st.aki_left);
st.aki_right = num("akiRight").unwrap_or(st.aki_right);
st.tsume = num("tsume").unwrap_or(st.tsume);
st.warichu_gap = num("warichuGap").unwrap_or(st.warichu_gap);
st.warichu_scale = num("warichuScale").unwrap_or(st.warichu_scale);
st.keep_dimensions_in_range();
@@ -532,6 +533,7 @@ impl crate::Document {
("tcyLeftRight", Some(st.tcy_left_right)),
("akiLeft", Some(st.aki_left)),
("akiRight", Some(st.aki_right)),
("tsume", Some(st.tsume)),
("leading", st.leading),
];
for (k, v) in kept {
+2 -1
View File
@@ -357,7 +357,7 @@ impl ParaList {
/// The style the marker is set in: `first` (the style of the paragraph's first character)
/// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning,
/// Insert Aki, tate-chu-yoko, warichu, underline and strikethrough.
/// Insert Aki, Tsume, tate-chu-yoko, warichu, underline and strikethrough.
pub fn marker_style(first: &CharStyle) -> CharStyle {
CharStyle {
baseline_shift: 0.0,
@@ -368,6 +368,7 @@ impl ParaList {
kerning_before: 0.0,
aki_left: 0.0,
aki_right: 0.0,
tsume: 0.0,
optical_kerning: first.optical_kerning,
tate_chu_yoko: false,
warichu: false,
+28
View File
@@ -129,6 +129,34 @@ fn insert_aki_is_saved_only_when_set() {
assert_eq!((d.char_styles[0].attrs["akiLeft"].as_f64(), d.char_styles[0].attrs["akiRight"].as_f64()), (Some(MAX_AKI), Some(0.0)));
}
/// Tsume is 0 by default and saved only when not 0; styles written before it read as 0, and a
/// value out of range from a file, in a run or a character style, is kept from 0 to 100.
#[test]
fn tsume_is_saved_only_when_set_and_kept_in_range() {
let st = CharStyle::default();
assert_eq!(st.tsume, 0.0);
let saved = serde_json::to_value(&st).unwrap();
assert!(saved.get("tsume").is_none());
let read = serde_json::from_value::<CharStyle>(saved).unwrap();
assert_eq!(read.tsume, 0.0);
let set = CharStyle { tsume: 37.5, ..CharStyle::default() };
let saved = serde_json::to_value(&set).unwrap();
assert_eq!(saved.get("tsume").and_then(serde_json::Value::as_f64), Some(37.5));
assert_eq!(serde_json::from_value::<CharStyle>(saved).unwrap(), set);
for (stored, kept) in [(-5.0, 0.0), (250.0, MAX_TSUME), (f64::NAN, 0.0), (f64::INFINITY, MAX_TSUME), (50.0, 50.0)] {
let mut damaged = CharStyle { tsume: stored, ..CharStyle::default() };
damaged.keep_dimensions_in_range();
assert_eq!(damaged.tsume, kept, "{stored}");
}
let mut d = crate::Document::new(100.0, 100.0);
for tsume in [500, -1] {
let attrs = serde_json::json!({ "tsume": tsume });
d.char_styles.push(TextStyleDef { name: format!("Tight {tsume}"), attrs: attrs.as_object().cloned().unwrap_or_default() });
}
d.keep_type_values_in_range();
assert_eq!((d.char_styles[0].attrs["tsume"].as_f64(), d.char_styles[1].attrs["tsume"].as_f64()), (Some(MAX_TSUME), Some(0.0)));
}
/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its
/// script's block; every system saves under its id, which reads back, and other names are refused.
#[test]
+11 -4
View File
@@ -661,8 +661,9 @@ pub fn has_container_appearance(n: &Node) -> bool {
/// The regions a group's or layer's own fills and strokes paint, in document space, each with its
/// fill rule: every visible member path and compound path, type as its glyph outlines and live
/// objects evaluated; a compound shape as its area, with the symbol instances among its members
/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its
/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the
/// as their symbols' art from `doc`; a clip group or clipping layer (`n` itself or one among its
/// members) as the region its clipping path clips to ([`crate::clip::clip_outline`]), so members
/// it hides add nothing. Images, other symbol instances and guides add nothing. A Repeat counts the
/// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> {
fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
@@ -690,8 +691,14 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul
}
}
fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
let clip = usize::from(n.clips());
for c in n.children().into_iter().flatten().skip(clip) {
// A clip group shows its members only inside its clipping path: its outline is that
// region, not the clipped members' own shapes, which may reach far outside it (#7289).
if n.clips() {
let clip = n.children().and_then(|ch| ch.first());
out.extend(clip.and_then(|c| crate::clip::clip_outline(c)).map(|(bp, rule)| (PathData::from_bezpath(&bp), rule)));
return;
}
for c in n.children().into_iter().flatten() {
push(doc, c, out);
}
}
+35
View File
@@ -1757,3 +1757,38 @@ fn a_sublayers_ruler_guide_in_a_hidden_or_locked_group_stays_hidden_or_locked()
assert_eq!(guides, [(NodeId(20), !hidden, locked)], "hidden {hidden}, locked {locked}");
}
}
/// A group's own Fill + Offset Path below its contents follows the outline of a clip group among
/// its members, its clipping path, not the clipped art it hides outside that path (#7289).
#[test]
fn a_groups_own_fill_follows_a_clip_groups_clipping_path_not_its_hidden_art() {
let mut clip = square_at_100();
clip.id = NodeId(2);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
// The clipped art reaches 100 pt past the clipping path on either side.
let hidden = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 100.0, 300.0, 200.0)), black());
let clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(hidden)], clip: true });
let mut g = Node::group(NodeId(10), vec![Arc::new(clipped.clone())]);
let mut fill = vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
fill.effects.push(offset_10());
g.appearance.items = vec![AppearanceItem::Fill(fill)];
g.appearance.set_contents_at(1);
let near = |a: Rect, b: Rect| (a.x0 - b.x0).abs() < 0.5 && (a.y0 - b.y0).abs() < 0.5 && (a.x1 - b.x1).abs() < 0.5 && (a.y1 - b.y1).abs() < 0.5;
let expected = Rect::new(90.0, 90.0, 210.0, 210.0);
let art = evaluate_container(None, &g).unwrap();
let ch = art.children().unwrap();
assert_eq!(ch.len(), 2, "the fill's art below the clip group");
assert_eq!(ch[0].id, NodeId(10));
let b = drawn_bounds(&ch[0]).unwrap();
assert!(near(b, expected), "the offset fill hugs the clipping path: {b:?}");
// The clip group itself with that fill: the same outline.
let mut own = clipped;
own.appearance = g.appearance.clone();
assert_eq!(member_shapes(None, &own).len(), 1);
let b = member_shapes(None, &own)[0].0.bounds().unwrap();
assert!(near(b, Rect::new(100.0, 100.0, 200.0, 200.0)), "the clipping path's region: {b:?}");
}
+55 -24
View File
@@ -370,7 +370,9 @@ fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: R
Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes)))
}
/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts
/// One object's surface of revolution about the axis of `b`, and each face it draws ([`Drawn`]),
/// back to front: by depth, and where two faces overlap, by their depths there
/// ([`crate::solids::surface_order`]). `copies` counts
/// the bytes of the paint copies of every surface and graphic of the object. Revolve (Classic) draws every face
/// with its classic surface, as Extrude & Bevel (Classic) does. Revolve's graphics, with the
/// symbols of `doc`, are drawn on the faces of the side ([`crate::solids::graphics::surface_parts`]):
@@ -388,7 +390,7 @@ fn surface(
blend: vectorcraft_doc::color::BlendMode,
expand: bool,
copies: &mut Copies,
) -> Option<(Vec<f64>, Node)> {
) -> Option<(Vec<Drawn>, Node)> {
// A loaded file or a profile edited past the limits keeps drawing its source path instead
// of crashing the frame. Commands validate the same evaluator and report its errors.
if fx.id == CLASSIC_REVOLVE {
@@ -397,9 +399,12 @@ fn surface(
if copies.surfaces > MAX_PAINT_COPIES {
return None;
}
let depths = facets.iter().map(|f| f.depth).collect();
let order = crate::solids::surface_order(facets.iter().map(|f| (f.contours.first().map_or(&[][..], Vec::as_slice), f.plane, f.depth)));
let facets = in_order(facets, &order);
let drawn =
facets.iter().map(|f| Drawn { depth: f.depth, points: f.contours.first().cloned().unwrap_or_default(), plane: f.plane }).collect();
let p = merged_params(&fx.id, &fx.params);
return Some((depths, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p)));
return Some((drawn, crate::solids::classic::surface(n, facets, &paint, opacity, blend, &p)));
}
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
@@ -407,6 +412,8 @@ fn surface(
if copies.surfaces > MAX_PAINT_COPIES {
return None;
}
let order = crate::solids::surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth)));
let faces = in_order(faces, &order);
let graphics = crate::solids::mapped_graphics(&merged_params(&fx.id, &fx.params));
let shade = |face: &vectorcraft_three_d::Face, c: Color| graphic_paint(face, c, colored);
// Graphics past the limits are left out, and the surface draws without them and their paints'
@@ -437,21 +444,28 @@ fn surface(
&& n.has_default_transparency())
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
.flatten();
let faces = visible.unwrap_or_else(|| {
faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace {
path: f.path(),
brightness: f.brightness,
depth: f.depth,
gradient: f.gradient,
illumination: f.illumination,
light_gradient: f.light_gradient,
rgb_gradient: f.rgb_gradient,
})
.collect()
});
let depths: Vec<f64> = faces.iter().map(|f: &vectorcraft_three_d::VisibleFace| f.depth).collect();
let (faces, keys): (Vec<vectorcraft_three_d::VisibleFace>, Vec<Drawn>) = match visible {
Some(visible) => {
let keys = visible.iter().map(|f| Drawn { depth: f.depth, points: vec![], plane: None }).collect();
(visible, keys)
}
None => {
let keys = faces.iter().map(|f| Drawn { depth: f.depth, points: f.points.clone(), plane: f.plane }).collect();
let faces = faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace {
path: f.path(),
brightness: f.brightness,
depth: f.depth,
gradient: f.gradient,
illumination: f.illumination,
light_gradient: f.light_gradient,
rgb_gradient: f.rgb_gradient,
})
.collect();
(faces, keys)
}
};
let children = faces
.into_iter()
.map(|face| {
@@ -517,7 +531,21 @@ fn surface(
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
Some((depths, group))
Some((keys, group))
}
/// A face [`surface`] draws: its depth and, for [`crate::solids::surface_order`], its polygon on the
/// drawing and its plane (none for a face trimmed to the part of it that shows).
struct Drawn {
depth: f64,
points: Vec<Point>,
plane: Option<vectorcraft_three_d::DepthPlane>,
}
/// `items` in `order`, a permutation of their indices.
fn in_order<T>(items: Vec<T>, order: &[usize]) -> Vec<T> {
let mut slots: Vec<Option<T>> = items.into_iter().map(Some).collect();
order.iter().filter_map(|&k| slots.get_mut(k).and_then(Option::take)).collect()
}
/// A surface of one object about the bounds of `path`, built with options derived from `fx`.
@@ -934,14 +962,17 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
} else {
let count = surfaces.len();
let mut faces = vec![];
for (member, (depths, s, scopes)) in surfaces.into_iter().enumerate() {
for (member, (drawn, s, scopes)) in surfaces.into_iter().enumerate() {
let (opacity, blend) = (s.opacity, s.blend);
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children).map(|(depth, face)| (depth, member, opacity, blend, scopes, face)));
faces.extend(drawn.into_iter().zip(children).map(|(drawn, face)| (drawn, member, opacity, blend, scopes, face)));
}
}
// A stable sort: faces at the same depth keep the members' order.
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
// A stable sort: faces at the same depth keep the members' order. Overlapping faces, of one
// member or of two, then go in the order of their depths where they overlap.
faces.sort_by(|a, b| a.0.depth.total_cmp(&b.0.depth));
let order = crate::solids::surface_order(faces.iter().map(|(d, ..)| (d.points.as_slice(), d.plane, d.depth)));
let faces = in_order(faces, &order);
if opaque {
vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(.., face)| face).collect()))]
} else {
+32
View File
@@ -1261,6 +1261,35 @@ fn draw_order(faces: &[Placed], sealed: bool) -> Vec<usize> {
local_order(faces, order)
}
/// The order, back to front, of the faces of one or more Revolve surfaces `faces`, given back to
/// front by depth: each face's polygon on the drawing, its plane and its depth. When two
/// overlapping faces are drawn the wrong way round for their depths where they overlap, every pair
/// of overlapping faces with a convex polygon and a plane goes in the order of its depths there,
/// the face nearer the viewer after the other ([`local_order`]), and each step draws the face that
/// comes first in the given order of those whose faces before are drawn, so a face without a plane
/// or a convex polygon can also move. Otherwise the order given.
pub(crate) fn surface_order<'a>(faces: impl IntoIterator<Item = (&'a [Point], Option<DepthPlane>, f64)>) -> Vec<usize> {
let placed: Vec<Placed> = faces
.into_iter()
.map(|(points, plane, depth)| {
let local = plane.is_some() && convex(points).is_some();
// Every face counts as one of a stroke's on one shape, so each overlapping pair is compared.
Placed {
depth,
side: true,
away: false,
bounds: polygon_bounds(points),
member: 0,
paints: Paints::Other,
outline: if local { points.to_vec() } else { vec![] },
plane: plane.filter(|_| local),
}
})
.collect();
let order = (0..placed.len()).collect();
local_order(&placed, order)
}
/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before`
/// its 3D effect, about the bounds of all of them.
pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
@@ -2178,3 +2207,6 @@ mod tests_sources;
#[cfg(test)]
mod tests_shadow;
#[cfg(test)]
mod tests_revolve_order;
@@ -0,0 +1,183 @@
use serde_json::json;
use super::*;
use crate::revolve::{CLASSIC_REVOLVE, REVOLVE, revolve_art, revolve_options};
/// The first pair of faces `(polygon, plane)`, in the order given, drawn the wrong way round where
/// their polygons overlap: the earlier one nearer the viewer at the middle of the overlap.
fn misordered(faces: &[(Vec<Point>, Option<DepthPlane>)]) -> Option<(usize, usize)> {
for (i, (a, pa)) in faces.iter().enumerate() {
let Some(pa) = pa.filter(|_| convex(a).is_some()) else { continue };
for (j, (b, pb)) in faces.iter().enumerate().skip(i + 1) {
let Some(pb) = pb else { continue };
let Some(turn) = convex(b) else { continue };
if !rects_overlap(polygon_bounds(a), polygon_bounds(b)) {
continue;
}
let Some(mid) = centroid(&clip_convex(a, b, turn)) else { continue };
let (Some(da), Some(db)) = (pa.depth_at(mid), pb.depth_at(mid)) else { continue };
if da - db > 1e-6 * (1.0 + da.abs().max(db.abs())) {
return Some((i, j));
}
}
}
None
}
/// An open, vase-like profile of straight edges.
fn vase() -> PathData {
let mut b = BezPath::new();
b.move_to((30.0, 20.0));
for p in [(90.0, 20.0), (55.0, 65.0), (95.0, 110.0), (30.0, 145.0)] {
b.line_to(p);
}
PathData::from_bezpath(&b)
}
/// The `k`th of five closed 14 × 20 pt rectangles stepping down and away from the axis, as the
/// dashes of a dashed line's outline do.
fn dash(k: usize) -> PathData {
let (x, y) = (30.0 + 12.0 * k as f64, 20.0 + 26.0 * k as f64);
let mut b = BezPath::new();
b.move_to((x, y));
b.line_to((x + 14.0, y));
b.line_to((x + 14.0, y + 20.0));
b.line_to((x, y + 20.0));
b.close_path();
PathData::from_bezpath(&b)
}
/// The five [`dash`]es as one compound path.
fn dashes() -> PathData {
PathData::new((0..5).flat_map(|k| dash(k).subpaths).collect())
}
/// A tilted three-quarter turn of [`vase`], whose faces sorted by their middles' depths draw a
/// nearer face under a farther one where the two overlap.
fn params() -> Value {
json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 24, "cap": true})
}
/// A three-quarter turn of [`dashes`], steeply tilted and in wide perspective, whose classic facets
/// sorted by their middles' depths draw a nearer facet under a farther one where the two overlap.
fn classic_params() -> Value {
json!({"angle": 270, "offset": 12, "rotationX": -60, "rotationY": 0, "perspectiveAngle": 80, "cap": true})
}
fn node(path: PathData) -> Node {
Node::path(NodeId(1), path, Appearance::basic(Paint::solid(Color::rgb(0.1, 0.7, 0.8)), Paint::None, 0.0))
}
/// The faces of `faces` (polygon, plane and outline) in the order `art` draws their outlines.
fn drawn_order(art: &Node, faces: &[(Vec<Point>, Option<DepthPlane>, PathData)]) -> Vec<(Vec<Point>, Option<DepthPlane>)> {
let mut out = vec![];
art.walk(&mut |n| {
if let Some(path) = n.path_data()
&& let Some((points, plane, _)) = faces.iter().find(|(.., outline)| outline == path)
{
out.push((points.clone(), *plane));
}
});
out
}
/// Of a tilted, revolved vase, a face sorted by the depth of its middle goes under a farther face
/// that it is in front of where the two overlap. The surface draws the nearer face last there, on
/// its own and as a member of a revolved group.
#[test]
fn a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last() {
let path = vase();
let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(&params())).unwrap();
let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect();
assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere");
let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect();
let mut n = node(path.clone());
n.appearance.effects.push(crate::new_effect(REVOLVE, &params()).unwrap());
let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed);
assert_eq!(drawn.len(), faces.len());
assert_eq!(misordered(&drawn), None, "an object");
let mut g = Node::group(NodeId(9), vec![Arc::new(node(path))]);
g.appearance.effects.push(crate::new_effect(REVOLVE, &params()).unwrap());
let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed);
assert_eq!(drawn.len(), faces.len());
assert_eq!(misordered(&drawn), None, "a group's member");
}
/// The faces of a group's members, sorted together by the depths of their middles, go in the order
/// of their depths where they overlap, those of two members as well as those of one.
#[test]
fn a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last() {
let p = json!({"angle": 270, "offset": 12, "rotationX": -30, "rotationY": 0, "perspectiveAngle": 0, "segments": 16, "cap": true});
let members: Vec<PathData> = (0..5).map(dash).collect();
let b = dashes().bounds().unwrap();
let mut faces: Vec<_> = members.iter().flat_map(|m| vectorcraft_three_d::revolve(m, b, revolve_options(&p)).unwrap()).collect();
faces.sort_by(|a, b| a.depth.total_cmp(&b.depth));
let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect();
assert!(misordered(&by_depth).is_some(), "the faces by depth are drawn the wrong way round somewhere");
let keyed: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane, f.path())).collect();
let mut g = Node::group(NodeId(9), members.into_iter().map(|m| Arc::new(node(m))).collect());
g.appearance.effects.push(crate::new_effect(REVOLVE, &p).unwrap());
let drawn = drawn_order(&revolve_art(&g).unwrap(), &keyed);
assert_eq!(drawn.len(), faces.len());
assert_eq!(misordered(&drawn), None);
}
/// Revolve (Classic) draws the nearer of two overlapping facets last where they overlap, too.
#[test]
fn a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last() {
let path = dashes();
// Revolve (Classic) sweeps its surface unshaded, with one facet for each cell.
let classic = vectorcraft_three_d::Revolve {
shade: false,
smooth_shading: false,
lights: None,
light_color: [1.0; 3],
..revolve_options(&crate::merged_params(CLASSIC_REVOLVE, &classic_params()))
};
let facets = vectorcraft_three_d::revolve_facets(&path, path.bounds().unwrap(), classic).unwrap();
let by_depth: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane)).collect();
assert!(misordered(&by_depth).is_some(), "the facets by depth are drawn the wrong way round somewhere");
let keyed: Vec<_> = facets.iter().map(|f| (f.contours[0].clone(), f.plane, f.path())).collect();
let mut n = node(path);
n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, &classic_params()).unwrap());
let drawn = drawn_order(&revolve_art(&n).unwrap(), &keyed);
assert_eq!(drawn.len(), facets.len());
assert_eq!(misordered(&drawn), None);
}
/// A surface whose faces by depth are already drawn the right way round wherever they overlap keeps
/// that order.
#[test]
fn a_surface_already_in_order_keeps_it() {
let path = vase();
let p = json!({"angle": 360, "offset": 12, "rotationX": 0, "rotationY": 0, "segments": 24, "cap": true});
let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), revolve_options(&p)).unwrap();
let by_depth: Vec<_> = faces.iter().map(|f| (f.points.clone(), f.plane)).collect();
assert_eq!(misordered(&by_depth), None);
let order = surface_order(faces.iter().map(|f| (f.points.as_slice(), f.plane, f.depth)));
assert_eq!(order, (0..faces.len()).collect::<Vec<_>>());
}
/// Of two overlapping faces, the one nearer the viewer where they overlap goes after the other,
/// whatever their depths; a face without a plane, and one that overlaps neither, keep their places.
/// Two faces at the same depth there keep their order.
#[test]
fn surface_order_puts_the_nearer_face_where_they_overlap_last() {
let square = |x: f64| vec![Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)];
// z = 10 - x: at the middle of its overlap with the next square (x 7.5) it is at z 2.5.
let r = std::f64::consts::FRAC_1_SQRT_2;
let sloped = Some(DepthPlane { normal: [r, 0.0, r], offset: 10.0 * r, center: Point::ZERO, eye: None });
let flat = |z: f64| Some(DepthPlane { normal: [0.0, 0.0, 1.0], offset: z, center: Point::ZERO, eye: None });
let (a, b, apart) = (square(0.0), square(5.0), square(40.0));
let faces = [(&a[..], sloped, 5.0), (&apart[..], flat(0.0), 1.0), (&b[..], flat(3.0), 3.0)];
assert_eq!(surface_order(faces), vec![0, 1, 2], "the flat face, nearer at the overlap, is already last");
let faces = [(&b[..], flat(2.0), 2.0), (&apart[..], flat(0.0), 3.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![0, 1, 2], "the sloped face, nearer at the overlap, is already last");
// The sloped face's middle (x 5) is at z 5, but the flat face at z 3 is nearer at the overlap.
let faces = [(&b[..], flat(3.0), 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![1, 2, 0], "the flat face is nearer at the overlap");
let faces = [(&b[..], None, 3.0), (&apart[..], flat(0.0), 4.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![0, 1, 2], "no plane");
let faces = [(&b[..], flat(2.5), 3.0), (&a[..], sloped, 5.0)];
assert_eq!(surface_order(faces), vec![0, 1], "the same depth there");
}
+1 -1
View File
@@ -70,7 +70,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode,
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub(crate) use export::with_artboard_fills;
pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated};
pub use export::{export_doc, export_source, unused_option_notes};
pub use export::{export_doc, export_source, selection_doc, unused_option_notes};
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::is_placeholder;
+1 -1
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Selection…",
["File"],
None,
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them",
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})",
has_selection,
export::export_selection
),
+14 -4
View File
@@ -295,8 +295,14 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C
/// ([`isolated`]), with one artboard: their crop. With `text`, the objects alone on one layer, so a
/// text export holds the type of the selected objects and not that of a clipping path around them.
fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = selected_artwork(s)?;
let doc = &s.doc()?.doc;
selection_doc(s.doc()?, cmd, text)
}
/// The document an Export Selection of `st` writes and its crop, as `document.exportSelection`
/// writes it ([`selection`]); `cmd` names the command in the error when nothing selected draws.
pub fn selection_doc(st: &DocState, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = artwork_of(st);
let doc = &st.doc;
let (mut d, bounds) = isolated(doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))?;
if text {
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
@@ -312,12 +318,16 @@ fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vector
/// layers are guides and are left out. With an object of a dynamic symbol instance's art selected,
/// the instance is exported whole.
pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(artwork_of(s.doc()?))
}
/// [`selected_artwork`] of document `st`.
fn artwork_of(st: &DocState) -> Vec<NodeId> {
let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc));
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
// On a template layer or sublayer, at any depth.
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
Ok(ids.into_iter().filter(|id| !on_template(*id)).collect())
ids.into_iter().filter(|id| !on_template(*id)).collect()
}
/// The crop of an export of objects `ids` of `doc` alone ([`isolated`]): the bounds the renderer
+83
View File
@@ -508,6 +508,89 @@ fn paint_targets_leave_a_graph_designs_copy_out() {
assert_eq!(super::paint_targets(d, &[copy, art, mark]), [mark]);
}
/// The art of a marker design in series 1 of a line graph: the design's copy and a path inside it.
fn marker_design_art() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Pill"})).unwrap();
let marker = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].clone();
assert!(matches!(marker.kind, NodeKind::Group { .. }), "the design's art");
let mut art = vec![];
marker.walk(&mut |m| {
if matches!(m.kind, NodeKind::Path { .. }) {
art.push(m.id);
}
});
(s, marker.id, art[0])
}
/// The ids `graphicStyle.list` reports as linked to style `name`.
fn linked_to(s: &mut Session, name: &str) -> Vec<u64> {
let list = s.execute("graphicStyle.list", &json!({})).unwrap();
let style = list["styles"].as_array().unwrap().iter().find(|g| g["name"] == name).unwrap().clone();
let mut ids: Vec<u64> = style["linked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
ids.sort_unstable();
ids
}
/// Graphic styles and the art of a column or marker design in a series (the design's copy and a path inside it):
/// `graphicStyle.new` makes a style from the art without linking it; `graphicStyle.redefine` leaves the art's look as
/// it was and unlinks it, whether the art is linked (as in a document saved before) or the source, while the other
/// linked objects follow the new look.
#[test]
fn graphic_styles_leave_a_graph_designs_art_unlinked() {
let make = |marker: bool| {
if marker {
marker_design_art()
} else {
let (s, _, copy, art, _) = design_art();
(s, copy, art)
}
};
let rows = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().appearance.clone();
let link = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().extras().graphic_style;
let link_directly = |s: &mut Session, ids: &[NodeId], style: u32| {
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
for id in ids {
d.node_mut(*id).unwrap().extras_mut().graphic_style = Some(style);
}
};
let rect = |s: &mut Session| {
NodeId(s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap())
};
for marker in [false, true] {
for source in [0, 1] {
let at = format!("marker {marker}, source {source}");
// New: the style is made from the art, which stays unlinked.
let (mut s, copy, art) = make(marker);
let source = [copy, art][source];
s.execute("graphicStyle.new", &json!({"id": source.0, "name": "Art"})).unwrap();
assert_eq!(link(&s, source), None, "{at}");
assert_eq!(linked_to(&mut s, "Art"), Vec::<u64>::new(), "{at}");
// Redefine from another object: linked art keeps its look and is unlinked; a linked rectangle follows.
let follower = rect(&mut s);
s.execute("graphicStyle.apply", &json!({"name": "Art", "ids": [follower.0]})).unwrap();
let style = link(&s, follower).unwrap();
link_directly(&mut s, &[copy, art], style);
let (copy_was, art_was) = (rows(&s, copy), rows(&s, art));
let (blue_rect, blue) = blue_style(&mut s, "Blue", false);
s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": blue_rect.0})).unwrap();
assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was.clone(), art_was.clone()), "{at}");
assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}");
assert_eq!(rows(&s, follower).fill_paint(), blue, "{at}");
assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}");
// Redefine from the art itself: the art is unlinked and the linked rectangle takes the art's look.
link_directly(&mut s, &[copy, art], style);
s.execute("graphicStyle.redefine", &json!({"name": "Art", "id": source.0})).unwrap();
assert_eq!((rows(&s, copy), rows(&s, art)), (copy_was, art_was), "{at}");
assert_eq!((link(&s, copy), link(&s, art)), (None, None), "{at}");
assert_eq!(linked_to(&mut s, "Art"), [follower.0.min(blue_rect.0), follower.0.max(blue_rect.0)], "{at}");
}
}
}
/// The art of a marker design in a series is refused too.
#[test]
fn effect_commands_refuse_a_marker_designs_art() {
File diff suppressed because one or more lines are too long
+12 -7
View File
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Graphic Style",
["Window", "Graphic Styles"],
None,
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}",
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object, unless it is the art of a graph design in a series. Names are made unique → {name}",
has_doc,
style_new
),
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Redefine Graphic Style",
["Window", "Appearance"],
None,
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}",
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. The art of a graph design in a series keeps its look and is unlinked, as the source or as a linked object. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}",
has_doc,
style_redefine
),
@@ -392,8 +392,10 @@ fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
None => d.new_graphic_style_name(),
};
let g = GraphicStyle { id: d.next_graphic_style_id(), ..GraphicStyle::of(name.clone(), captured(n), n) };
// The art of a graph design in a series is not linked: the graph draws it again from the design.
let link = (!super::graph::is_design_art(d, src)).then_some(g.id);
s.edit("New Graphic Style", |d, _| {
set_link(d, &[src], Some(g.id));
set_link(d, &[src], link);
d.graphic_styles.push(g);
Ok(())
})?;
@@ -491,10 +493,12 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| {
let id = d.graphic_style_id(i);
let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look });
// Objects still linked follow the new definition; edited ones are unlinked (they keep
// their look), and the source keeps its own.
// Objects still linked follow the new definition; edited ones are unlinked (they keep their look), and the
// source keeps its own. The art of a graph design in a series is unlinked and keeps its look too, as the
// source or as a linked object: the graph draws it again from the design.
let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src);
let (follow, edited): (Vec<NodeId>, Vec<NodeId>) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old)));
let (follow, edited): (Vec<NodeId>, Vec<NodeId>) =
linked.into_iter().partition(|nid| !super::graph::is_design_art(d, *nid) && d.node(*nid).is_some_and(|n| in_sync(n, &old)));
set_link(d, &edited, None);
// A linked graph series group passes the style to the paths directly inside it, so the graph stores it, and
// goes back to the empty appearance the graph draws it with. The source and the edited paths keep their look.
@@ -509,7 +513,8 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
}
super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true);
super::graph::capture_label_rows(d, &follow_marks);
set_link(d, &[src], Some(id));
let link = (!super::graph::is_design_art(d, src)).then_some(id);
set_link(d, &[src], link);
Ok(())
})?;
Ok(json!({ "name": name }))
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -222,6 +222,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
("warichuAlign", json!(v.warichu_align)),
("akiLeft", json!(v.aki_left)),
("akiRight", json!(v.aki_right)),
("tsume", json!(v.tsume)),
("standardVerticalRoman", json!(v.standard_vertical_roman)),
("noBreak", json!(v.no_break)),
("digits", json!(v.digits)),
@@ -578,6 +579,10 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
}
let (key, lo, hi) = ("tsume", 0.0, vectorcraft_doc::text::MAX_TSUME);
if changed(key) && !(lo..=hi).contains(&st.tsume) {
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
let lines = vectorcraft_doc::text::WARICHU_LINES;
if changed("warichuLines") && !lines.contains(&st.warichu_lines) {
return Err(bad(&c, format!("`warichuLines` must be a whole number from {} to {}", lines.start(), lines.end())));
@@ -1075,12 +1080,12 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps
/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes,
/// scales, leading, shifts, Insert Aki, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose
/// scales, leading, shifts, Insert Aki, Tsume, Warichu Settings or stroke width are out of the range `charStyle.new` takes, whose
/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range
/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in
/// `src` of those skipped)
fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
const RANGED: [&str; 21] = [
const RANGED: [&str; 22] = [
"size",
"h_scale",
"v_scale",
@@ -1088,6 +1093,7 @@ fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
"baseline_shift",
"akiLeft",
"akiRight",
"tsume",
"tcyUpDown",
"tcyLeftRight",
"warichuLines",
+4 -1
View File
@@ -397,6 +397,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() {
json!({"tcyLeftRight": -1296.1}),
json!({"akiLeft": -0.1}),
json!({"akiRight": 1000.1}),
json!({"tsume": -0.1}),
json!({"tsume": 100.1}),
] {
let key = attrs.as_object().unwrap().keys().next().unwrap();
for cmd in ["charStyle.edit", "charStyle.setAttrs", "charStyle.new"] {
@@ -419,7 +421,8 @@ fn character_style_dimensions_are_checked_before_a_definition_changes() {
"h_scale": 1.0, "v_scale": 10000.0,
"baseline_shift": -1296.0,
"tcyUpDown": 1296.0, "tcyLeftRight": -1296.0,
"akiLeft": 0.0, "akiRight": 1000.0
"akiLeft": 0.0, "akiRight": 1000.0,
"tsume": 100.0
}}),
)
.unwrap();
@@ -1821,6 +1821,61 @@ fn insert_aki_is_a_character_attribute_set_one_side_at_a_time() {
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call");
}
/// Tsume (Character panel, East Asian options): a character attribute in percent, 0 by default
/// and saved only when not 0. Both commands set it in one undo step, a call that gives the value
/// the text already has records none, and a value that is not a number or lies outside 0 to 100 is
/// refused, naming the key, before anything changes; a character style captured from the
/// characters carries it.
#[test]
fn tsume_is_a_character_attribute() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
let tsume = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.tsume)).collect::<Vec<_>>();
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert!(saved.get("tsume").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"tsume": 50})).unwrap();
assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]);
let undo = s.doc().unwrap().history.undo.len();
s.execute("text.setFormat", &json!({"tsume": 50})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step without a change");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(tsume(&s), [("(日本)".to_string(), 0.0)], "one undo step");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(tsume(&s), [("(日本)".to_string(), 50.0)]);
let paren = "(".len();
s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": paren, "tsume": 100})).unwrap();
assert_eq!(tsume(&s), [("(".to_string(), 100.0), ("日本)".to_string(), 50.0)]);
let saved = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert_eq!(saved["tsume"], json!(100.0));
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.tsume)).collect();
assert_eq!(reopened, tsume(&s));
let attrs = super::super::textstyles::char_attrs(&runs(&s)[0].style);
assert_eq!(attrs["tsume"], json!(100.0));
let before = tsume(&s);
let undo = s.doc().unwrap().history.undo.len();
for (cmd, p, want) in [
("text.setFormat", json!({"tsume": "50"}), "`tsume` must be a number"),
("text.setFormat", json!({"tsume": -1}), "`tsume` must be from 0 to 100"),
("text.setRangeStyle", json!({"id": id, "tsume": true}), "`tsume` must be a number"),
("text.setRangeStyle", json!({"id": id, "start": 0, "end": paren, "tsume": 100.5}), "`tsume` must be from 0 to 100"),
] {
let e = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}");
}
assert_eq!(tsume(&s), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step for a refused call");
}
/// Standard Vertical Roman Alignment (Character panel menu, East Asian options): a character
/// attribute, off by default and saved only when on. Documents written before it read as off; a
/// character style captured from the characters carries it.
+1 -1
View File
@@ -1330,7 +1330,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.exportAs",
"Export As…",
"",
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
),
(
"ui.fileInfoDialog",
+2
View File
@@ -31,6 +31,8 @@ image = { workspace = true }
# Colour / transform / length parsing for the <text> fallback (MIT OR Apache-2.0; already used by usvg).
svgtypes = "0.16"
simplecss = "0.2.2"
# Bidi classes for the first strong character of `unicode-bidi: plaintext` text (MIT OR Apache-2.0; already used by vectorcraft-text).
unicode-bidi = "0.3.18"
[dev-dependencies]
# Reading embedded font subsets back.
+11 -5
View File
@@ -283,6 +283,11 @@ impl Writer<'_> {
self.warn("type on a path with Insert Aki is outlined to keep its look");
return self.text_outlines(n, t);
}
// Tsume does as well.
if t.runs.iter().any(|r| r.style.tsume != 0.0) {
self.warn("type on a path with Tsume is outlined to keep its look");
return self.text_outlines(n, t);
}
// A `<textPath>` holds the characters of the text, not the bullets or numbers drawn
// before them.
if !lay.markers.is_empty() {
@@ -715,13 +720,13 @@ const PLACE_TOLERANCE: f64 = 1e-6;
/// starts a new segment at each character the layout draws elsewhere than a reader continuing
/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between
/// the characters of a justified Japanese line, and the spacing Mojikumi changes. Text with Insert
/// Aki does so on every line, with `fewer` too.
/// Aki or Tsume does so on every line, with `fewer` too.
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Line> {
let plain = t.plain_text();
let mut lines = Vec::with_capacity(lay.lines.len());
let (mut kerned, mut unkerned) = (None, None);
// Insert Aki moves characters from where a reader draws them, with `fewer` too.
let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0);
// Insert Aki and Tsume move characters from where a reader draws them, with `fewer` too.
let aki = t.runs.iter().any(|r| r.style.aki_left != 0.0 || r.style.aki_right != 0.0 || r.style.tsume != 0.0);
for line in &lay.lines {
let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count());
let style = t.para_at(para);
@@ -885,11 +890,12 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning:
if !kerning {
r.style.kerning.get_or_insert(0.0);
}
// A reader puts no space before a line's first character (`kerningBefore`) and no Insert
// Aki: the segment starts that much further along instead.
// A reader puts no space before a line's first character (`kerningBefore`), no Insert
// Aki and no Tsume: the segment starts that much further along instead.
r.style.kerning_before = 0.0;
r.style.aki_left = 0.0;
r.style.aki_right = 0.0;
r.style.tsume = 0.0;
}
let relaid;
let glyphs = if set == *t {
+83 -16
View File
@@ -711,9 +711,9 @@ struct Ctx {
sizes: RefCell<HashMap<roxmltree::NodeId, f64>>,
weights: RefCell<HashMap<roxmltree::NodeId, u16>>,
elems: RefCell<HashMap<roxmltree::NodeId, Rc<ElemStyle>>>,
/// Whether the first strong character of each element read so far is right to left
/// ([`Self::first_strong_rtl`]); `None` where it holds none.
strong: RefCell<HashMap<roxmltree::NodeId, Option<bool>>>,
/// The first strong characters of each `<text>` read so far, by the `<text>`
/// ([`Self::first_strong_rtl`]).
strong: RefCell<HashMap<roxmltree::NodeId, Rc<FirstStrong>>>,
/// The path each `<textPath>` read so far follows ([`Self::text_path`]); `None` where it has
/// no usable one. The styles of a `<use>` instance share it, so each path is found and parsed
/// once however many `<use>` elements show its text.
@@ -1006,22 +1006,39 @@ impl Ctx {
}
/// Whether the first strong character (UAX #9 P2) of the characters [`Self::collect`] reads
/// from element `n` is right to left; `None` when they hold none. Each element is read once.
/// from element `n` is right to left; `None` when they hold none. The characters of the whole
/// `<text>` are read once, as one sequence, so an isolate that opens in one text node and
/// closes in a later one is skipped whole ([`FirstStrong`]).
fn first_strong_rtl(&self, n: XNode) -> Option<bool> {
if let Some(s) = self.strong.borrow().get(&n.id()) {
return *s;
// The `<text>` (or outermost element) whose characters hold those of `n`.
let mut root = n;
while let Some(p) = root.parent_element().filter(|_| root.tag_name().name() != "text" && self.is_inline_child(root)) {
root = p;
}
let s = n.children().find_map(|c| {
if c.is_text() {
text_first_strong_rtl(c.text().unwrap_or(""))
} else if self.is_inline_child(c) {
self.first_strong_rtl(c)
} else {
None
}
let strong = self.strong.borrow().get(&root.id()).cloned();
let strong = strong.unwrap_or_else(|| {
let mut chars = vec![];
let mut spans = HashMap::new();
self.inline_chars(root, &mut chars, &mut spans);
let s = Rc::new(FirstStrong::new(&chars, spans));
self.strong.borrow_mut().insert(root.id(), s.clone());
s
});
self.strong.borrow_mut().insert(n.id(), s);
s
strong.of(n.id())
}
/// Appends the characters of element `n` and of its shown inline children to `chars`, in
/// document order, and the range each of those elements holds to `spans`.
fn inline_chars(&self, n: XNode, chars: &mut Vec<char>, spans: &mut HashMap<roxmltree::NodeId, Range<usize>>) {
let start = chars.len();
for c in n.children() {
if c.is_text() {
chars.extend(c.text().unwrap_or("").chars());
} else if self.is_inline_child(c) {
self.inline_chars(c, chars, spans);
}
}
spans.insert(n.id(), start..chars.len());
}
/// Whether child `c` of a `<text>` content element holds characters of the text: a shown
@@ -1774,6 +1791,56 @@ fn text_first_strong_rtl(text: &str) -> Option<bool> {
(!vectorcraft_text::paragraph_is_rtl(&format!("{line}\u{5D0}"), None)).then_some(false)
}
/// The first strong characters (UAX #9 P2) of the elements of one `<text>`, found for each element
/// in constant time from one pass over the text's characters. An isolate initiator is skipped to
/// its matching PDI (UAX #9 BD9), and one without a matching PDI to the end; paragraph separators
/// count as spaces, as in [`text_first_strong_rtl`].
struct FirstStrong {
/// The characters each element holds: a range of indices into `next`.
spans: HashMap<roxmltree::NodeId, Range<usize>>,
/// For each character and for the end: the index of the first strong character from there on,
/// isolates skipped, and whether it is right to left; `None` when there is none.
next: Vec<Option<(usize, bool)>>,
}
impl FirstStrong {
fn new(chars: &[char], spans: HashMap<roxmltree::NodeId, Range<usize>>) -> Self {
use unicode_bidi::BidiClass::{AL, FSI, L, LRI, PDI, R, RLI};
let class = |c: char| unicode_bidi::bidi_class(c);
// The matching PDI of each isolate initiator.
let mut pdi = vec![None; chars.len()];
let mut open = vec![];
for (i, &c) in chars.iter().enumerate() {
match class(c) {
LRI | RLI | FSI => open.push(i),
PDI => {
if let Some(at) = open.pop() {
pdi[at] = Some(i);
}
}
_ => {}
}
}
let mut next = vec![None; chars.len() + 1];
for (i, &c) in chars.iter().enumerate().rev() {
next[i] = match class(c) {
L => Some((i, false)),
R | AL => Some((i, true)),
LRI | RLI | FSI => pdi[i].and_then(|p| next[p + 1]),
_ => next[i + 1],
};
}
Self { spans, next }
}
/// Whether the first strong character of element `n` is right to left; `None` when it holds
/// none. An isolate that opens inside `n` and closes after it hides the rest of `n`.
fn of(&self, n: roxmltree::NodeId) -> Option<bool> {
let span = self.spans.get(&n)?;
self.next.get(span.start).copied().flatten().filter(|&(at, _)| at < span.end).map(|(_, rtl)| rtl)
}
}
/// A left-aligned point text of `runs` at the origin (for measuring).
fn with_runs(runs: Vec<TextRun>) -> TextObject {
let mut t = TextObject::point(Point::ZERO, "", CharStyle::default());
+6
View File
@@ -587,6 +587,12 @@ fn unicode_bidi_values_the_text_model_cannot_hold_are_reported() {
(r#"<tspan display="none">&#x5D0;&#x5D1;</tspan>ab"#.to_string(), "ab", Ltr),
(format!("<tspan>{far}</tspan>"), "\u{5D0}", Rtl),
(format!("<tspan>\n</tspan>12 <tspan>{far}</tspan>"), "\u{5D0}", Rtl),
// An isolate that opens in one text node and closes in a later one is skipped whole.
("&#x2067;ab<tspan>&#x2069;&#x5D0;</tspan>".to_string(), "\u{5D0}", Rtl),
("&#x2067;ab<tspan>cd</tspan>&#x2069;&#x5D0;".to_string(), "\u{5D0}", Rtl),
// An isolate that opens in a nested `plaintext` element and closes after it hides the rest of that element.
(r#"<tspan unicode-bidi="plaintext">&#x2067;ab</tspan>&#x2069;&#x5D0;"#.to_string(), "\u{5D0}", Rtl),
(r#"<tspan unicode-bidi="plaintext">&#x2067;ab</tspan>&#x2069;&#x5D0;"#.to_string(), "ab", Ltr),
] {
let (d, _) =
import_with_report(&svg(&format!(r#"<text y="40">x <tspan direction="ltr" unicode-bidi="plaintext">{body}</tspan></text>"#))).unwrap();
+29
View File
@@ -196,6 +196,35 @@ fn insert_aki_places_each_moved_character() {
assert!(!out.svg.contains("<textPath") && out.warnings.iter().any(|w| w.contains("Insert Aki")), "{:?}", out.warnings);
}
/// Tsume: each character the layout moves by its Tsume starts a `<tspan>` where the character is
/// drawn, so a reader draws the text where the canvas does, with fewer `<tspan>`s asked for too.
/// On a path the type is outlined.
#[test]
fn tsume_places_each_moved_character() {
let tight = CharStyle { tsume: 50.0, ..style(12.0) };
let runs =
vec![run("H", style(12.0)), run("O", tight.clone()), run("H ", style(12.0)), run("OHO\nHO", CharStyle { tsume: 100.0, ..style(12.0) })];
for justify in [Justify::Left, Justify::Center, Justify::Right] {
assert_drawn_in_place(&area_doc(runs.clone(), 200.0, justify));
}
let d = area_doc(runs, 200.0, Justify::Left);
let plain = tspans(&export(&area_doc(vec![run("HOH OHO\nHO", style(12.0))], 200.0, Justify::Left), &ExportOptions::default())).len();
let fewer = tspans(&export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() })).len();
assert!(fewer > plain, "each moved character is placed with fewer tspans too: {plain} {fewer}");
let mut t = TextObject::point(Point::ZERO, "", style(12.0));
t.runs = vec![run("HO", tight)];
let mut line = BezPath::new();
line.move_to((20.0, 100.0));
line.line_to((280.0, 100.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
let mut d = Document::new(300.0, 200.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let out = vectorcraft_svg::export_full(&d, &ExportOptions::default(), None);
assert!(!out.svg.contains("<textPath") && out.warnings.iter().any(|w| w.contains("Tsume")), "{:?}", out.warnings);
}
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
#[test]
fn proportional_metrics_write_palt() {
+1
View File
@@ -56,6 +56,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool {
kerning_before: _,
aki_left: _,
aki_right: _,
tsume: _,
optical_kerning,
baseline_shift,
h_scale,
+85 -9
View File
@@ -13,7 +13,7 @@ use kurbo::Shape;
use skrifa::MetadataProvider;
use skrifa::charmap::Charmap;
use skrifa::instance::Size;
use vectorcraft_doc::text::{INLINE_CHAR, MAX_AKI};
use vectorcraft_doc::text::{INLINE_CHAR, MAX_AKI, MAX_TSUME};
use vectorcraft_doc::{CharStyle, Digits, InlineArt};
use crate::features::{OtFeatures, ligatures_suppressed_by};
@@ -969,9 +969,9 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte:
/// Can one glyph draw characters in styles `a` and `b`? OpenType features already apply to their
/// own character ranges during shaping, so feature-only differences do not split a cluster.
/// Horizontal type does not read Standard Vertical Roman Alignment or the tate-chu-yoko offsets.
/// The space before a line's first character ([`space_before`]) and Insert Aki ([`aki`]) come
/// from the cluster's first character, so they do not split a ligature or a letter from its marks
/// either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]).
/// The space before a line's first character ([`space_before`]), Insert Aki ([`aki`]) and Tsume
/// ([`tighten`]) come from the cluster's first character, so they do not split a ligature or a
/// letter from its marks either. Warichu Settings count only on characters marked Warichu ([`warichu_alike`]).
fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
// Exhaustive, like `shapes_alike`: compare borrowed fields without copying feature lists.
let CharStyle {
@@ -986,6 +986,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
kerning_before,
aki_left,
aki_right,
tsume,
optical_kerning,
baseline_shift,
h_scale,
@@ -1034,6 +1035,7 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
kerning_before == &a.kerning_before
&& aki_left == &a.aki_left
&& aki_right == &a.aki_right
&& tsume == &a.tsume
&& offsets == other_offsets
&& font_family == &b.font_family
&& font_style == &b.font_style
@@ -1094,7 +1096,7 @@ pub(crate) fn warichu_alike(a: &CharStyle, b: &CharStyle) -> bool {
/// character style's name, leading, baseline shift, rotation, Character Alignment, Character
/// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its
/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]),
/// Insert Aki ([`aki`]), the OpenType features (each run's apply to its own characters, as features
/// Insert Aki ([`aki`]), Tsume ([`tighten`]), the OpenType features (each run's apply to its own characters, as features
/// over a range of the one buffer) and the tracking, as long as the tracking keeps or drops
/// standard ligatures alike ([`crate::features::ligatures_suppressed_by`]).
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
@@ -1111,6 +1113,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
kerning_before: _,
aki_left: _,
aki_right: _,
tsume: _,
optical_kerning: _,
baseline_shift: _,
h_scale,
@@ -1332,6 +1335,9 @@ struct Shaped {
/// from their letters and the character-level pass matches ([`crate::harakat`]); empty
/// otherwise (glyphs keep their shaping-cluster start).
chars: Vec<u32>,
/// Each glyph's x advance without the font's pair kerning, the width Tsume measures, when
/// horizontal type with Tsume sets the same glyphs without it; empty otherwise.
unkerned: Vec<i32>,
}
/// The features that turn the whole-text features `base` into `own` over the clusters `range`, put
@@ -1443,6 +1449,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
}
ranged
};
// Horizontal type with Tsume on any of the segment's characters.
let tsume_here = !feats.vertical && runs.get(source_runs.clone()).unwrap_or_default().iter().any(|(_, s)| s.tsume > 0.0);
// Shape the segment as if all of it were in `st`, with the features `more` on top.
let shape_in = |st: &CharStyle, more: &[Feature]| {
// A BCP 47 tag is short ASCII; anything else (a file may hold anything) is shaped without one.
@@ -1452,7 +1460,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
.filter(|l| l.len() <= 35 && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'))
.and_then(|l| l.parse::<harfrust::Language>().ok());
let mut shaped = Shaped::default();
let Shaped { raw, proportional, vpal, chars } = &mut shaped;
let Shaped { raw, proportional, vpal, chars, unkerned } = &mut shaped;
let hb_shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
// The baseline and character-level pass use exactly the same resolved features.
@@ -1531,6 +1539,16 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
*proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect();
}
}
if tsume_here {
// Tsume measures each glyph on its own width: the same glyphs set without the
// font's pair kerning (`kern` and `apkn`) give it.
let mut bare = along_features.clone();
bare.extend([b"kern", b"apkn"].map(|t| Feature::new(Tag::new(t), 0, range.start..=range.end)));
let bare = shape(&bare, along);
if same_glyphs(&bare) {
*unkerned = bare.iter().map(|g| g.2).collect();
}
}
if feats.proportional_vertical(st) {
// `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set
// top to bottom with it (and the vertical kerning that goes with it, `vkrn` and `vapk`)
@@ -1577,7 +1595,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
shaped
};
let first_optical = if optical(range.start) { Some(range.start) } else { boundaries().find(|&b| optical(b)) };
let Shaped { raw, proportional, vpal, chars } = if let Some(first) = first_optical.filter(|_| !feats.vertical) {
let Shaped { raw, proportional, vpal, chars, unkerned } = if let Some(first) = first_optical.filter(|_| !feats.vertical) {
// The pair entering Optical keeps the left character's font kerning. Subsequent
// Optical pairs use outline spacing, so font pair features stop at its second character.
let optical_off = if first == range.start {
@@ -1711,11 +1729,16 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
let ch = first_char(cl);
let mut adv = xa as f64 * k * hs;
// The glyph's own width, without the font's pair kerning and before the tracking, manual
// kerning and Insert Aki after it: what Tsume measures.
let mut own = unkerned.get(gi).map_or(adv, |&u| f64::from(u) * k * hs);
// The chosen glyph takes its own advance.
let chosen = chosen_at(gi);
let gid = match chosen {
Some(c) => {
adv += (face.advance(c.gid) - face.advance(gid)) * k * hs;
let wider = (face.advance(c.gid) - face.advance(gid)) * k * hs;
adv += wider;
own += wider;
c.gid
}
None => gid,
@@ -1723,7 +1746,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let mut kern_shift = 0.0;
let mut tracked = 0.0;
let (mut aki_shift, mut aki_in_adv) = (0.0, 0.0);
if ch == SOFT_HYPHEN || ch == '\u{2028}' {
let invisible = ch == SOFT_HYPHEN || ch == '\u{2028}';
if invisible {
adv = 0.0;
} else {
let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0);
@@ -1785,9 +1809,61 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
cursive,
hyphen_language: crate::hyphen::Language::from_tag(gst.language.as_deref()),
});
// Tsume takes its share of the blank space beside the cluster's ink once the cluster is
// complete, in horizontal type.
if last_in_cluster && !invisible && !feats.vertical && gst.tsume > 0.0 {
let first = out.iter().rposition(|g| g.byte != cl).map_or(0, |i| i + 1);
tighten(out.get_mut(first..).unwrap_or_default(), own, gst.tsume);
}
}
}
/// Tsume ([`CharStyle::tsume`]) on one cluster: `glyphs` are its glyphs in the order they are
/// drawn along the line, the last one with `own` as its width before the tracking, kerning and
/// Insert Aki its advance holds. `tsume` percent of the blank space between the cluster's own width
/// and its ink, on each side, comes out of the cluster's advance, and its glyphs are drawn the
/// left side's share earlier. A cluster without ink, such as a space, or with a width that is not
/// positive, keeps its place and width.
fn tighten(glyphs: &mut [SGlyph], own: f64, tsume: f64) {
let Some(last) = glyphs.len().checked_sub(1) else { return };
let share = if tsume.is_nan() { 0.0 } else { tsume.clamp(0.0, MAX_TSUME) / 100.0 };
let (mut pen, mut ink): (f64, Option<(f64, f64)>) = (0.0, None);
for (i, g) in glyphs.iter().enumerate() {
if let Some((a, b)) = ink_across(&g.face, g.gid) {
let (a, b) = (pen + g.dx + a * g.sx, pen + g.dx + b * g.sx);
let (lo, hi) = (a.min(b), a.max(b));
ink = Some(ink.map_or((lo, hi), |(l, h)| (l.min(lo), h.max(hi))));
}
pen += if i == last { own } else { g.adv };
}
let Some((lo, hi)) = ink.filter(|(lo, hi)| lo.is_finite() && hi.is_finite()) else { return };
if !(pen.is_finite() && pen > 0.0) {
return;
}
let left = lo.clamp(0.0, pen) * share;
let right = (pen - hi).clamp(0.0, pen) * share;
for g in glyphs.iter_mut() {
g.dx -= left;
}
if let Some(g) = glyphs.get_mut(last) {
g.adv -= left + right;
}
}
/// The left and right edges of glyph `gid`'s outline in `face`, in font units; `None` for a glyph
/// without an outline (a space, or a bitmap or color glyph without one).
fn ink_across(face: &FontFace, gid: u32) -> Option<(f64, f64)> {
let font = face.skrifa()?;
let glyph = font.outline_glyphs().get(skrifa::GlyphId::new(gid))?;
let mut pen = crate::fontdb::FlipPen(kurbo::BezPath::new());
glyph.draw(skrifa::outline::DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen).ok()?;
if pen.0.elements().is_empty() {
return None;
}
let b = pen.0.bounding_box();
Some((b.x0, b.x1))
}
#[cfg(test)]
mod optical_tests {
use super::*;
+1
View File
@@ -30,6 +30,7 @@ mod shape_plans;
mod snap;
mod svg_glyphs;
mod sysfonts;
mod tsume;
mod typo;
mod variable;
mod versions;
+143
View File
@@ -0,0 +1,143 @@
//! Tsume (`CharStyle::tsume`): the share of the blank space on each side of a character's ink,
//! inside its own width, that horizontal type takes away, the glyph keeping its shape.
use super::{area, db, point, style};
use crate::*;
use kurbo::Shape;
use vectorcraft_doc::{CharStyle, Justify};
/// Where glyph `byte` is drawn along a horizontal line.
fn drawn_x(l: &TextLayout, byte: usize) -> f64 {
l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap()
}
/// The left and right edges of glyph `byte`'s ink.
fn ink(l: &TextLayout, byte: usize) -> (f64, f64) {
let b = l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.outline.bounding_box()).unwrap();
(b.x0, b.x1)
}
/// `t` with `f` applied to the style of the character at `byte`.
fn with(t: &TextObject, byte: usize, f: impl Fn(&mut CharStyle)) -> TextObject {
let mut t = t.clone();
let end = edit::next_char(&t.plain_text(), byte);
edit::style_range(&mut t.runs, byte, end, f);
t
}
/// How far each of `bytes` moved along the line from `a` to `b`.
fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec<f64> {
let (la, lb) = (layout(db(), a), layout(db(), b));
bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect()
}
fn near(values: &[f64], expected: &[f64]) -> bool {
values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6)
}
/// The blank space before and after the O of "HOH" in `t`'s layout: between the start of its
/// width and its ink, and between its ink and the next character.
fn o_bearings(t: &TextObject) -> (f64, f64) {
let l = layout(db(), t);
let (x0, x1) = ink(&l, 1);
(x0 - drawn_x(&l, 1), drawn_x(&l, 2) - x1)
}
#[test]
fn tsume_takes_its_share_of_the_space_beside_the_ink() {
let plain = point("HOH", style(100.0));
let (before, after) = o_bearings(&plain);
assert!(before > 1.0 && after > 1.0, "{before} {after}");
let plain_layout = layout(db(), &plain);
let width = ink(&plain_layout, 1).1 - ink(&plain_layout, 1).0;
for tsume in [25.0, 50.0, 100.0] {
let tight = with(&plain, 1, |s| s.tsume = tsume);
let share = tsume / 100.0;
// The O's ink moves back by its share of the space before it; the H after it also moves
// back by the share of the space after the O.
let l = layout(db(), &tight);
let (x0, x1) = ink(&l, 1);
let moved = (x0 - ink(&plain_layout, 1).0, ink(&l, 2).0 - ink(&plain_layout, 2).0);
assert!((moved.0 + before * share).abs() < 1e-6, "{tsume}: {moved:?}");
assert!((moved.1 + (before + after) * share).abs() < 1e-6, "{tsume}: {moved:?}");
// The glyph keeps its shape, and the H before it stays.
assert!((x1 - x0 - width).abs() < 1e-6, "{tsume}: {} {width}", x1 - x0);
assert!((ink(&l, 0).0 - ink(&plain_layout, 0).0).abs() < 1e-9);
}
// At 100% the O's ink starts where the H before it ends its width, and the next H starts its
// width where the O's ink ends.
let l = layout(db(), &with(&plain, 1, |s| s.tsume = 100.0));
let cell_start = drawn_x(&plain_layout, 1);
assert!((ink(&l, 1).0 - cell_start).abs() < 1e-6 && (drawn_x(&l, 2) - ink(&l, 1).1).abs() < 1e-6, "{:?} {cell_start}", ink(&l, 1));
}
#[test]
fn zero_out_of_range_and_spaces_keep_the_layout() {
let plain = point("H O", style(100.0));
let base = layout(db(), &plain);
for (tsume, at) in [(0.0, 2), (f64::NAN, 2), (-50.0, 2), (100.0, 1)] {
// 0, a value that is not a number, a negative value, and a space at 100%.
let l = layout(db(), &with(&plain, at, |s| s.tsume = tsume));
let xs: Vec<f64> = l.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect();
let base_xs: Vec<f64> = base.glyphs.iter().map(|g| g.xf.as_coeffs()[4]).collect();
assert_eq!(xs, base_xs, "{tsume} at {at}");
}
// A value above 100 takes all the space, as 100 does.
let most = moves(&plain, &with(&plain, 2, |s| s.tsume = 500.0), &[2]);
let full = moves(&plain, &with(&plain, 2, |s| s.tsume = 100.0), &[2]);
assert!(near(&most, &full) && full[0] < -1.0, "{most:?} {full:?}");
}
#[test]
fn tracking_and_insert_aki_are_added_after_the_tsume() {
let plain = point("HOH", style(100.0));
let tight = moves(&plain, &with(&plain, 1, |s| s.tsume = 50.0), &[0, 1, 2]);
let spaced = moves(&plain, &with(&plain, 1, |s| (s.tsume, s.aki_left, s.tracking) = (50.0, 250.0, 100.0)), &[0, 1, 2]);
// 25 pt of Aki before the O, and 10 pt of tracking after it, on top of the Tsume.
assert!(tight[1] < -1.0 && tight[2] < tight[1], "{tight:?}");
assert!(near(&spaced, &[0.0, tight[1] + 25.0, tight[2] + 35.0]), "{tight:?} {spaced:?}");
}
#[test]
fn the_font_kerning_is_added_after_the_tsume() {
// An L, with blank space after its ink, before a letter the font kerns it with: its Tsume is
// the same as before an H, which the font does not kern it with.
let lh = point("LH", style(100.0));
let kerned = ["LT", "LV", "LY", "LW"]
.map(|s| point(s, style(100.0)))
.into_iter()
.find(|t| drawn_x(&layout(db(), t), 1) < drawn_x(&layout(db(), &lh), 1) - 1.0);
let Some(kerned) = kerned else { panic!("the test font kerns none of the pairs") };
let before_kerned = moves(&kerned, &with(&kerned, 0, |s| s.tsume = 100.0), &[1]);
let before_h = moves(&lh, &with(&lh, 0, |s| s.tsume = 100.0), &[1]);
assert!(near(&before_kerned, &before_h) && before_h[0] < -10.0, "{before_kerned:?} {before_h:?}");
}
#[test]
fn a_letter_with_its_mark_takes_the_tsume_of_the_letter() {
let plain = point("H\u{301}O", style(100.0));
let after = "H\u{301}".len();
let letter = moves(&plain, &with(&plain, 0, |s| s.tsume = 100.0), &[after]);
let mark = moves(&plain, &with(&plain, 1, |s| s.tsume = 100.0), &[after]);
assert!(letter[0] < -1.0 && near(&mark, &[0.0]), "{letter:?} {mark:?}");
}
#[test]
fn justification_keeps_the_tsume() {
let plain = area("HOH HOH", style(20.0), Rect::new(0.0, 0.0, 400.0, 400.0), Justify::JustifyAll);
let tight = with(&plain, 1, |s| s.tsume = 100.0);
let (lp, lt) = (layout(db(), &plain), layout(db(), &tight));
// The O stays nearer the H before it by the space Tsume took before its ink.
let gap = |l: &TextLayout| ink(l, 1).0 - ink(l, 0).1;
assert!(gap(&lt) < gap(&lp) - 1.0, "{} {}", gap(&lp), gap(&lt));
}
#[test]
fn vertical_type_keeps_its_spacing() {
for text in ["§§", "ab"] {
let mut plain = point(text, style(20.0));
plain.xf = Affine::IDENTITY;
plain.vertical = true;
let centers = |t: &TextObject| layout(db(), t).glyphs.iter().map(|g| g.outline.bounding_box().center()).collect::<Vec<_>>();
assert_eq!(centers(&with(&plain, 0, |s| s.tsume = 100.0)), centers(&plain), "{text}");
}
}
+13
View File
@@ -371,6 +371,10 @@ pub struct Face {
/// For a face of the swept side (not an end cap): where its corners lie on the side surface,
/// for a graphic mapped onto it.
pub surface: Option<SurfaceCell>,
/// The face's plane in view space and the projection it was drawn with, for its depth at a
/// point of the drawing ([`DepthPlane::depth_at`]). `None` when its corners lie more than
/// 0.001 pt off one plane or a corner is at or past the eye.
pub plane: Option<DepthPlane>,
}
/// Where a face of Revolve's swept side lies on the side surface, unrolled flat: x runs around the
@@ -638,6 +642,14 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi
normal = V3(-normal.0, -normal.1, -normal.2);
}
let flat_brightness = brightness(&o, normal, light);
// As for an extruded face: no plane when a corner lies off it, or when the projection holds a
// corner just in front of the eye, so the drawing no longer matches the plane's projection.
let centre = vertices.iter().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2));
let centre = V3(centre.0 / vertices.len() as f64, centre.1 / vertices.len() as f64, centre.2 / vertices.len() as f64);
let offset = normal.dot(centre);
let held = eye.is_some_and(|e| vertices.iter().any(|v| e - v.2 < e * 1e-3));
let flat = !held && vertices.iter().all(|v| (normal.dot(*v) - offset).abs() <= 1e-3);
let plane = flat.then_some(DepthPlane { normal: [normal.0, normal.1, normal.2], offset, center: origin, eye });
let points: Vec<_> = vertices
.iter()
.map(|v| {
@@ -707,6 +719,7 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi
cap: false,
visibility_pieces: None,
surface: None,
plane,
}))
}
+4 -4
View File
@@ -295,8 +295,8 @@ pub struct Facet {
/// (its front). `None` for every other facet.
pub end: Option<PlanarFace>,
/// The face's plane in view space and the projection it was drawn with, for its depth at a
/// point of the drawing ([`Facet::depth_at`]). `None` for Revolve's faces and for a face whose
/// corners lie more than 0.001 pt off one plane (a piece of a twisted wall).
/// point of the drawing ([`Facet::depth_at`]). `None` for a face whose corners lie more than
/// 0.001 pt off one plane (a piece of a twisted wall) or reach the eye.
pub plane: Option<DepthPlane>,
/// The contours in view space, before projection, for tests that measure depths.
#[cfg(test)]
@@ -367,7 +367,7 @@ impl Facet {
PathData::from_bezpath(&bp)
}
/// Revolve's face `face` as a facet: its polygon, curved outline, depth, normal and lighting.
/// Revolve's face `face` as a facet: its polygon, curved outline, depth, plane, normal and lighting.
/// A face that joins the profile's ends round the axis is a side; a cap is an end.
pub(crate) fn of_face(face: crate::Face) -> Self {
Facet {
@@ -382,7 +382,7 @@ impl Facet {
// A revolved cap is not one of the planar faces graphics map onto.
end: None,
away: false,
plane: None,
plane: face.plane,
#[cfg(test)]
view: vec![],
}
+32
View File
@@ -375,3 +375,35 @@ fn malformed_and_degenerate_inputs_are_bounded() {
assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err());
assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty());
}
/// A revolved face carries its plane: the depth it gives at each corner of its drawing is the
/// corner's view-space depth, with and without perspective. A face whose corners lie off one plane,
/// or with a corner at or past the eye, has none.
#[test]
fn a_revolved_face_measures_its_depth_on_its_plane() {
let o = Revolve { rotation_x: -30.0, rotation_y: 25.0, ..Default::default() }.sanitized();
let origin = Point::new(50.0, 80.0);
let light = V3(0.0, 0.0, 1.0);
let [a, b, c, d] = [V3(-20.0, -10.0, 5.0), V3(20.0, -12.0, -15.0), V3(25.0, 15.0, -20.0), V3(-18.0, 14.0, 4.0)];
// The fourth corner moved onto the plane of the first three.
let n = b.sub(a).cross(c.sub(a));
let d = V3(d.0, d.1, a.2 - (n.0 * (d.0 - a.0) + n.1 * (d.1 - a.1)) / n.2);
let flat = [a, b, c, d];
for eye in [None, Some(400.0)] {
let face = project_face(&flat, None, o, origin, eye, light).unwrap().unwrap();
let plane = face.plane.unwrap();
for (p, v) in face.points.iter().zip(&flat) {
let depth = plane.depth_at(*p).unwrap();
assert!((depth - v.2).abs() < 1e-6, "{eye:?}: {depth} against {}", v.2);
}
let twisted = [a, b, c, V3(d.0, d.1, d.2 + 1.0)];
assert!(project_face(&twisted, None, o, origin, eye, light).unwrap().unwrap().plane.is_none(), "{eye:?}");
}
let past = [a, b, V3(c.0, c.1, 500.0)];
assert!(project_face(&past, None, o, origin, Some(400.0), light).unwrap().unwrap().plane.is_none());
assert!(project_face(&past, None, o, origin, Some(1000.0), light).unwrap().unwrap().plane.is_some());
// Every face of a revolved profile of straight edges has one.
let (p, b) = cylinder();
let faces = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 35.0, ..Default::default() }).unwrap();
assert!(!faces.is_empty() && faces.iter().all(|f| f.plane.is_some()));
}
+1
View File
@@ -303,6 +303,7 @@ mod tests {
cap: false,
visibility_pieces: None,
surface: None,
plane: None,
}
}
+3 -1
View File
@@ -153,7 +153,7 @@ pub use color_picker::open as open_color_picker;
pub use document_setup::open as open_document_setup;
pub use effect::open as open_effect_dialog;
pub(crate) use effect::{FILM_GRAIN_FIELDS, PAINT_DAUB_BRUSH_TYPE_LABELS, SHADES, shown_choices};
pub use export_as::open as open_export_as;
pub use export_as::{export_selection, open as open_export_as};
pub(crate) use export_for_screens::drop_unread_screen_settings;
pub use export_for_screens::open as open_export_for_screens;
pub(crate) use export_for_screens::{
@@ -947,6 +947,8 @@ mod tests_accessibility;
#[cfg(test)]
mod tests_export;
#[cfg(test)]
mod tests_export_selection;
#[cfg(test)]
mod tests_import_pdf;
#[cfg(test)]
mod tests_package;
@@ -26,9 +26,10 @@ const NUMBERS: &[&str] = &["size", "leading", "kerning", "tracking"];
/// The on, off or unset checkboxes.
const SWITCHES: &[&str] = &["underline", "strikethrough", "standardVerticalRoman"];
/// The attributes of Advanced Character Formats, in the order the section shows them.
const ADVANCED: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "language"];
const ADVANCED: &[&str] =
&["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "tsume", "language"];
/// The number fields of Advanced Character Formats.
const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight"];
const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "akiLeft", "akiRight", "tsume"];
/// The Insert Aki Left and Right fields, which take the Character panel's presets and stop at
/// [`vectorcraft_doc::text::MAX_AKI`].
const AKI: [&str; 2] = ["akiLeft", "akiRight"];
@@ -86,6 +87,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
"rotation",
"akiLeft",
"akiRight",
"tsume",
"language",
"fill",
"fill_tint",
@@ -275,6 +277,7 @@ fn optional_number(d: &Dialog, key: &str) -> Result<Option<Value>, String> {
"rotation" => tl!("Character Rotation"),
"akiLeft" => tl!("Insert Aki Left"),
"akiRight" => tl!("Insert Aki Right"),
"tsume" => tl!("Tsume"),
"fill_tint" => tl!("Fill Tint"),
"stroke_tint" => tl!("Stroke Tint"),
"stroke_width" => tl!("Weight"),
@@ -322,6 +325,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result<Option<Value>, String> {
// Insert Aki stops at 0 and at the largest value the style takes, as the Character
// panel's fields do.
let n = if AKI.contains(&key) { n.clamp(0.0, vectorcraft_doc::text::MAX_AKI) } else { n };
// Tsume stops at 0% and 100% the same way.
let n = if key == "tsume" { n.clamp(0.0, vectorcraft_doc::text::MAX_TSUME) } else { n };
Ok(Some(json!(n)))
}
}
@@ -600,7 +605,7 @@ pub(crate) fn reset_basic(d: &mut Dialog) {
}
}
/// Reset Panel in Advanced Character Formats: its nine attributes become unset.
/// Reset Panel in Advanced Character Formats: its ten attributes become unset.
pub(crate) fn reset_advanced(d: &mut Dialog) {
d.fields.insert("__resetAdvanced".into(), json!(true));
for key in ADVANCED_NUMBERS {
@@ -755,8 +760,8 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) {
}
/// Advanced Character Formats: Horizontal Scale, Vertical Scale, Baseline Shift, the Tate-chu-yoko
/// Up/Down and Left/Right, Character Rotation, Insert Aki Left and Right (with the Character
/// panel's presets) and Language, each empty (unset) or set.
/// Up/Down and Left/Right, Character Rotation, Insert Aki Left and Right and Tsume (with the
/// Character panel's presets) and Language, each empty (unset) or set.
pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) {
egui::Grid::new("character-style-advanced").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| {
for (key, caption, unit) in [
@@ -787,6 +792,17 @@ pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) {
}
ui.end_row();
}
let presets: Vec<String> = crate::panels::character::TSUME_PRESETS.iter().map(f64::to_string).collect();
let presets: Vec<&str> = presets.iter().map(String::as_str).collect();
let row = ui.label(tl!("Tsume"));
let value = if d.fields.get("tsume").is_none_or(Value::is_null) { String::new() } else { d.str("tsume") };
ui.horizontal(|ui| {
if let Some(v) = ui.label_inputs_by(row.id, |ui| crate::widgets::text_presets(ui, (KIND, "tsume"), &value, &presets, 160.0)) {
d.fields.insert("tsume".into(), json!(v));
}
ui.label("%");
});
ui.end_row();
let row = ui.label(tl!("Language:"));
let names: Vec<&str> = std::iter::once(" ").chain(vectorcraft_doc::setup::LANGUAGES.iter().map(|(name, _, _)| *name)).collect();
let current = d.fields.get("language").and_then(Value::as_str).unwrap_or("").to_string();
@@ -1847,6 +1863,50 @@ mod tests {
assert!(t.runs.iter().all(|r| (r.style.aki_left, r.style.aki_right) == (250.0, 1000.0)));
}
/// Tsume in Advanced Character Formats: empty is unset, an explicit 0 stays set, a typed value
/// stops at 0 and 100, Reset Panel unsets it, and the field is drawn and commits to the style
/// and the text set in it.
#[test]
fn tsume_field_shows_sets_resets_and_commits() {
let d = dialog_for(json!({"size": 12.0}));
assert_eq!(d.fields["tsume"], json!(""));
let mut d = dialog_for(json!({"tsume": 50.0}));
assert_eq!(d.fields["tsume"], json!(50.0));
for (typed, kept) in [("0", 0.0), ("25.5", 25.5), ("150", 100.0), ("-5", 0.0)] {
d.fields.insert("tsume".into(), json!(typed));
assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tsume": kept})), "{typed}");
}
d.fields.insert("tsume".into(), json!(""));
assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({})));
d.fields.insert("tsume".into(), json!("tight"));
assert_eq!(edited_attrs(&d).unwrap_err(), "Tsume must be a number");
let mut d = dialog_for(json!({"tsume": 50.0, "size": 12.0}));
d.fields.insert("section".into(), json!("advanced"));
reset_section(&mut d);
assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"size": 12.0})));
let mut app = app();
app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap();
let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Text"})).unwrap()["id"].as_u64().unwrap();
app.run("charStyle.apply", json!({"name": "Body", "id": id})).unwrap();
open(&mut app, "Body").unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 900.0));
let mut d = app.ui.dialog.clone().unwrap();
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
let mut out = ctx.run_ui(input, |ui| advanced_body(ui, &mut d));
let texts = texts_of(&out);
out.textures_delta.clear();
assert!(texts.iter().any(|(text, _)| text == "Tsume"), "Tsume is drawn: {texts:?}");
app.ui.dialog.as_mut().unwrap().fields.insert("tsume".into(), json!("75"));
super::super::confirm(&mut app).unwrap();
let state = app.session.doc().unwrap();
assert_eq!(state.doc.char_styles[0].attrs, attrs(json!({"size": 24.0, "tsume": 75.0})));
let vectorcraft_doc::NodeKind::Text(t) = &state.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
assert!(t.runs.iter().all(|r| r.style.tsume == 75.0));
}
/// The negated tags of the twenty stylistic sets: an explicit empty Stylistic Sets choice.
fn no_sets() -> Vec<String> {
vectorcraft_text::EXTRA_FEATURES[..20].iter().map(|s| format!("-{s}")).collect()
+5 -2
View File
@@ -158,8 +158,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
});
ui.add_space(4.0);
// Hidden for Export Selection, which writes the selected art alone.
let selection = app.session.active().is_some_and(|st| !st.selection.is_empty());
if widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection) {
if !d.bool(super::export_as::SELECTION_FIELD)
&& widgets::check(ui, tl!("Export Selected Art Only"), d.bool("selectedOnly") && selection, selection)
{
d.fields.insert("selectedOnly".into(), json!(!d.bool("selectedOnly")));
}
check(ui, d, "alterPaths", tl!("Alter Paths for Appearance"));
@@ -227,5 +230,5 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
app.ui.dxf_options = Value::Object(remembered.into_iter().flatten().collect());
app.ui.dialog = None;
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
io::export(app, Some("dxf"), path, &params)
io::export(app, Some("dxf"), path, &super::export_as::exported(d, params))
}
+2 -2
View File
@@ -30,7 +30,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
};
/// Fields that are not remembered between exports (they belong to one export).
const PER_EXPORT: [&str; 7] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly"];
const PER_EXPORT: [&str; 8] = ["format", "path", "useArtboards", "range", "artboard", "artboards", "selectedOnly", super::export_as::SELECTION_FIELD];
/// The options a first EPS Options dialog starts from (the export's defaults).
fn defaults() -> Map<String, Value> {
@@ -133,5 +133,5 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
Value::Object(d.fields.iter().filter(|(k, _)| !PER_EXPORT.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect());
app.ui.dialog = None;
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
io::export(app, Some("eps"), path, &params)
io::export(app, Some("eps"), path, &super::export_as::exported(d, params))
}
+66 -11
View File
@@ -4,8 +4,10 @@
//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG
//! Options, Text Export Options, DXF Options, EPS Options, and Save PDF),
//! else the file is written. DWG is listed, greyed out, with what to use instead. The formats of
//! installed exporter plug-ins come after the built-in ones: they write the whole document, and one
//! with parameters asks for them before writing (`plugin::open_export`).
//! installed exporter plug-ins come after the built-in ones: they write the whole document (from
//! Export Selection, the selection), and one with parameters asks for them before writing
//! (`plugin::open_export`). Export Selection ([`export_selection`]) takes the same route after its
//! save panel, and every format then writes the selection alone, cropped to it.
use std::sync::LazyLock;
@@ -144,12 +146,52 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
app.ui.dialog = None;
let path = io::export_path(app, None, f)?;
let range = if all { "all".to_string() } else { range };
let r = options_or_write(app, f, plugin, path, use_artboards, all, range);
let r = options_or_write(app, f, plugin, path, Pages { use_artboards, all, range, selection: false });
// The options follow the save panel: the Enter that closed it isn't theirs (as in Save As).
super::opened_by_panel(app);
r
}
/// The options dialogs' field that is true when Export Selection opened them: OK then writes the
/// selection alone ([`exported`]). Not remembered with the dialog's options.
pub(super) const SELECTION_FIELD: &str = "__selection";
/// `params` of an options dialog `d` for [`io::export`]: with [`io::EXPORT_SELECTION`] when
/// Export Selection opened `d`.
pub(super) fn exported(d: &Dialog, mut params: Value) -> Value {
if d.bool(SELECTION_FIELD)
&& let Some(o) = params.as_object_mut()
{
o.insert(io::EXPORT_SELECTION.into(), json!(true));
}
params
}
/// File › Export Selection (`document.exportSelection` without params): the save panel picks the
/// file and its format, then the format's options dialog follows as in Export As, else the file is
/// written. Each route writes the selected objects alone, cropped to them. Nothing selected to
/// export stops before the save panel.
pub fn export_selection(app: &mut VectorcraftApp) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
fileio::selection_doc(st, "document.exportSelection", false).map_err(|e| e.to_string())?;
let path = io::selection_path(app)?;
let f = fileio::writable_format(None, Some(&path))?;
let plugin = vectorcraft_plugins::export::by_format(f.id);
let pages = Pages { use_artboards: false, all: true, range: String::new(), selection: true };
let r = options_or_write(app, f, plugin, path, pages);
super::opened_by_panel(app);
r
}
/// What an export writes: with `use_artboards` the artboards (`all`, else the 1-based `range`),
/// else the bounds of the visible art, or with `selection` the selection alone.
struct Pages {
use_artboards: bool,
all: bool,
range: String,
selection: bool,
}
/// After the save panel picked `path`: the options dialog of format `f` (or of its exporter
/// plug-in), else the file written.
fn options_or_write(
@@ -157,11 +199,10 @@ fn options_or_write(
f: &'static Format,
plugin: Option<std::sync::Arc<vectorcraft_plugins::Plugin>>,
path: String,
use_artboards: bool,
all: bool,
range: String,
pages: Pages,
) -> Result<Value, String> {
match f.id {
let Pages { use_artboards, all, range, selection } = pages;
let r = match f.id {
"svg" | "svgz" => {
svg_options::open(app, svg_options::Mode::Export, Some(&path));
if let Some(o) = app.ui.dialog.as_mut() {
@@ -169,24 +210,33 @@ fn options_or_write(
}
Ok(Value::Null)
}
// Save PDF writes the artboards as pages, or one page of the art's bounds.
// Save PDF writes the artboards as pages, or one page of the art's bounds or the selection.
"pdf" if use_artboards => save_pdf::open(app, &json!({ "path": path, "range": (!all).then_some(range) })),
"pdf" => save_pdf::open(app, &json!({ "path": path, "useArtboards": false })),
"txt" => {
text_export::open(app, &path);
if let Some(o) = app.ui.dialog.as_mut().filter(|_| selection) {
o.fields.insert("selectionOnly".into(), json!(true));
}
Ok(Value::Null)
}
_ => {
let mut params = json!({ "format": f.id, "path": path });
// A format that holds the whole document (a template) has no Use Artboards: the
// export is not given the option, which its warnings would name as not used.
if plugin.is_some() || f.options.iter().any(|o| o.name == "useArtboards") {
if !selection && (plugin.is_some() || f.options.iter().any(|o| o.name == "useArtboards")) {
params["useArtboards"] = json!(use_artboards);
if use_artboards {
params["range"] = json!(range);
}
}
if let Some(plugin) = plugin.filter(|p| !p.manifest().params.is_empty()) {
let plugin = plugin.filter(|p| !p.manifest().params.is_empty());
let dialog = plugin.is_none() && (f.raster || matches!(f.id, "dxf" | "eps"));
if selection {
// An options dialog keeps the mark as a field of its own, which OK hands on.
params[if dialog { SELECTION_FIELD } else { io::EXPORT_SELECTION }] = json!(true);
}
if let Some(plugin) = plugin {
plugin::open_export(app, &plugin, params);
return Ok(Value::Null);
}
@@ -202,7 +252,12 @@ fn options_or_write(
eps_options::open(app, &params);
return Ok(Value::Null);
}
io::export(app, Some(f.id), Some(path), &params)
return io::export(app, Some(f.id), Some(path), &params);
}
};
// Export Selection's mark on the options dialog that opened.
if let Some(o) = app.ui.dialog.as_mut().filter(|_| selection) {
o.fields.insert(SELECTION_FIELD.into(), json!(true));
}
r
}
+6 -2
View File
@@ -53,7 +53,11 @@ fn format_of(d: &Dialog) -> Option<&'static Format> {
/// Open the options of raster format `f` for an export whose params (format, path, artboard
/// choice) are `params`.
pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) {
let size = app.session.active().and_then(|st| export_size(&st.doc, &params));
let selection = params[super::export_as::SELECTION_FIELD] == true;
let size = app.session.active().and_then(|st| match selection {
true => vectorcraft_engine::cmd::fileio::selection_doc(st, "document.exportSelection", false).ok().map(|(_, r)| (r.width(), r.height())),
false => export_size(&st.doc, &params),
});
if let Some(o) = params.as_object_mut() {
o.insert("ppi".into(), json!(72));
o.extend(defaults(app, f));
@@ -340,5 +344,5 @@ pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str],
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let (format, path) = (d.str("format"), d.str("path"));
app.ui.dialog = None;
io::export(app, Some(&format), Some(path), &form::params(d))
io::export(app, Some(&format), Some(path), &super::export_as::exported(d, form::params(d)))
}
+9 -6
View File
@@ -6,7 +6,8 @@
//! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is
//! `document.pdfSettings`. Save Preset… names the settings as a preset (`pdf.preset.save`).
//! Opened from Export As with Use Artboards off (`__artBounds`), the dialog has no Artboards
//! section, and OK writes one page of the visible art's bounds through [`io::export`].
//! section, and OK writes one page of the visible art's bounds through [`io::export`]; opened
//! from Export Selection (`__selection` too), the page is the selection's crop.
//!
//! The same dialog edits a preset (kind `pdfPreset`, from Edit → PDF Presets): a `name` and
//! `description` instead of the preset and artboards, and OK saves the preset.
@@ -154,7 +155,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
}
let r = match SaveMode::of(&d.str("__save")) {
Some(mode) => io::save_pdf(app, mode, params(d)),
None if d.bool(ART_BOUNDS) => export_art(app, params(d)),
None if d.bool(ART_BOUNDS) => export_art(app, d),
None => io::export_pdf(app, params(d)),
};
// A question the write asked (replace the file the document was opened from?) stays open.
@@ -164,12 +165,14 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
r
}
/// A page of the art's bounds (Export As with Use Artboards off): written as Export As writes
/// other formats ([`io::export`]), with the dialog's options and a saved preset written out.
fn export_art(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> {
/// A page of the art's bounds (Export As with Use Artboards off), or of the selection's crop
/// (Export Selection): written as Export As writes other formats ([`io::export`]), with the
/// dialog's options and a saved preset written out.
fn export_art(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = params(d);
let path = p.as_object_mut().and_then(|o| o.remove("path")).and_then(|v| v.as_str().map(str::to_string));
let p = pdf::expand_preset(&app.session, "file.exportAs", &p).map_err(|e| e.to_string())?.into_owned();
io::export(app, Some("pdf"), path, &p)
io::export(app, Some("pdf"), path, &super::export_as::exported(d, p))
}
/// The settings as `pdf.preset.save` params (without the artboards and path).
+15 -3
View File
@@ -129,15 +129,23 @@ fn options(d: &Dialog) -> Value {
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let opts = options(d);
if Mode::of(d) == Mode::Export && d.bool("useArtboards") {
let selection = d.bool(super::export_as::SELECTION_FIELD);
if Mode::of(d) == Mode::Export && d.bool("useArtboards") && !selection {
let n = app.session.active().map_or(0, |st| st.doc.artboards.len());
let pick: vectorcraft_engine::cmd::fileio::ArtboardPick = serde_json::from_value(opts.clone()).map_err(|e| e.to_string())?;
pick.resolve(n)?;
}
// Remembered for next time, without the document's artboard range.
// Remembered for next time, without the document's artboard range. Export Selection shows no
// Use Artboards, so the one remembered before stays.
let mut last = opts.clone();
if let Some(o) = last.as_object_mut() {
o.remove("range");
if selection {
match app.ui.svg_options.get("useArtboards") {
Some(v) => o.insert("useArtboards".into(), v.clone()),
None => o.remove("useArtboards"),
};
}
}
app.ui.svg_options = last;
app.ui.dialog = None;
@@ -145,6 +153,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if let Some(p) = d.fields.get("path").filter(|p| p.is_string()) {
params["path"] = p.clone();
}
let params = super::export_as::exported(d, params);
let id = match Mode::of(d) {
Mode::Export => "file.export.svg",
Mode::Save => "file.saveAs",
@@ -177,8 +186,11 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, enabled: boo
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
option_fields(ui, d, false);
// Export Selection writes the selection's crop, without artboards to choose.
if Mode::of(d) == Mode::Export {
artboards(app, ui, d);
if !d.bool(super::export_as::SELECTION_FIELD) {
artboards(app, ui, d);
}
} else {
check(ui, d, "preserveEditing", tl!("Preserve Editing Capabilities"), true);
}
@@ -0,0 +1,238 @@
//! Export Selection (`document.exportSelection` without params): the picked format's options
//! dialog follows the save panel, as in Export As, and writes the selection alone.
use std::cell::Cell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_plugins::wat::{self as tpl, Echo};
use super::tests_export::{Written, app_saving, frame, kind, names, set};
use super::*;
/// The last file written, as text.
fn last_text(written: &Written) -> String {
String::from_utf8_lossy(&written.borrow().last().unwrap().1).into_owned()
}
/// The width and height in a PNG's header.
fn png_size(png: &[u8]) -> (u32, u32) {
let n = |at: usize| u32::from_be_bytes([png[at], png[at + 1], png[at + 2], png[at + 3]]);
(n(16), n(20))
}
/// Pick `path` in the next save panels.
fn picking(app: &mut VectorcraftApp, path: &str) {
let picked = path.to_string();
app.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
}
/// How many paths a native document's JSON (or the input an exporter plug-in is given) holds.
fn paths_in(bytes: &[u8]) -> usize {
String::from_utf8_lossy(vectorcraft_format::json_part(bytes)).matches("\"anchors\"").count()
}
/// A PDF continues in Save PDF, without its artboard choice, and its options apply: the page is
/// the selected 10 m rectangle's crop (not the art's bounds, which take in the ellipse), with a
/// `UserUnit` once "Write pages longer than 200 in at full size in points" is turned off.
#[test]
fn export_selection_to_pdf_continues_in_save_pdf() {
let (mut app, written) = app_saving(2, |_| "/out/sel.pdf".into());
app.run("shape.rectangle", json!({"x": 0, "y": 100, "width": 28_346.46, "height": 50})).unwrap();
app.run("document.exportSelection", json!({})).unwrap();
assert_eq!(kind(&app), Some("savePdf"), "the PDF options follow");
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.str("path"), "/out/sel.pdf");
assert!(d.bool("__artBounds") && d.bool(export_as::SELECTION_FIELD));
assert!(written.borrow().is_empty(), "nothing is written before OK");
set(&mut app, "longPagesInPoints", json!(false));
frame(&mut app);
assert_eq!(confirm(&mut app).unwrap()["path"], "/out/sel.pdf");
assert!(app.ui.dialog.is_none());
let bytes = written.borrow()[0].1.clone();
assert!(String::from_utf8_lossy(&bytes).contains("/UserUnit"), "the dialog's option applies");
let pdf = vectorcraft_pdf::import(&bytes).unwrap();
assert_eq!(pdf.artboards.len(), 1);
let page = pdf.artboards[0].rect;
assert!((28_346.0..28_350.0).contains(&page.width()), "{}", page.width());
assert_eq!(page.height().round(), 50.0, "the selection's crop");
}
/// Every format with an options dialog shows it after the save panel, and OK writes the
/// selection alone, cropped to the selected 10×5 pt rectangle: the PNG at 72 ppi, the SVG's view
/// box, the EPS bounding box and the DXF's extents. The mark is not kept with the options
/// remembered for the next export.
#[test]
fn export_selection_continues_in_each_format_s_options() {
let (mut app, written) = app_saving(2, |_| String::new());
app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap();
for (ext, options) in [("svg", "svgOptions"), ("png", "pngOptions"), ("eps", "epsOptions"), ("dxf", "dxfOptions")] {
let path = format!("/out/sel.{ext}");
picking(&mut app, &path);
app.run("document.exportSelection", json!({})).unwrap();
assert_eq!(kind(&app), Some(options), "{ext}");
assert!(app.ui.dialog.as_ref().unwrap().bool(export_as::SELECTION_FIELD), "{ext}");
if ext == "png" {
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["__size"], json!([10.0, 5.0]), "the selection's size");
}
frame(&mut app);
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "{ext}");
assert_eq!(names(&written).last(), Some(&path), "{ext}");
let text = last_text(&written);
match ext {
"png" => assert_eq!(png_size(&written.borrow().last().unwrap().1), (10, 5), "10×5 pt at 72 ppi"),
"svg" => assert!(text.contains(r#"viewBox="0 0 10 5""#), "{text}"),
"eps" => assert!(text.contains("%%BoundingBox: 0 0 10 5"), "{text}"),
_ => {
let at = text.find("$EXTMAX").unwrap_or_else(|| panic!("no extents: {text}"));
let max: Vec<f64> = text[at..].lines().skip(1).collect::<Vec<_>>().chunks(2).take(2).map(|c| c[1].trim().parse().unwrap()).collect();
// In millimeters, DXF Options' default unit: 10×5 pt.
assert!((max[0] - 10.0 * 25.4 / 72.0).abs() < 1e-3 && (max[1] - 5.0 * 25.4 / 72.0).abs() < 1e-3, "the selection's extents: {max:?}");
}
}
}
assert_eq!(names(&written).len(), 4, "one file each");
assert!(app.ui.eps_options.get(export_as::SELECTION_FIELD).is_none(), "EPS Options does not remember the mark");
assert!(app.ui.svg_options.get(export_as::SELECTION_FIELD).is_none());
}
/// Text Export Options opens with Selection Only on, and OK writes the selected type alone.
#[test]
fn export_selection_to_text_writes_the_selected_type() {
let (mut app, written) = app_saving(1, |_| "/out/sel.txt".into());
app.run("text.create", json!({"x": 10, "y": 10, "text": "Left out"})).unwrap();
app.run("text.create", json!({"x": 10, "y": 30, "text": "Selected"})).unwrap();
app.run("document.exportSelection", json!({})).unwrap();
assert_eq!(kind(&app), Some("txtOptions"));
assert!(app.ui.dialog.as_ref().unwrap().bool("selectionOnly"));
frame(&mut app);
confirm(&mut app).unwrap();
assert_eq!(names(&written), ["/out/sel.txt"]);
assert_eq!(last_text(&written), "Selected\n");
}
/// SVG Options opened by Export Selection hides Use Artboards, and its OK leaves the Use
/// Artboards setting that the next Export As SVG starts from as it was.
#[test]
fn export_selection_to_svg_keeps_the_remembered_use_artboards() {
let (mut app, _) = app_saving(2, |_| "/out/sel.svg".into());
app.run("file.export.svg", json!({})).unwrap();
set(&mut app, "useArtboards", json!(true));
confirm(&mut app).unwrap();
assert_eq!(app.ui.svg_options["useArtboards"], true);
app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap();
app.run("document.exportSelection", json!({})).unwrap();
assert_eq!(kind(&app), Some("svgOptions"));
confirm(&mut app).unwrap();
assert_eq!(app.ui.svg_options["useArtboards"], true, "{}", app.ui.svg_options);
}
/// A format without an options dialog (a native document) and an exporter plug-in, with or
/// without parameters, are given the selection alone: fewer paths than the whole document.
#[test]
fn export_selection_without_a_dialog_and_to_plug_ins_writes_the_selection() {
let (mut app, written) = app_saving(1, |_| String::new());
app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap();
app.run("file.exportAs", json!({"path": "/out/all.vectorcraft"})).unwrap();
let all = paths_in(&written.borrow().last().unwrap().1);
picking(&mut app, "/out/sel.vectorcraft");
app.run("document.exportSelection", json!({})).unwrap();
assert!(app.ui.dialog.is_none(), "written at once");
assert_eq!(names(&written).last().map(String::as_str), Some("/out/sel.vectorcraft"));
let selected = paths_in(&written.borrow().last().unwrap().1);
assert!(selected >= 1 && selected < all, "{selected} of {all} paths");
for (id, ext, params) in
[("test.ui.selwhole", "tsw", "{}"), ("test.ui.selparams", "tsp", r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#)]
{
let wasm = wat::parse_str(tpl::echo_exporter(&tpl::exporter(id, &format!(r#"["{ext}"]"#), params), Echo::Input)).unwrap();
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap();
picking(&mut app, &format!("/out/sel.{ext}"));
app.run("document.exportSelection", json!({})).unwrap();
if params != "{}" {
assert_eq!(kind(&app), Some(plugin::EXPORT_KIND), "{id} asks for its parameters");
frame(&mut app);
confirm(&mut app).unwrap();
}
assert!(app.ui.dialog.is_none(), "{id}");
assert_eq!(names(&written).last(), Some(&format!("/out/sel.{ext}")));
let given = paths_in(&written.borrow().last().unwrap().1);
app.run("plugin.remove", json!({"id": id})).unwrap();
assert_eq!(given, selected, "{id} is given the selection");
}
}
/// The question before replacing the file the document was opened from runs the export again as
/// an Export Selection: the PNG written after OK is the selection's.
#[test]
fn export_selection_after_the_question_before_losing_writes_the_selection() {
let (mut app, written) = app_saving(1, |_| "/out/sel.png".into());
app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap();
let st = app.session.doc_mut().unwrap();
st.imported_from = Some("/out/sel.png".into());
st.import_losses = vec!["hidden type".into()];
app.run("document.exportSelection", json!({})).unwrap();
assert_eq!(kind(&app), Some("pngOptions"));
frame(&mut app);
assert_eq!(confirm(&mut app).unwrap()["pending"], super::confirm::KIND);
assert!(written.borrow().is_empty(), "nothing is written before OK");
confirm(&mut app).unwrap();
assert_eq!(names(&written), ["/out/sel.png"]);
assert_eq!(png_size(&written.borrow()[0].1), (10, 5), "the selection's crop");
}
/// `file.exportAs {path, exportSelection: true}` writes the selection without a dialog; a value
/// that is not true or false is refused.
#[test]
fn export_as_with_export_selection_writes_the_selection() {
let (mut app, written) = app_saving(1, |_| String::new());
app.run("shape.rectangle", json!({"x": 40, "y": 30, "width": 10, "height": 5})).unwrap();
app.run("file.exportAs", json!({"path": "/out/sel.png", "exportSelection": true, "useArtboards": true})).unwrap();
assert_eq!(png_size(&written.borrow()[0].1), (10, 5));
let e = app.run("file.exportAs", json!({"path": "/out/sel.png", "exportSelection": "yes"})).unwrap_err();
assert!(e.contains("exportSelection must be true or false"), "{e}");
assert_eq!(names(&written).len(), 1);
}
/// Appearance of Black › Printing / Exporting set to rich black draws a CMYK black as RGB black in
/// a PNG from Export As and from Export Selection, as `document.export` draws it.
#[test]
fn rich_black_reaches_files_from_the_export_dialogs() {
let (mut app, written) = app_saving(1, |_| "/out/sel.png".into());
app.run("file.new", json!({"width": 40, "height": 30, "colorMode": "cmyk"})).unwrap();
app.run("paint.setStroke", json!({"none": true})).unwrap();
app.run("paint.setFill", json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
let px = |w: &Written| {
let w = w.borrow();
image::load_from_memory(&w.last().unwrap().1).unwrap().to_rgba8().get_pixel(10, 10).0
};
app.run("file.exportAs", json!({"path": "/out/all.png"})).unwrap();
let accurate = px(&written);
assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}");
app.session.prefs.black_output = "rich".into();
app.run("file.exportAs", json!({"path": "/out/all.png"})).unwrap();
assert_eq!(px(&written), [0, 0, 0, 255], "Export As");
app.run("document.exportSelection", json!({})).unwrap();
frame(&mut app);
confirm(&mut app).unwrap();
assert_eq!(names(&written).last().map(String::as_str), Some("/out/sel.png"));
assert_eq!(px(&written), [0, 0, 0, 255], "Export Selection");
}
/// With nothing selected, Export Selection stops before the save panel.
#[test]
fn nothing_selected_stops_before_the_save_panel() {
let (mut app, written) = app_saving(1, |_| "/out/sel.pdf".into());
app.run("select.none", json!({})).unwrap();
let asked = Rc::new(Cell::new(false));
let a = asked.clone();
app.services.pick_save = Some(Box::new(move |_| {
a.set(true);
Some("/out/sel.pdf".into())
}));
let e = app.run("document.exportSelection", json!({})).unwrap_err();
assert!(e.contains("select something to export"), "{e}");
assert!(!asked.get() && app.ui.dialog.is_none() && written.borrow().is_empty());
}
+17 -5
View File
@@ -1,5 +1,6 @@
//! Export As → Text: the Text Export Options (encoding, line endings, selection only) for a
//! picked `.txt` path. Fields are `document.export` params (`format`, `path`…).
//! Export As → Text and Export Selection to `.txt`: the Text Export Options (encoding, line
//! endings, selection only) for a picked `.txt` path. Fields are `document.export` params
//! (`format`, `path`…).
use serde_json::{Value, json};
@@ -39,14 +40,25 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.end_row();
});
ui.add_space(8.0);
let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty());
// Export Selection keeps Selection Only on.
let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()) && !d.bool(super::export_as::SELECTION_FIELD);
ui.add_enabled_ui(selected, |ui| form::check(ui, d, "selectionOnly", tl!("Selection Only")));
false
}
/// Write the text file (the command runs first, so a bad option keeps the dialog open).
/// Write the text file (the command runs first, so a bad option keeps the dialog open). Opened by
/// Export Selection, it writes `document.exportSelection`'s text.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let path = io::save_command_output(app, "document.export", "txt", form::params(d))?;
let mut params = form::params(d);
let id = if d.bool(super::export_as::SELECTION_FIELD) {
if let Some(o) = params.as_object_mut() {
o.remove("selectionOnly");
}
"document.exportSelection"
} else {
"document.export"
};
let path = io::save_command_output(app, id, "txt", params)?;
app.ui.dialog = None;
Ok(json!({ "path": path }))
}
+7 -1
View File
@@ -698,7 +698,7 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String {
if depth <= 1 || quoted(v) {
v.to_string()
} else if v.starts_with(['{', '[']) {
lang.catalog().exact_message(v).unwrap_or(v).to_string()
exact_translation(lang.catalog(), v).unwrap_or_else(|| v.to_string())
} else {
message_at(lang, v, depth - 1)
}
@@ -708,6 +708,12 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String {
fill_once(template, &args)
}
/// The translation of a message with an exact `@msg` row in `catalog`, its `{{` and `}}` shown as
/// `{` and `}`.
fn exact_translation(catalog: &Catalog, s: &str) -> Option<String> {
catalog.exact_message(s).map(|tr| fill_once(tr, &[]))
}
/// Fill `{name}` placeholders in one pass: a value that holds `{name}` text stays as it is.
/// Unknown placeholders are left as written. `{{` and `}}` are a literal `{` and `}`.
pub(crate) fn fill_once(template: &str, args: &[(&str, &str)]) -> String {
+1
View File
@@ -2588,6 +2588,7 @@ font-technology Variable Променливи
Proportional Metrics Пропорционални метрики
Insert Aki Left Вмъкване на аки отляво
Insert Aki Right Вмъкване на аки отдясно
Tsume Цуме
Character Direction Посока на знаците
Digits Цифри
Arabic Арабски
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -1573,6 +1573,7 @@ lighting Light Svjetlo
Proportional Metrics Proporcionalna metrika
Insert Aki Left Umetni aki lijevo
Insert Aki Right Umetni aki desno
Tsume Tsume
Character Direction Smjer znakova
Digits Cifre
Arabic Arapski
@@ -6490,7 +6491,7 @@ anchor Points: Tačke:
Stop on Errors Zaustavi pri greškama
Yonroku-ban Yonroku-ban
@msg image '{key}' has no pixels and was left out slika '{key}' nema piksela i izostavljena je
@msg not an RTF file: it doesn't start with {\\rtf nije RTF datoteka: ne počinje s {\\rtf
@msg not an RTF file: it doesn't start with {{\\rtf nije RTF datoteka: ne počinje s {{\\rtf
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG ovo je PostScript datoteka (.ai sačuvan u starijem formatu ili bez PDF kompatibilnosti, ili EPS datoteka), koja se još ne može otvoriti: sačuvajte je kao PDF, .ai kompatibilan s PDF-om ili SVG
font-script Japanese Japansko
swatch [None] [Nijedna]
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7 -6
View File
@@ -3358,6 +3358,7 @@ digits Malayalam Malaiàlam
Proportional Metrics Mètriques proporcionals
Insert Aki Left Insereix aki a l'esquerra
Insert Aki Right Insereix aki a la dreta
Tsume Tsume
Proportional Lining Xifres proporcionals alineades
Position: Posició:
Default Position Posició predeterminada
@@ -5933,7 +5934,7 @@ digits Malayalam Malaiàlam
@msg not an HP-GL file no és un fitxer HP-GL
@msg not a PICT file no és un fitxer PICT
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb
@msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf
@msg not an RTF file: it doesn't start with {{\\rtf no és un fitxer RTF: no comença amb {{\\rtf
@msg not found no s’ha trobat
@msg not in pattern editing mode no esteu en mode d’edició de motius
@msg finish editing the pattern first acabeu primer l’edició del motiu
@@ -6498,8 +6499,8 @@ digits Malayalam Malaiàlam
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un exportador declara d’1 a {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` com a màxim {MAX_UNGROUPED} paràmetres fora de grups: doneu un `group` als altres
@msg can't encode the input no es pot codificar l’entrada
@msg expected {"dataBase64"} s’esperava {"dataBase64"}
@msg expected {"objects"} s’esperava {"objects"}
@msg expected {{"dataBase64": "…"}} s’esperava {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} s’esperava {{"objects": [...]}}
@msg export `{name}`: {e} exportació del mòdul `{name}`: {e}
@msg extension {e} is listed twice l’extensió {e} apareix dues vegades
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensió {e}: feu servir 1-16 lletres minúscules i dígits, sense el punt
@@ -6563,11 +6564,11 @@ digits Malayalam Malaiàlam
@msg a painted live effect returns only `objects` un efecte dinàmic de pintura només retorna `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una peça d’un efecte dinàmic de pintura només té `path`, `fillRule`, `fills` i `strokes`
@msg a read-only object was left out s’ha omès un objecte de només lectura
@msg a read-only object was left out: return it, as {"id"} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {"id"} sol o tal com s’ha rebut
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given s’ha omès un objecte de només lectura: retorneu-lo com a {{"id": …}} sol o tal com s’ha rebut
@msg a string una cadena
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un traç només té `paint`, `width`, `join`, `cap` i `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un traç ha de ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} un efecte ha de ser {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un traç ha de ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} un efecte ha de ser {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` un efecte en un farciment només retorna `fills`
@msg an effect on a stroke returns only `strokes` un efecte en un traç només retorna `strokes`
@msg an object must be a JSON object un objecte ha de ser un objecte JSON
Can't render this file because it contains an unexpected character in line 2 and column 73.
+17 -9
View File
@@ -13,7 +13,8 @@ pub struct Catalog {
ids: HashMap<String, String>,
/// plural messages: `one|other` → forms
plurals: HashMap<String, Vec<String>>,
/// status and error messages without placeholders (`@msg`): English → translation
/// status and error messages without placeholders (`@msg`): English, its `{{` and `}}` read as
/// `{` and `}` → translation, as written
messages: HashMap<String, String>,
/// status and error messages with `{name}` placeholders (`@msg`, and each English form of a
/// `@msgplural` row), most specific first
@@ -193,13 +194,20 @@ impl Catalog {
"@plural" => {
c.plurals.insert(src, tr.split('|').map(str::to_string).collect());
}
"@msg" => {
if src.contains('{') || src.contains("}}") {
c.templates.extend(Template::parse(&src, vec![tr], false));
} else {
c.messages.insert(src, tr);
}
"@msg" if !src.contains(['{', '}']) => {
c.messages.insert(src, tr);
}
"@msg" => match Template::parse(&src, vec![tr], false) {
// A row without placeholders is an exact message, under its text with `{{` and
// `}}` read as literal braces.
Some(Template { pieces, mut forms, .. }) if matches!(pieces.as_slice(), [Piece::Text(_)]) => {
if let (Some(Piece::Text(text)), Some(tr)) = (pieces.into_iter().next(), forms.pop()) {
c.messages.insert(text, tr);
}
}
Some(template) => c.templates.push(template),
None => {}
},
"@msgplural" => {
let forms: Vec<String> = tr.split('|').map(str::to_string).collect();
c.templates.extend(src.split('|').filter_map(|english| Template::parse(english, forms.clone(), true)));
@@ -249,8 +257,8 @@ impl Catalog {
self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.forms.as_slice(), caps)))
}
/// The translation of a status or error message that has an exact `@msg` entry; templates are
/// not tried.
/// The translation of a status or error message that has an exact `@msg` entry, as written in
/// the catalog (`{{` and `}}` for literal braces); templates are not tried.
pub fn exact_message(&self, s: &str) -> Option<&str> {
self.messages.get(s).map(String::as_str)
}
+6 -5
View File
@@ -1601,6 +1601,7 @@ lighting Light Světlo
Proportional Metrics Proporcionální metrika
Insert Aki Left Vložit aki vlevo
Insert Aki Right Vložit aki vpravo
Tsume Tsume
Character Direction Směr znaků
Digits Číslice
Arabic Arabské
@@ -6393,8 +6394,8 @@ axis Y Axis osa Y
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` exportní zásuvný modul deklaruje 1 až {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` mimo skupiny smí být nejvýše {MAX_UNGROUPED} parametrů: ostatním přiřaďte `group`
@msg can't encode the input vstup nelze zakódovat
@msg expected {"dataBase64"} očekáváno {"dataBase64"}
@msg expected {"objects"} očekáváno {"objects"}
@msg expected {{"dataBase64": "…"}} očekáváno {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} očekáváno {{"objects": [...]}}
@msg export `{name}`: {e} export `{name}`: {e}
@msg extension {e} is listed twice přípona {e} je uvedena dvakrát
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot přípona {e}: použijte 1-16 malých písmen a číslic, bez tečky
@@ -6458,11 +6459,11 @@ axis Y Axis osa Y
@msg a painted live effect returns only `objects` malující živý efekt vrací jen `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` část malujícího živého efektu má jen `path`, `fillRule`, `fills` a `strokes`
@msg a read-only object was left out objekt jen pro čtení byl vynechán
@msg a read-only object was left out: return it, as {"id"} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {"id"} nebo tak, jak byl předán
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given objekt jen pro čtení byl vynechán: vraťte ho jako samotné {{"id": …}} nebo tak, jak byl předán
@msg a string řetězec
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` tah má jen `paint`, `width`, `join`, `cap` a `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} tah musí být {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} efekt musí být {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} tah musí být {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} efekt musí být {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` efekt na výplni vrací jen `fills`
@msg an effect on a stroke returns only `strokes` efekt na tahu vrací jen `strokes`
@msg an object must be a JSON object objekt musí být objekt JSON
Can't render this file because it is too large.
+1
View File
@@ -1509,6 +1509,7 @@ lighting Light Lys
Proportional Metrics Proportionale metrikker
Insert Aki Left Indsæt aki til venstre
Insert Aki Right Indsæt aki til højre
Tsume Tsume
Character Direction Tegnretning
Digits Cifre
Arabic Arabisk
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -2632,6 +2632,7 @@ mark-style Japanese Japanisch
Proportional Metrics Proportionale Metrik
Insert Aki Left Aki links einfügen
Insert Aki Right Aki rechts einfügen
Tsume Tsume
Character Direction Zeichenrichtung
Digits Ziffern
Arabic Arabisch
@@ -5985,8 +5986,8 @@ axis Y Axis Y-Achse
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ein Export-Plug-in gibt 1 bis {MAX_EXTENSIONS} `extensions` an
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` höchstens {MAX_UNGROUPED} Parameter außerhalb von Gruppen: Geben Sie den anderen eine `group`
@msg can't encode the input die Eingabe kann nicht codiert werden
@msg expected {"dataBase64"} erwartet: {"dataBase64"}
@msg expected {"objects"} erwartet: {"objects"}
@msg expected {{"dataBase64": "…"}} erwartet: {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} erwartet: {{"objects": [...]}}
@msg export `{name}`: {e} Modulexport `{name}`: {e}
@msg extension {e} is listed twice die Dateiendung {e} ist doppelt aufgeführt
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot Dateiendung {e}: Verwenden Sie 1-16 Kleinbuchstaben und Ziffern, ohne Punkt
@@ -6050,11 +6051,11 @@ axis Y Axis Y-Achse
@msg a painted live effect returns only `objects` ein malender Live-Effekt gibt nur `objects` zurück
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ein Teil eines malenden Live-Effekts hat nur `path`, `fillRule`, `fills` und `strokes`
@msg a read-only object was left out ein schreibgeschütztes Objekt wurde weggelassen
@msg a read-only object was left out: return it, as {"id"} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {"id"} allein oder so, wie es übergeben wurde
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given ein schreibgeschütztes Objekt wurde weggelassen: Geben Sie es zurück, als {{"id": …}} allein oder so, wie es übergeben wurde
@msg a string eine Zeichenkette
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` eine Kontur hat nur `paint`, `width`, `join`, `cap` und `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} eine Kontur muss {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} sein
@msg an effect must be {"id", "params"?, "visible"?} ein Effekt muss {"id", "params"?, "visible"?} sein
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} eine Kontur muss {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} sein
@msg an effect must be {{"id", "params"?, "visible"?}} ein Effekt muss {{"id", "params"?, "visible"?}} sein
@msg an effect on a fill returns only `fills` ein Effekt auf einer Fläche gibt nur `fills` zurück
@msg an effect on a stroke returns only `strokes` ein Effekt auf einer Kontur gibt nur `strokes` zurück
@msg an object must be a JSON object ein Objekt muss ein JSON-Objekt sein
Can't render this file because it is too large.
+6 -5
View File
@@ -3428,6 +3428,7 @@ theme Appearance Εμφάνιση
Proportional Metrics Αναλογικές μετρικές
Insert Aki Left Εισαγωγή aki αριστερά
Insert Aki Right Εισαγωγή aki δεξιά
Tsume Tsume
Arabic Αραβικά
Hindi Χίντι
Farsi Περσικά
@@ -6345,8 +6346,8 @@ axis Y Axis Άξονας Y
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ένα πρόσθετο εξαγωγής δηλώνει 1 έως {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` το πολύ {MAX_UNGROUPED} παράμετροι εκτός ομάδων: δώστε στις υπόλοιπες ένα `group`
@msg can't encode the input η είσοδος δεν μπορεί να κωδικοποιηθεί
@msg expected {"dataBase64"} αναμενόταν {"dataBase64"}
@msg expected {"objects"} αναμενόταν {"objects"}
@msg expected {{"dataBase64": "…"}} αναμενόταν {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} αναμενόταν {{"objects": [...]}}
@msg export `{name}`: {e} εξαγωγή `{name}`: {e}
@msg extension {e} is listed twice η επέκταση {e} αναφέρεται δύο φορές
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot επέκταση {e}: χρησιμοποιήστε 1-16 πεζά γράμματα και ψηφία, χωρίς την τελεία
@@ -6410,11 +6411,11 @@ axis Y Axis Άξονας Y
@msg a painted live effect returns only `objects` ένα ζωντανό εφέ βαφής επιστρέφει μόνο `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ένα κομμάτι ζωντανού εφέ βαφής έχει μόνο `path`, `fillRule`, `fills` και `strokes`
@msg a read-only object was left out παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση
@msg a read-only object was left out: return it, as {"id"} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {"id"} μόνο ή όπως δόθηκε
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given παραλείφθηκε ένα αντικείμενο μόνο για ανάγνωση: επιστρέψτε το ως {{"id": …}} μόνο ή όπως δόθηκε
@msg a string συμβολοσειρά
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` ένα περίγραμμα έχει μόνο `paint`, `width`, `join`, `cap` και `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} ένα περίγραμμα πρέπει να είναι {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} ένα εφέ πρέπει να είναι {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} ένα περίγραμμα πρέπει να είναι {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} ένα εφέ πρέπει να είναι {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` ένα εφέ σε γέμισμα επιστρέφει μόνο `fills`
@msg an effect on a stroke returns only `strokes` ένα εφέ σε περίγραμμα επιστρέφει μόνο `strokes`
@msg an object must be a JSON object ένα αντικείμενο πρέπει να είναι αντικείμενο JSON
Can't render this file because it is too large.
+6 -5
View File
@@ -2666,6 +2666,7 @@ font-technology Variable Variables
Proportional Metrics Métrica proporcional
Insert Aki Left Insertar aki a la izquierda
Insert Aki Right Insertar aki a la derecha
Tsume Tsume
Character Direction Dirección de caracteres
Digits Dígitos
Arabic Árabes
@@ -6336,8 +6337,8 @@ axis Y Axis Eje Y
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plugin de exportación declara de 1 a {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` como máximo {MAX_UNGROUPED} parámetros fuera de grupos: asigna un `group` a los demás
@msg can't encode the input no se puede codificar la entrada
@msg expected {"dataBase64"} se esperaba {"dataBase64"}
@msg expected {"objects"} se esperaba {"objects"}
@msg expected {{"dataBase64": "…"}} se esperaba {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} se esperaba {{"objects": [...]}}
@msg export `{name}`: {e} exportación del módulo `{name}`: {e}
@msg extension {e} is listed twice la extensión {e} aparece dos veces
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensión {e}: usa 1-16 letras minúsculas y dígitos, sin el punto
@@ -6401,11 +6402,11 @@ axis Y Axis Eje Y
@msg a painted live effect returns only `objects` un efecto dinámico de pintura solo devuelve `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` una pieza de un efecto dinámico de pintura solo tiene `path`, `fillRule`, `fills` y `strokes`
@msg a read-only object was left out se omitió un objeto de solo lectura
@msg a read-only object was left out: return it, as {"id"} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {"id"} solo o tal como se recibió
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given se omitió un objeto de solo lectura: devuélvalo como {{"id": …}} solo o tal como se recibió
@msg a string una cadena
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un trazo solo tiene `paint`, `width`, `join`, `cap` y `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un trazo debe ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} un efecto debe ser {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un trazo debe ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} un efecto debe ser {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` un efecto en un relleno solo devuelve `fills`
@msg an effect on a stroke returns only `strokes` un efecto en un trazo solo devuelve `strokes`
@msg an object must be a JSON object un objeto debe ser un objeto JSON
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -1576,6 +1576,7 @@ lighting Light Valo
Proportional Metrics Suhteelliset metriikat
Insert Aki Left Lisää aki vasemmalle
Insert Aki Right Lisää aki oikealle
Tsume Tsume
Character Direction Merkkien suunta
Digits Numerot
Arabic Arabia
@@ -6423,8 +6424,8 @@ axis Y Axis Y-akseli
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` vientilaajennus ilmoittaa 1–{MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ryhmien ulkopuolella saa olla enintään {MAX_UNGROUPED} parametria: anna muille `group`
@msg can't encode the input syötettä ei voi koodata
@msg expected {"dataBase64"} odotettiin {"dataBase64"}
@msg expected {"objects"} odotettiin {"objects"}
@msg expected {{"dataBase64": "…"}} odotettiin {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} odotettiin {{"objects": [...]}}
@msg export `{name}`: {e} moduulin vienti `{name}`: {e}
@msg extension {e} is listed twice tunniste {e} on luettelossa kahdesti
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot tunniste {e}: käytä 1-16 pientä kirjainta ja numeroa ilman pistettä
@@ -6488,11 +6489,11 @@ axis Y Axis Y-akseli
@msg a painted live effect returns only `objects` maalaava live-tehoste palauttaa vain kentän `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` maalaavan live-tehosteen osalla on vain `path`, `fillRule`, `fills` ja `strokes`
@msg a read-only object was left out vain luku -objekti jätettiin pois
@msg a read-only object was left out: return it, as {"id"} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {"id"}-muotona tai sellaisena kuin se annettiin
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given vain luku -objekti jätettiin pois: palauta se pelkkänä {{"id": …}}-muotona tai sellaisena kuin se annettiin
@msg a string merkkijono
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` viivalla on vain `paint`, `width`, `join`, `cap` ja `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} viivan on oltava muotoa {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} tehosteen on oltava muotoa {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} viivan on oltava muotoa {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} tehosteen on oltava muotoa {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` täytön tehoste palauttaa vain kentän `fills`
@msg an effect on a stroke returns only `strokes` viivan tehoste palauttaa vain kentän `strokes`
@msg an object must be a JSON object objektin on oltava JSON-objekti
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -2658,6 +2658,7 @@ font-technology Variable Variables
Proportional Metrics Métrique proportionnelle
Insert Aki Left Insérer un aki à gauche
Insert Aki Right Insérer un aki à droite
Tsume Tsume
Character Direction Direction des caractères
Digits Chiffres
Arabic Arabes
@@ -6388,8 +6389,8 @@ axis Y Axis Axe Y
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un module externe d’exportation déclare de 1 à {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` {MAX_UNGROUPED} paramètres au plus hors des groupes : donnez un `group` aux autres
@msg can't encode the input impossible d’encoder l’entrée
@msg expected {"dataBase64"} attendu : {"dataBase64"}
@msg expected {"objects"} attendu : {"objects"}
@msg expected {{"dataBase64": "…"}} attendu : {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} attendu : {{"objects": [...]}}
@msg export `{name}`: {e} export du module `{name}` : {e}
@msg extension {e} is listed twice l’extension {e} figure deux fois
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extension {e} : utilisez 1-16 lettres minuscules et chiffres, sans le point
@@ -6453,11 +6454,11 @@ axis Y Axis Axe Y
@msg a painted live effect returns only `objects` un effet dynamique de peinture renvoie seulement `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` une pièce d’un effet dynamique de peinture a seulement `path`, `fillRule`, `fills` et `strokes`
@msg a read-only object was left out un objet en lecture seule a été omis
@msg a read-only object was left out: return it, as {"id"} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {"id"} seul ou tel qu’il a été fourni
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given un objet en lecture seule a été omis : renvoyez-le, sous la forme {{"id": …}} seul ou tel qu’il a été fourni
@msg a string une chaîne
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` un contour a seulement `paint`, `width`, `join`, `cap` et `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} un contour doit être {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} un effet doit être {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} un contour doit être {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} un effet doit être {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` un effet sur un fond renvoie seulement `fills`
@msg an effect on a stroke returns only `strokes` un effet sur un contour renvoie seulement `strokes`
@msg an object must be a JSON object un objet doit être un objet JSON
Can't render this file because it is too large.
+23 -22
View File
@@ -3698,6 +3698,7 @@ antiAlias Sharp शार्प
Proportional Metrics आनुपातिक मीट्रिक
Insert Aki Left बाईं ओर आकी जोड़ें
Insert Aki Right दाईं ओर आकी जोड़ें
Tsume त्सुमे
Proportional Oldstyle आनुपातिक ओल्डस्टाइल
Protect Corners कॉर्नर को सुरक्षित रखें
Proximity Guides निकटता गाइड
@@ -5616,11 +5617,11 @@ antiAlias Sharp शार्प
@msg a painted live effect returns only `objects` पेंट करने वाला लाइव इफ़ेक्ट केवल `objects` लौटाता है
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` पेंट करने वाले लाइव इफ़ेक्ट के हिस्से में केवल `path`, `fillRule`, `fills` और `strokes` होते हैं
@msg a read-only object was left out एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया
@msg a read-only object was left out: return it, as {"id"} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {"id"} के रूप में या जैसा दिया गया था वैसा लौटाएँ
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given एक केवल-पढ़ने योग्य ऑब्जेक्ट छोड़ दिया गया: इसे केवल {{"id": …}} के रूप में या जैसा दिया गया था वैसा लौटाएँ
@msg a string एक स्ट्रिंग
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` स्ट्रोक में केवल `paint`, `width`, `join`, `cap` और `miterLimit` होते हैं
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} स्ट्रोक {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} होना चाहिए
@msg an effect must be {"id", "params"?, "visible"?} इफ़ेक्ट {"id", "params"?, "visible"?} होना चाहिए
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} स्ट्रोक {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} होना चाहिए
@msg an effect must be {{"id", "params"?, "visible"?}} इफ़ेक्ट {{"id", "params"?, "visible"?}} होना चाहिए
@msg an effect on a fill returns only `fills` फ़िल पर इफ़ेक्ट केवल `fills` लौटाता है
@msg an effect on a stroke returns only `strokes` स्ट्रोक पर इफ़ेक्ट केवल `strokes` लौटाता है
@msg an object must be a JSON object ऑब्जेक्ट एक JSON ऑब्जेक्ट होना चाहिए
@@ -6084,7 +6085,7 @@ antiAlias Sharp शार्प
@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` में पिछली text.findFontFiles सर्च में मिली फ़ॉन्ट फ़ाइलों के 1 से 1000 पाथ होने चाहिए
@msg `folder` must be an absolute folder path `folder` एक पूर्ण फ़ोल्डर पाथ होना चाहिए
@msg `follow` must be "average", "a" or "b" `follow` को "average", "a" या "b" होना चाहिए।
@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` को अक्ष टैग और मानों का एक ऑब्जेक्ट होना चाहिए, जैसे कि {"wght"}
@msg `fontAxes` must be an object of axis tags and values, such as {{"wght": 650}} `fontAxes` को अक्ष टैग और मानों का एक ऑब्जेक्ट होना चाहिए, जैसे कि {{"wght": 650}}
@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` को "emBox", "capHeight", "xHeight" या "icfBox" होना चाहिए
@msg `format` must be a string `format` एक स्ट्रिंग होना चाहिए
@msg `format` must be one of {_1} `format` को {_1} में से एक होना चाहिए
@@ -6118,12 +6119,12 @@ antiAlias Sharp शार्प
@msg `index` must be a point index `index` एक बिंदु सूचकांक होना चाहिए
@msg `index` must be a whole number or null `index` होना चाहिए पूरी संख्या या null होना चाहिए
@msg `indices` must be a non-empty array of item indices `indices` आइटम सूचकांकों की एक गैर-खाली सरणी होना चाहिए
@msg `items` is a list of {kind, item} `items` {kind, item} की सूची है
@msg `items` is a list of {{kind, item}} `items` {{kind, item}} की सूची है
@msg `items` is empty `items` खाली है
@msg `items` must be a list of {{id, anchors?}}, not {v} `items` को {{id, anchors?}} की सूची होना चाहिए, {v} नहीं
@msg `items` must be a list of {id, points} `items` को {id, points} की सूची होना चाहिए
@msg `items` must be a list of {{id, points}} `items` को {{id, points}} की सूची होना चाहिए
@msg `items` must be an array of {{id, corners?}}, not {v} `items` को {{id, corners?}} की ऐरे होना चाहिए, {v} नहीं
@msg `items` must be an array of {id, anchors} `items` को {id, anchors} की ऐरे होना चाहिए
@msg `items` must be an array of {{id, anchors}} `items` को {{id, anchors}} की ऐरे होना चाहिए
@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` होना चाहिए "none", "short", "medium", "long" या "stylistic"।
@msg `kind` is one of {_1} `kind` {_1} में से एक है
@msg `kind` must be "line" or "paragraph", not {other} `kind` होना चाहिए "line" या "paragraph", नहीं {other}
@@ -6408,7 +6409,7 @@ antiAlias Sharp शार्प
@msg artboard {i} doesn't print आर्टबोर्ड {i} प्रिंट नहीं करता है
@msg artboard is a 0-based index artboard 0 से शुरू होने वाला इंडेक्स है
@msg artboard must be a 0-based index artboard 0 से शुरू होने वाला इंडेक्स होना चाहिए
@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout को {layout?, columns?, spacing?, rightToLeft?} होना चाहिए
@msg artboardLayout must be {{layout?, columns?, spacing?, rightToLeft?}} artboardLayout को {{layout?, columns?, spacing?, rightToLeft?}} होना चाहिए
@msg artboardLayout.columns must be 1 or more artboardLayout.columns को 1 या अधिक होना चाहिए
@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout को gridByRow, gridByColumn, row या column होना चाहिए
@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft को true या false होना चाहिए
@@ -6777,13 +6778,13 @@ antiAlias Sharp शार्प
@msg DXF scale must be a positive number up to {_1}, not {scale} DXF स्केल {_1} तक की धनात्मक संख्या होनी चाहिए, न कि {scale}
@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf इकाई `{u}`: एक इकाई जैसे mm, cm, in या pt
@msg dxf: {e} dxf: {e}
@msg each format is an object {format, scale?, suffix?} हर फ़ॉर्मैट एक ऑब्जेक्ट {format, scale?, suffix?} है
@msg each format is an object {{format, scale?, suffix?}} हर फ़ॉर्मैट एक ऑब्जेक्ट {{format, scale?, suffix?}} है
@msg each item needs an id प्रत्येक आइटम को एक आईडी की आवश्यकता होती है
@msg each match needs `kind` प्रत्येक मैच की जरूरत है `kind`
@msg each match needs a `name` प्रत्येक मैच को `name` की आवश्यकता होती है
@msg each of `fonts` is {family, style?}; 1 to 1000 of them `fonts` का हर आइटम {family, style?} है; 1 से 1000 आइटम
@msg each of `fonts` is {{family, style?}}; 1 to 1000 of them `fonts` का हर आइटम {{family, style?}} है; 1 से 1000 आइटम
@msg each of `items` is {{id, corners?}} with an object id, not {it} `items` का हर आइटम ऑब्जेक्ट आईडी वाला {{id, corners?}} है, {it} नहीं
@msg each of `items` is {kind, item} `items` का हर आइटम {kind, item} है
@msg each of `items` is {{kind, item}} `items` का हर आइटम {{kind, item}} है
@msg each row needs `from` हर पंक्ति के लिए `from` चाहिए
@msg each stop needs a position प्रत्येक स्टॉप को स्थिति की आवश्यकता होती है
@msg each subpath needs an anchors array प्रत्येक उपपाथ को एक एंकर सरणी की आवश्यकता होती है
@@ -6815,8 +6816,8 @@ antiAlias Sharp शार्प
@msg Expand: no gradient fills विस्तार: कोई ग्रेडिएंट भरता नहीं
@msg Expand: nothing to expand विस्तार: विस्तार करने के लिए कुछ नहीं
@msg expanded SVG styles exceed the import size limit विस्तारित SVG स्टाइल्स आयात आकार सीमा से अधिक है
@msg expected {"dataBase64"} अपेक्षित: {"dataBase64"}
@msg expected {"objects"} अपेक्षित: {"objects"}
@msg expected {{"dataBase64": "…"}} अपेक्षित: {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} अपेक्षित: {{"objects": [...]}}
@msg export `{name}`: {e} निर्यात `{name}`: {e}
@msg Export cancelled निर्यात रद्द
@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft स्क्रीन के लिए एक्सपोर्ट png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai या vectorcraft लिखता है
@@ -6836,7 +6837,7 @@ antiAlias Sharp शार्प
@msg features must be a list of tags features टैग की एक सूची होनी चाहिए
@msg fidelity must be 0..100 fidelity 0..100 होना चाहिए
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) फ़ाइल संस्करण {0} इस वेक्टर क्रेफ्ट समर्थन ({VERSION}) की तुलना में नया है।
@msg files must be {name, dataBase64} files {name, dataBase64} होना चाहिए
@msg files must be {{name, dataBase64}} files {{name, dataBase64}} होना चाहिए
@msg finish editing the pattern first पहले पैटर्न को संपादित करना
@msg First measured segment no longer exists पहले मापा खंड अब मौजूद नहीं है
@msg fit must be none|autoHeight|shrinkText|block, got {id} fit को none|autoHeight|shrinkText|block होना चाहिए, {id} मिला
@@ -6862,7 +6863,7 @@ antiAlias Sharp शार्प
@msg frequency must be between 1 and 300 lines per inch frequency प्रति इंच 1 से 300 लाइनों के बीच होनी चाहिए
@msg from and to must be finite से और होना चाहिए करने के लिए
@msg from must be [x0, y0, x1, y1] from [x0, y0, x1, y1] होना चाहिए
@msg give {key, value} or {values} {key, value} या {values} दें
@msg give {{key, value}} or {{values}} {{key, value}} या {{values}} दें
@msg give `{a}` and `{b}` `{a}` और `{b}` दें
@msg give `anchor`, the index of the anchor to slide खिसकाने वाले एंकर का इंडेक्स `anchor` दें
@msg give `before` or `after`, not both `before` या `after` दें, दोनों नहीं
@@ -6880,7 +6881,7 @@ antiAlias Sharp शार्प
@msg give `id` or `guide`, not both `id` या `guide` दें, दोनों नहीं
@msg give `inkLimit`, `removeWhiteOverprint` or `overprintBlack` `inkLimit`, `removeWhiteOverprint` या `overprintBlack` दें
@msg give `length` or `to` `length` या `to` देना
@msg give `matches`: [{kind, name, target}] `matches`: [{kind, name, target}]
@msg give `matches`: [{{kind, name, target}}] `matches`: [{{kind, name, target}}]
@msg give `name` (or edit a pattern) `name` (या एक पैटर्न संपादित करें)
@msg give `name` or `names` `name` या `names` दें
@msg give `path` (a .wasm file) or `dataBase64` (the module) `path` (.wasm फ़ाइल) या `dataBase64` (मॉड्यूल) दें
@@ -6937,7 +6938,7 @@ antiAlias Sharp शार्प
@msg give the preset a `name` प्रीसेट देना `name`
@msg give the preset a name प्रीसेट को एक नाम देना
@msg give the preset a name (1–64 characters) प्रीसेट का नाम दें (1–64 अक्षर)
@msg give the settings to change, e.g. {"formats"}]} सेटिंग्स को बदलने के लिए, उदाहरण के लिए {"formats"}]}
@msg give the settings to change, e.g. {{"formats": [{{"format": "png", "scale": "2x"}}]}} सेटिंग्स को बदलने के लिए, उदाहरण के लिए {{"formats": [{{"format": "png", "scale": "2x"}}]}}
@msg give the style a name स्टाइल को एक नाम दें
@msg give two or more style `names` दो या अधिक स्टाइल `names`
@msg give two or more swatches दो या अधिक स्वॉच दें
@@ -7157,7 +7158,7 @@ antiAlias Sharp शार्प
@msg missing `library` (see graphicStyle.libraries) लापता `library` (graphicStyle.libraries देखें)
@msg missing `library` (see swatch.library.list) लापता `library` (swatch.library.list देखें)
@msg missing `library` (see symbol.libraries) `library` अनुपस्थित है (symbol.libraries देखें)
@msg missing `map` (rows [{from, to}] or {current}) लापता `map` (rows [{from, to}] या {current})
@msg missing `map` (rows [{{from, to}}] or {{current: new}}) लापता `map` (rows [{{from, to}}] या {{current: new}})
@msg missing `matrix` [a,b,c,d,e,f] लापता `matrix` [a,b,c,d,e,f]
@msg missing `name` लापता `name`
@msg missing `name` (see gradient.presets.list) लापता `name` (gradient.presets.list देखें)
@@ -7527,7 +7528,7 @@ antiAlias Sharp शार्प
@msg not an export presets file (format `{_1}`) यह एक्सपोर्ट प्रीसेट फ़ाइल नहीं है (फ़ॉर्मैट `{_1}`)
@msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e}
@msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें)
@msg not an RTF file: it doesn't start with {\\rtf RTF फ़ाइल नहीं: यह {\rtf के साथ शुरू नहीं होता है
@msg not an RTF file: it doesn't start with {{\\rtf RTF फ़ाइल नहीं: यह {{\rtf के साथ शुरू नहीं होता है
@msg not area type (text in a frame) outside perspective पर्सपेक्टिव के बाहर का एरिया टाइप (फ़्रेम में टेक्स्ट) नहीं है
@msg not editing an opacity mask ओपेसिटी मास्क संपादित नहीं किया जा रहा है
@msg not found नहीं मिला
@@ -7724,7 +7725,7 @@ antiAlias Sharp शार्प
@msg printing isn't available here मुद्रण यहाँ उपलब्ध नहीं है
@msg procedures nest too deeply प्रक्रियाओं घोंसला बहुत गहराई से
@msg procedures nested too deeply प्रक्रियाओं ने बहुत गहराई से घोंसले
@msg quotes must be {double?, single?} quotes को {double?, single?} होना चाहिए
@msg quotes must be {{double?, single?}} quotes को {{double?, single?}} होना चाहिए
@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" उद्धरण.{k} एक उद्घाटन और एक समापन उद्धरण होना चाहिए, उदाहरण के लिए "“”"
@msg range `{s}` is empty श्रेणी `{s}` खाली है
@msg range `{s}`: `{part}` names no number सीमा `{s}`: `{part}` कोई संख्या नहीं बताता
@@ -7965,14 +7966,14 @@ antiAlias Sharp शार्प
@msg Switch x/y is for scatter graphs स्विच x/y स्कैटर ग्राफ़ के लिए है
@msg t, dx and dy must be finite टी, डीएक्स और डीआई को परिमित होना चाहिए
@msg Targa files are RGB: colorModel rgb Targa फ़ाइलें RGB हैं: colorModel rgb
@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text को {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} होना चाहिए
@msg text must be {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} text को {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} होना चाहिए
@msg Text options: {e} टेक्स्ट ऑप्शन्स: {e}
@msg text transform is collapsed or unrepresentable टेक्स्ट ट्रांसफ़ॉर्म सिमटा हुआ है या उसे दर्शाया नहीं जा सकता
@msg Text Wrap: select a wrap object टेक्स्ट लपेटें: एक रैप ऑब्जेक्ट चुनें
@msg text.{key} `{v}`: use "{_1}" or "{_2}" Text.{key} `{v}`: "{_1}" या "{_2}" का उपयोग करें।
@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces को 2 से 100 के बीच पूर्णांक होना चाहिए
@msg textureImage: {e} textureImage: {e}
@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: किसी इमेज फ़ाइल का {path} या {name, dataBase64} दें, या effect.list द्वारा दिया गया टेक्सचर दें
@msg textureImage: give {{path}} or {{name, dataBase64}} of an image file, or a texture as effect.list gives it textureImage: किसी इमेज फ़ाइल का {{path}} या {{name, dataBase64}} दें, या effect.list द्वारा दिया गया टेक्सचर दें
@msg that anchor can't slide: an end of an open path has nowhere to go कि एंकर स्लाइड नहीं कर सकते: एक खुले रास्ते का अंत कहीं नहीं जाना है
@msg the active document changed while the question was open; nothing was deleted प्रश्न खुला रहते हुए सक्रिय डॉक्यूमेंट बदल गया; कुछ भी नहीं हटाया गया
@msg that document is no longer open यह डॉक्यूमेंट अब खुला नहीं है
Can't render this file because it is too large.
+6 -5
View File
@@ -3224,6 +3224,7 @@ mark-style Japanese Japán
Proportional Metrics Arányos metrika
Insert Aki Left Aki beszúrása balra
Insert Aki Right Aki beszúrása jobbra
Tsume Tsume
Proportional Lining Arányos modern
Position: Pozíció:
Default Position Alapértelmezett pozíció
@@ -6376,8 +6377,8 @@ axis Y Axis Y tengely
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` az exportáló bővítmény 1–{MAX_EXTENSIONS} `extensions` értéket ad meg
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` csoporton kívül legfeljebb {MAX_UNGROUPED} paraméter lehet: a többinek adj meg `group` értéket
@msg can't encode the input a bemenet nem kódolható
@msg expected {"dataBase64"} várt formátum: {"dataBase64"}
@msg expected {"objects"} várt formátum: {"objects"}
@msg expected {{"dataBase64": "…"}} várt formátum: {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} várt formátum: {{"objects": [...]}}
@msg export `{name}`: {e} `{name}` modulexport: {e}
@msg extension {e} is listed twice a(z) {e} kiterjesztés kétszer szerepel
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} kiterjesztés: 1-16 kisbetűt és számjegyet használj, pont nélkül
@@ -6441,11 +6442,11 @@ axis Y Axis Y tengely
@msg a painted live effect returns only `objects` a festő élő effektus csak `objects` értéket ad vissza
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` a festő élő effektus darabjának csak `path`, `fillRule`, `fills` és `strokes` kulcsa van
@msg a read-only object was left out kimaradt egy írásvédett objektum
@msg a read-only object was left out: return it, as {"id"} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {"id"} formában vagy úgy, ahogy megkapta
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given kimaradt egy írásvédett objektum: adja vissza önmagában {{"id": …}} formában vagy úgy, ahogy megkapta
@msg a string karakterlánc
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` a körvonalnak csak `paint`, `width`, `join`, `cap` és `miterLimit` kulcsa van
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} a körvonal formája {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} legyen
@msg an effect must be {"id", "params"?, "visible"?} az effektus formája {"id", "params"?, "visible"?} legyen
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} a körvonal formája {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} legyen
@msg an effect must be {{"id", "params"?, "visible"?}} az effektus formája {{"id", "params"?, "visible"?}} legyen
@msg an effect on a fill returns only `fills` a kitöltésen lévő effektus csak `fills` értéket ad vissza
@msg an effect on a stroke returns only `strokes` a körvonalon lévő effektus csak `strokes` értéket ad vissza
@msg an object must be a JSON object az objektum JSON-objektum legyen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1509,6 +1509,7 @@ lighting Light Cahaya
Proportional Metrics Metrik proporsional
Insert Aki Left Sisipkan aki kiri
Insert Aki Right Sisipkan aki kanan
Tsume Tsume
Character Direction Arah karakter
Digits Digit
Arabic Arab
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1509
1510
1511
1512
1513
1514
1515
+6 -5
View File
@@ -2708,6 +2708,7 @@ mark-style Japanese Giapponese
Proportional Metrics Metrica proporzionale
Insert Aki Left Inserisci aki a sinistra
Insert Aki Right Inserisci aki a destra
Tsume Tsume
Character Direction Direzione caratteri
Digits Cifre
Arabic Arabe
@@ -6019,8 +6020,8 @@ axis Y Axis Asse Y
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` un plug-in di esportazione dichiara da 1 a {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` al massimo {MAX_UNGROUPED} parametri fuori dai gruppi: assegna agli altri un `group`
@msg can't encode the input impossibile codificare l’input
@msg expected {"dataBase64"} previsto {"dataBase64"}
@msg expected {"objects"} previsto {"objects"}
@msg expected {{"dataBase64": "…"}} previsto {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} previsto {{"objects": [...]}}
@msg export `{name}`: {e} esportazione del modulo `{name}`: {e}
@msg extension {e} is listed twice l’estensione {e} è elencata due volte
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot estensione {e}: usa 1-16 lettere minuscole e cifre, senza il punto
@@ -6084,11 +6085,11 @@ axis Y Axis Asse Y
@msg a painted live effect returns only `objects` un effetto dinamico di pittura restituisce solo `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` un pezzo di un effetto dinamico di pittura ha solo `path`, `fillRule`, `fills` e `strokes`
@msg a read-only object was left out è stato omesso un oggetto di sola lettura
@msg a read-only object was left out: return it, as {"id"} alone or as it was given è stato omesso un oggetto di sola lettura: restituiscilo come {"id"} da solo o così come è stato fornito
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given è stato omesso un oggetto di sola lettura: restituiscilo come {{"id": …}} da solo o così come è stato fornito
@msg a string una stringa
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` una traccia ha solo `paint`, `width`, `join`, `cap` e `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} una traccia deve essere {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} un effetto deve essere {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} una traccia deve essere {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} un effetto deve essere {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` un effetto su un riempimento restituisce solo `fills`
@msg an effect on a stroke returns only `strokes` un effetto su una traccia restituisce solo `strokes`
@msg an object must be a JSON object un oggetto deve essere un oggetto JSON
Can't render this file because it is too large.
+6 -5
View File
@@ -52,6 +52,7 @@ font-technology Variable バリアブル
Proportional Metrics プロポーショナルメトリクス
Insert Aki Left 前にアキを挿入
Insert Aki Right 後ろにアキを挿入
Tsume 文字ツメ
Character Direction 文字の方向
Digits 数字
Arabic アラビア数字
@@ -6085,8 +6086,8 @@ axis Y Axis Y 軸
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 書き出しプラグインは `extensions` を 1~{MAX_EXTENSIONS} 個宣言します
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` グループ外のパラメーターは {MAX_UNGROUPED} 個までです。ほかのパラメーターには `group` を指定してください
@msg can't encode the input 入力をエンコードできません
@msg expected {"dataBase64"} {"dataBase64"} が必要です
@msg expected {"objects"} {"objects"} が必要です
@msg expected {{"dataBase64": "…"}} {{"dataBase64": "…"}} が必要です
@msg expected {{"objects": [...]}} {{"objects": [...]}} が必要です
@msg export `{name}`: {e} モジュールのエクスポート `{name}`: {e}
@msg extension {e} is listed twice 拡張子 {e} が 2 回指定されています
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 拡張子 {e}: ドットを付けずに小文字と数字で 1-16 文字にしてください
@@ -6150,11 +6151,11 @@ axis Y Axis Y 軸
@msg a painted live effect returns only `objects` ペイント型のライブ効果が返すのは `objects` だけです
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` ペイント型のライブ効果のピースに使えるのは `path`、`fillRule`、`fills`、`strokes` だけです
@msg a read-only object was left out 読み取り専用のオブジェクトが省かれています
@msg a read-only object was left out: return it, as {"id"} alone or as it was given 読み取り専用のオブジェクトが省かれています: {"id"} だけの形か、渡されたとおりの形で返してください
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 読み取り専用のオブジェクトが省かれています: {{"id": …}} だけの形か、渡されたとおりの形で返してください
@msg a string 文字列
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 線に使えるのは `paint`、`width`、`join`、`cap`、`miterLimit` だけです
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 線は {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} の形にしてください
@msg an effect must be {"id", "params"?, "visible"?} 効果は {"id", "params"?, "visible"?} の形にしてください
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 線は {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} の形にしてください
@msg an effect must be {{"id", "params"?, "visible"?}} 効果は {{"id", "params"?, "visible"?}} の形にしてください
@msg an effect on a fill returns only `fills` 塗りの効果が返すのは `fills` だけです
@msg an effect on a stroke returns only `strokes` 線の効果が返すのは `strokes` だけです
@msg an object must be a JSON object オブジェクトは JSON オブジェクトにしてください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7 -6
View File
@@ -1446,6 +1446,7 @@ lighting Light სინათლე
Proportional Metrics პროპორციული მეტრიკა
Insert Aki Left აკის ჩასმა მარცხნივ
Insert Aki Right აკის ჩასმა მარჯვნივ
Tsume ცუმე
Character Direction სიმბოლოების მიმართულება
Digits ციფრები
Arabic არაბული
@@ -6344,8 +6345,8 @@ axis Y Axis Y ღერძი
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` ექსპორტის პლაგინი აცხადებს 1-დან {MAX_EXTENSIONS}-მდე `extensions`-ს
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` ჯგუფების გარეთ მაქსიმუმ {MAX_UNGROUPED} პარამეტრია დასაშვები: დანარჩენებს მიეცით `group`
@msg can't encode the input შეყვანის კოდირება ვერ ხერხდება
@msg expected {"dataBase64"} მოსალოდნელი იყო {"dataBase64"}
@msg expected {"objects"} მოსალოდნელი იყო {"objects"}
@msg expected {{"dataBase64": "…"}} მოსალოდნელი იყო {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} მოსალოდნელი იყო {{"objects": [...]}}
@msg export `{name}`: {e} მოდულის ექსპორტი `{name}`: {e}
@msg extension {e} is listed twice გაფართოება {e} ორჯერაა მითითებული
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot გაფართოება {e}: გამოიყენეთ 1-16 პატარა ასო და ციფრი, წერტილის გარეშე
@@ -6409,11 +6410,11 @@ axis Y Axis Y ღერძი
@msg a painted live effect returns only `objects` მხატვრული ცოცხალი ეფექტი აბრუნებს მხოლოდ `objects`-ს
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` მხატვრული ცოცხალი ეფექტის ნაწილს აქვს მხოლოდ `path`, `fillRule`, `fills` და `strokes`
@msg a read-only object was left out გამოტოვებულია მხოლოდ წასაკითხი ობიექტი
@msg a read-only object was left out: return it, as {"id"} alone or as it was given გამოტოვებულია მხოლოდ წასაკითხი ობიექტი: დააბრუნეთ ის მხოლოდ {"id"}-ის სახით ან ისე, როგორც მიიღეთ
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given გამოტოვებულია მხოლოდ წასაკითხი ობიექტი: დააბრუნეთ ის მხოლოდ {{"id": …}}-ის სახით ან ისე, როგორც მიიღეთ
@msg a string სტრიქონი
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` კონტურს აქვს მხოლოდ `paint`, `width`, `join`, `cap` და `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} კონტური უნდა იყოს {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} ეფექტი უნდა იყოს {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} კონტური უნდა იყოს {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} ეფექტი უნდა იყოს {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` შევსების ეფექტი აბრუნებს მხოლოდ `fills`-ს
@msg an effect on a stroke returns only `strokes` კონტურის ეფექტი აბრუნებს მხოლოდ `strokes`-ს
@msg an object must be a JSON object ობიექტი უნდა იყოს JSON ობიექტი
@@ -7359,7 +7360,7 @@ font-script Japanese იაპონური
Japanese Envelope #3 იაპონური კონვერტი #3
Japanese Envelope #4 იაპონური კონვერტი #4
@msg image '{key}' has no pixels and was left out გამოსახულებას '{key}' პიქსელები არ აქვს და გამოტოვდა
@msg not an RTF file: it doesn't start with {\\rtf RTF ფაილი არ არის: არ იწყება {\\rtf-ით
@msg not an RTF file: it doesn't start with {{\\rtf RTF ფაილი არ არის: არ იწყება {{\\rtf-ით
# Type Tools add-on (Object › Plug-ins › Type Tools).
Type Tools ტექსტის ხელსაწყოები
Text Tweaker… ტექსტის მორგება…
Can't render this file because it is too large.
+6 -5
View File
@@ -46,6 +46,7 @@ font-technology Variable 가변
Proportional Metrics 비례 메트릭
Insert Aki Left 앞 아키 삽입
Insert Aki Right 뒤 아키 삽입
Tsume 츠메
Character Direction 문자 방향
Digits 숫자
Arabic 아라비아어
@@ -5980,8 +5981,8 @@ axis Y Axis Y축
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 내보내기 플러그인은 `extensions`를 1~{MAX_EXTENSIONS}개 선언합니다
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 그룹 밖의 매개변수는 최대 {MAX_UNGROUPED}개입니다. 나머지에는 `group`을 지정하세요
@msg can't encode the input 입력을 인코딩할 수 없습니다
@msg expected {"dataBase64"} {"dataBase64"} 형식이어야 합니다
@msg expected {"objects"} {"objects"} 형식이어야 합니다
@msg expected {{"dataBase64": "…"}} {{"dataBase64": "…"}} 형식이어야 합니다
@msg expected {{"objects": [...]}} {{"objects": [...]}} 형식이어야 합니다
@msg export `{name}`: {e} 모듈 내보내기 `{name}`: {e}
@msg extension {e} is listed twice 확장자 {e}이(가) 두 번 나열되었습니다
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 확장자 {e}: 점 없이 소문자와 숫자 1-16자를 사용하세요
@@ -6045,11 +6046,11 @@ axis Y Axis Y축
@msg a painted live effect returns only `objects` 페인트형 라이브 효과는 `objects`만 반환합니다
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 페인트형 라이브 효과의 조각에는 `path`, `fillRule`, `fills`, `strokes`만 있습니다
@msg a read-only object was left out 읽기 전용 개체가 빠졌습니다
@msg a read-only object was left out: return it, as {"id"} alone or as it was given 읽기 전용 개체가 빠졌습니다: {"id"}만 있는 형태나 받은 그대로 반환하십시오
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 읽기 전용 개체가 빠졌습니다: {{"id": …}}만 있는 형태나 받은 그대로 반환하십시오
@msg a string 문자열
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 선에는 `paint`, `width`, `join`, `cap`, `miterLimit`만 있습니다
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 선은 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 형태여야 합니다
@msg an effect must be {"id", "params"?, "visible"?} 효과는 {"id", "params"?, "visible"?} 형태여야 합니다
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 선은 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 형태여야 합니다
@msg an effect must be {{"id", "params"?, "visible"?}} 효과는 {{"id", "params"?, "visible"?}} 형태여야 합니다
@msg an effect on a fill returns only `fills` 칠에 적용된 효과는 `fills`만 반환합니다
@msg an effect on a stroke returns only `strokes` 선에 적용된 효과는 `strokes`만 반환합니다
@msg an object must be a JSON object 개체는 JSON 개체여야 합니다
Can't render this file because it is too large.
+6 -5
View File
@@ -290,8 +290,8 @@
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` een exportplug-in declareert 1 tot {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` maximaal {MAX_UNGROUPED} parameters buiten groepen: geef de andere een `group`
@msg can't encode the input de invoer kan niet worden gecodeerd
@msg expected {"dataBase64"} verwacht: {"dataBase64"}
@msg expected {"objects"} verwacht: {"objects"}
@msg expected {{"dataBase64": "…"}} verwacht: {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} verwacht: {{"objects": [...]}}
@msg export `{name}`: {e} export `{name}`: {e}
@msg extension {e} is listed twice extensie {e} staat twee keer in de lijst
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensie {e}: gebruik 1-16 kleine letters en cijfers, zonder de punt
@@ -355,11 +355,11 @@
@msg a painted live effect returns only `objects` een geschilderd live-effect geeft alleen `objects` terug
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` een stuk van een geschilderd live-effect heeft alleen `path`, `fillRule`, `fills` en `strokes`
@msg a read-only object was left out een alleen-lezen object is weggelaten
@msg a read-only object was left out: return it, as {"id"} alone or as it was given een alleen-lezen object is weggelaten: geef het terug, als alleen {"id"} of zoals het is gegeven
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given een alleen-lezen object is weggelaten: geef het terug, als alleen {{"id": …}} of zoals het is gegeven
@msg a string een string
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` een contour heeft alleen `paint`, `width`, `join`, `cap` en `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} een contour moet {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} zijn
@msg an effect must be {"id", "params"?, "visible"?} een effect moet {"id", "params"?, "visible"?} zijn
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} een contour moet {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} zijn
@msg an effect must be {{"id", "params"?, "visible"?}} een effect moet {{"id", "params"?, "visible"?}} zijn
@msg an effect on a fill returns only `fills` een effect op een vulling geeft alleen `fills` terug
@msg an effect on a stroke returns only `strokes` een effect op een contour geeft alleen `strokes` terug
@msg an object must be a JSON object een object moet een JSON-object zijn
@@ -2027,6 +2027,7 @@ lighting Light Licht
Proportional Metrics Proportionele metingen
Insert Aki Left Aki links invoegen
Insert Aki Right Aki rechts invoegen
Tsume Tsume
Character Direction Tekenrichting
Digits Cijfers
Arabic Arabisch
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -5
View File
@@ -1514,6 +1514,7 @@ lighting Light Światło
Proportional Metrics Metryka proporcjonalna
Insert Aki Left Wstaw aki z lewej
Insert Aki Right Wstaw aki z prawej
Tsume Tsume
Character Direction Kierunek znaków
Digits Cyfry
Arabic Arabskie
@@ -6248,8 +6249,8 @@ axis Y Axis Oś Y
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` wtyczka eksportu deklaruje od 1 do {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` najwyżej {MAX_UNGROUPED} parametrów poza grupami: pozostałym nadaj `group`
@msg can't encode the input nie można zakodować danych wejściowych
@msg expected {"dataBase64"} oczekiwano {"dataBase64"}
@msg expected {"objects"} oczekiwano {"objects"}
@msg expected {{"dataBase64": "…"}} oczekiwano {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} oczekiwano {{"objects": [...]}}
@msg export `{name}`: {e} eksport modułu `{name}`: {e}
@msg extension {e} is listed twice rozszerzenie {e} występuje dwa razy
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot rozszerzenie {e}: użyj 1-16 małych liter i cyfr, bez kropki
@@ -6313,11 +6314,11 @@ axis Y Axis Oś Y
@msg a painted live effect returns only `objects` malujący efekt dynamiczny zwraca tylko `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` element malującego efektu dynamicznego ma tylko `path`, `fillRule`, `fills` i `strokes`
@msg a read-only object was left out pominięto obiekt tylko do odczytu
@msg a read-only object was left out: return it, as {"id"} alone or as it was given pominięto obiekt tylko do odczytu: zwróć go jako samo {"id"} lub w postaci, w jakiej został przekazany
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given pominięto obiekt tylko do odczytu: zwróć go jako samo {{"id": …}} lub w postaci, w jakiej został przekazany
@msg a string ciągiem znaków
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` obrys ma tylko `paint`, `width`, `join`, `cap` i `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} obrys musi mieć postać {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} efekt musi mieć postać {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} obrys musi mieć postać {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} efekt musi mieć postać {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` efekt wypełnienia zwraca tylko `fills`
@msg an effect on a stroke returns only `strokes` efekt obrysu zwraca tylko `strokes`
@msg an object must be a JSON object obiekt musi być obiektem JSON
Can't render this file because it is too large.
+7 -6
View File
@@ -2232,6 +2232,7 @@ antiAlias Sharp Nítido
Proportional Metrics Métrica Proporcional
Insert Aki Left Inserir Aki à Esquerda
Insert Aki Right Inserir Aki à Direita
Tsume Tsume
Character Direction Direção de Caracteres
Digits Dígitos
Arabic Arábicos
@@ -6308,8 +6309,8 @@ axis Y Axis Eixo Y
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` um plug-in de exportação declara de 1 a {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` no máximo {MAX_UNGROUPED} parâmetros fora de grupos: dê um `group` aos outros
@msg can't encode the input não é possível codificar a entrada
@msg expected {"dataBase64"} esperado {"dataBase64"}
@msg expected {"objects"} esperado {"objects"}
@msg expected {{"dataBase64": "…"}} esperado {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} esperado {{"objects": [...]}}
@msg export `{name}`: {e} exportação do módulo `{name}`: {e}
@msg extension {e} is listed twice a extensão {e} aparece duas vezes
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot extensão {e}: use 1-16 letras minúsculas e dígitos, sem o ponto
@@ -6373,11 +6374,11 @@ axis Y Axis Eixo Y
@msg a painted live effect returns only `objects` um efeito dinâmico de pintura retorna apenas `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` uma peça de um efeito dinâmico de pintura tem apenas `path`, `fillRule`, `fills` e `strokes`
@msg a read-only object was left out um objeto somente leitura foi omitido
@msg a read-only object was left out: return it, as {"id"} alone or as it was given um objeto somente leitura foi omitido: retorne-o como {"id"} sozinho ou como foi recebido
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given um objeto somente leitura foi omitido: retorne-o como {{"id": …}} sozinho ou como foi recebido
@msg a string uma string
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` um traçado tem apenas `paint`, `width`, `join`, `cap` e `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} um traçado deve ser {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} um efeito deve ser {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} um traçado deve ser {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} um efeito deve ser {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` um efeito em um preenchimento retorna apenas `fills`
@msg an effect on a stroke returns only `strokes` um efeito em um traçado retorna apenas `strokes`
@msg an object must be a JSON object um objeto deve ser um objeto JSON
@@ -7276,7 +7277,7 @@ swatch Lime Lima
Line Weight: Espessura da linha:
Load Variable Library Carregar biblioteca de variáveis
swatch Magenta Magenta
@msg not an RTF file: it doesn't start with {\\rtf não é um arquivo RTF: ele não começa com {\\rtf
@msg not an RTF file: it doesn't start with {{\\rtf não é um arquivo RTF: ele não começa com {{\\rtf
swatch Ocean Oceano
shape Open Aberta
swatch Orange Laranja
Can't render this file because it is too large.
+7 -6
View File
@@ -2695,6 +2695,7 @@ mark-style Japanese Японский
Proportional Metrics Пропорциональные метрики
Insert Aki Left Вставить аки слева
Insert Aki Right Вставить аки справа
Tsume Цумэ
Character Direction Направление символов
Digits Цифры
Arabic Арабские
@@ -6004,8 +6005,8 @@ axis Y Axis Ось Y
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль экспорта объявляет от 1 до {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` вне групп может быть не более {MAX_UNGROUPED} параметров: задайте остальным `group`
@msg can't encode the input не удаётся закодировать входные данные
@msg expected {"dataBase64"} ожидалось {"dataBase64"}
@msg expected {"objects"} ожидалось {"objects"}
@msg expected {{"dataBase64": "…"}} ожидалось {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} ожидалось {{"objects": [...]}}
@msg export `{name}`: {e} экспорт модуля `{name}`: {e}
@msg extension {e} is listed twice расширение {e} указано дважды
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot расширение {e}: используйте 1-16 строчных букв и цифр, без точки
@@ -6069,11 +6070,11 @@ axis Y Axis Ось Y
@msg a painted live effect returns only `objects` рисующий динамический эффект возвращает только `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` у части рисующего динамического эффекта есть только `path`, `fillRule`, `fills` и `strokes`
@msg a read-only object was left out пропущен объект только для чтения
@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущен объект только для чтения: верните его как один {"id"} или в том виде, в каком он был передан
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given пропущен объект только для чтения: верните его как один {{"id": …}} или в том виде, в каком он был передан
@msg a string строка
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` у обводки есть только `paint`, `width`, `join`, `cap` и `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обводка должна иметь вид {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} эффект должен иметь вид {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} обводка должна иметь вид {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} эффект должен иметь вид {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` эффект на заливке возвращает только `fills`
@msg an effect on a stroke returns only `strokes` эффект на обводке возвращает только `strokes`
@msg an object must be a JSON object объект должен быть объектом JSON
@@ -7381,7 +7382,7 @@ breakText Glyphs Глифы
@id pathfinder.add Объединить
@msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами
@msg image '{key}' has no pixels and was left out изображение '{key}' не содержит пикселов и было пропущено
@msg not an RTF file: it doesn't start with {\\rtf это не файл RTF: он не начинается с {\\rtf
@msg not an RTF file: it doesn't start with {{\\rtf это не файл RTF: он не начинается с {{\\rtf
@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG это файл PostScript (.ai, сохранённый в старом формате или без совместимости с PDF, либо EPS-файл), который пока нельзя открыть: сохраните его как PDF, .ai с совместимостью с PDF или SVG
@msg this kind of brush has no options dialog yet у кистей этого типа пока нет диалога параметров
digits Hindi Хинди
Can't render this file because it is too large.
+23 -22
View File
@@ -2603,6 +2603,7 @@ font-technology Variable Variabla
Proportional Metrics Proportionella mått
Insert Aki Left Infoga aki till vänster
Insert Aki Right Infoga aki till höger
Tsume Tsume
Character Direction Teckenriktning
Digits Siffror
Arabic Arabiska
@@ -6443,11 +6444,11 @@ axis Y Axis Y-axel
@msg a painted live effect returns only `objects` en målande live-effekt returnerar bara `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` en del av en målande live-effekt har bara `path`, `fillRule`, `fills` och `strokes`
@msg a read-only object was left out ett skrivskyddat objekt utelämnades
@msg a read-only object was left out: return it, as {"id"} alone or as it was given ett skrivskyddat objekt utelämnades: returnera det som enbart {"id"} eller som det gavs
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given ett skrivskyddat objekt utelämnades: returnera det som enbart {{"id": …}} eller som det gavs
@msg a string en sträng
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` en kontur har bara `paint`, `width`, `join`, `cap` och `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} en kontur måste vara {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} en effekt måste vara {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} en kontur måste vara {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} en effekt måste vara {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` en effekt på en fyllning returnerar bara `fills`
@msg an effect on a stroke returns only `strokes` en effekt på en kontur returnerar bara `strokes`
@msg an object must be a JSON object ett objekt måste vara ett JSON-objekt
@@ -6994,7 +6995,7 @@ anchor Points: Punkter:
VectorCraft Updates Uppdateringar för VectorCraft
Yonroku-ban Yonroku-ban
@msg image '{key}' has no pixels and was left out bilden '{key}' har inga pixlar och utelämnades
@msg not an RTF file: it doesn't start with {\\rtf inte en RTF-fil: den börjar inte med {\\rtf
@msg not an RTF file: it doesn't start with {{\\rtf inte en RTF-fil: den börjar inte med {{\\rtf
shape Open Öppen
# Color Effects add-on: Select by Color Kind.
@@ -7593,7 +7594,7 @@ digits Hindi Hindi
@msg `expand` must be true or false `expand` måste vara true eller false
@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` måste vara 1 till 1000 sökvägar till typsnittsfiler som den senaste text.findFontFiles-sökningen hittade
@msg `folder` must be an absolute folder path `folder` måste vara en absolut mappsökväg
@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` måste vara ett objekt med axeltaggar och värden, till exempel {"wght"}
@msg `fontAxes` must be an object of axis tags and values, such as {{"wght": 650}} `fontAxes` måste vara ett objekt med axeltaggar och värden, till exempel {{"wght": 650}}
@msg `format` must be a string `format` måste vara en sträng
@msg `format` must be one of {_1} `format` måste vara något av {_1}
@msg `fractions` is chop or scale `fractions` är chop eller scale
@@ -7616,12 +7617,12 @@ digits Hindi Hindi
@msg `index` must be a point index `index` måste vara ett punktindex
@msg `index` must be a whole number or null `index` måste vara ett heltal eller null
@msg `indices` must be a non-empty array of item indices `indices` måste vara en icke-tom array av objektindex
@msg `items` is a list of {kind, item} `items` är en lista med {kind, item}
@msg `items` is a list of {{kind, item}} `items` är en lista med {{kind, item}}
@msg `items` is empty `items` är tom
@msg `items` must be a list of {{id, anchors?}}, not {v} `items` måste vara en lista med {{id, anchors?}}, inte {v}
@msg `items` must be a list of {id, points} `items` måste vara en lista med {id, points}
@msg `items` must be a list of {{id, points}} `items` måste vara en lista med {{id, points}}
@msg `items` must be an array of {{id, corners?}}, not {v} `items` måste vara en array av {{id, corners?}}, inte {v}
@msg `items` must be an array of {id, anchors} `items` måste vara en array av {id, anchors}
@msg `items` must be an array of {{id, anchors}} `items` måste vara en array av {{id, anchors}}
@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` måste vara "none", "short", "medium", "long" eller "stylistic"
@msg `kind` is one of {_1} `kind` är något av {_1}
@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` måste vara "round", "invertedRound" eller "chamfer", inte {v}
@@ -7787,7 +7788,7 @@ digits Hindi Hindi
@msg artboard {i} doesn't print rityta {i} skrivs inte ut
@msg artboard is a 0-based index artboard är ett 0-baserat index
@msg artboard must be a 0-based index artboard måste vara ett 0-baserat index
@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout måste vara {layout?, columns?, spacing?, rightToLeft?}
@msg artboardLayout must be {{layout?, columns?, spacing?, rightToLeft?}} artboardLayout måste vara {{layout?, columns?, spacing?, rightToLeft?}}
@msg artboardLayout.columns must be 1 or more artboardLayout.columns måste vara 1 eller mer
@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout måste vara gridByRow, gridByColumn, row eller column
@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft måste vara true eller false
@@ -7935,13 +7936,13 @@ digits Hindi Hindi
@msg DXF scale must be a positive number up to {_1}, not {scale} DXF-skalan måste vara ett positivt tal upp till {_1}, inte {scale}
@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf-enhet `{u}`: en enhet som mm, cm, in eller pt
@msg dxf: {e} dxf: {e}
@msg each format is an object {format, scale?, suffix?} varje format är ett objekt {format, scale?, suffix?}
@msg each format is an object {{format, scale?, suffix?}} varje format är ett objekt {{format, scale?, suffix?}}
@msg each item needs an id varje objekt behöver ett id
@msg each match needs `kind` varje matchning behöver `kind`
@msg each match needs a `name` varje matchning behöver ett `name`
@msg each of `fonts` is {family, style?}; 1 to 1000 of them varje post i `fonts` är {family, style?}; 1 till 1000 stycken
@msg each of `fonts` is {{family, style?}}; 1 to 1000 of them varje post i `fonts` är {{family, style?}}; 1 till 1000 stycken
@msg each of `items` is {{id, corners?}} with an object id, not {it} varje post i `items` är {{id, corners?}} med ett objekt-id, inte {it}
@msg each of `items` is {kind, item} varje post i `items` är {kind, item}
@msg each of `items` is {{kind, item}} varje post i `items` är {{kind, item}}
@msg each row needs `from` varje rad behöver `from`
@msg each stop needs a position varje stopp behöver en position
@msg each subpath needs an anchors array varje delbana behöver en anchors-array
@@ -7959,8 +7960,8 @@ digits Hindi Hindi
@msg EPS Save writes all artwork: use document.export for selected-only EPS När du sparar som EPS skrivs allt bildmaterial: använd document.export för EPS med bara det markerade
@msg every numbered name for the result is taken alla numrerade namn för resultatet är upptagna
@msg expanded SVG styles exceed the import size limit de expanderade SVG-stilarna överskrider storleksgränsen för import
@msg expected {"dataBase64"} förväntade {"dataBase64"}
@msg expected {"objects"} förväntade {"objects"}
@msg expected {{"dataBase64": "…"}} förväntade {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} förväntade {{"objects": [...]}}
@msg export `{name}`: {e} export av `{name}`: {e}
@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft Exportera för skärmar skriver png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai eller vectorcraft
@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes filen med exportförinställningar är större än {MAX_PRESET_FILE_BYTES} byte
@@ -7973,7 +7974,7 @@ digits Hindi Hindi
@msg featureChanges must be a list of tags featureChanges måste vara en lista med taggar
@msg features must be a list of tags features måste vara en lista med taggar
@msg fidelity must be 0..100 fidelity måste vara 0..100
@msg files must be {name, dataBase64} files måste vara {name, dataBase64}
@msg files must be {{name, dataBase64}} files måste vara {{name, dataBase64}}
@msg fit must be none|autoHeight|shrinkText|block, got {id} fit måste vara none|autoHeight|shrinkText|block, fick {id}
@msg Flare click coordinates must be finite numbers Klickkoordinaterna för Ljusreflex måste vara ändliga tal
@msg flattener: {e} utplattning: {e}
@@ -7991,7 +7992,7 @@ digits Hindi Hindi
@msg frequency must be between 1 and 300 lines per inch frequency måste vara mellan 1 och 300 linjer per tum
@msg from and to must be finite from och to måste vara ändliga
@msg from must be [x0, y0, x1, y1] from måste vara [x0, y0, x1, y1]
@msg give {key, value} or {values} ange {key, value} eller {values}
@msg give {{key, value}} or {{values}} ange {{key, value}} eller {{values}}
@msg give `before` or `after`, not both ange `before` eller `after`, inte båda
@msg give `char` or `text` ange `char` eller `text`
@msg give `colors`, or `color` and `rule` ange `colors`, eller `color` och `rule`
@@ -8006,7 +8007,7 @@ digits Hindi Hindi
@msg give `id` and `anchors`, or `items`, not both ange `id` och `anchors`, eller `items`, inte båda
@msg give `id` or `guide`, not both ange `id` eller `guide`, inte båda
@msg give `length` or `to` ange `length` eller `to`
@msg give `matches`: [{kind, name, target}] ange `matches`: [{kind, name, target}]
@msg give `matches`: [{{kind, name, target}}] ange `matches`: [{{kind, name, target}}]
@msg give `name` (or edit a pattern) ange `name` (eller redigera ett mönster)
@msg give `name` or `names` ange `name` eller `names`
@msg give `path` (a .wasm file) or `dataBase64` (the module) ange `path` (en .wasm-fil) eller `dataBase64` (modulen)
@@ -8042,7 +8043,7 @@ digits Hindi Hindi
@msg give the preset a `name` ge förinställningen ett `name`
@msg give the preset a name ge förinställningen ett namn
@msg give the preset a name (1–64 characters) ge förinställningen ett namn (1–64 tecken)
@msg give the settings to change, e.g. {"formats"}]} ange inställningarna som ska ändras, t.ex. {"formats"}]}
@msg give the settings to change, e.g. {{"formats": [{{"format": "png", "scale": "2x"}}]}} ange inställningarna som ska ändras, t.ex. {{"formats": [{{"format": "png", "scale": "2x"}}]}}
@msg give the style a name ge stilen ett namn
@msg give two or more style `names` ange två eller fler stilnamn i `names`
@msg give two or more swatches ange två eller fler färgrutor
@@ -8147,7 +8148,7 @@ digits Hindi Hindi
@msg missing `library` `library` saknas
@msg missing `library` (see graphicStyle.libraries) `library` saknas (se graphicStyle.libraries)
@msg missing `library` (see swatch.library.list) `library` saknas (se swatch.library.list)
@msg missing `map` (rows [{from, to}] or {current}) `map` saknas (rader [{from, to}] eller {current})
@msg missing `map` (rows [{{from, to}}] or {{current: new}}) `map` saknas (rader [{{from, to}}] eller {{current: new}})
@msg missing `matrix` [a,b,c,d,e,f] `matrix` [a,b,c,d,e,f] saknas
@msg missing `name` (see gradient.presets.list) `name` saknas (se gradient.presets.list)
@msg missing `offset` (a distance in points) `offset` saknas (ett avstånd i punkter)
@@ -8439,7 +8440,7 @@ digits Hindi Hindi
@msg print settings: {e} utskriftsinställningar: {e}
@msg procedures nest too deeply procedurerna är nästlade för djupt
@msg procedures nested too deeply procedurerna nästlades för djupt
@msg quotes must be {double?, single?} quotes måste vara {double?, single?}
@msg quotes must be {{double?, single?}} quotes måste vara {{double?, single?}}
@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} måste vara ett inledande och ett avslutande citattecken, t.ex. "“”"
@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi måste vara 1–2400 (72 skärm, 150 medel, 300 hög)
@msg rating must be a whole number from 0 to {MAX_RATING} rating måste vara ett heltal från 0 till {MAX_RATING}
@@ -8551,13 +8552,13 @@ digits Hindi Hindi
@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG-filen är inte i UTF-8, UTF-16 eller ISO 8859-1
@msg swatchConflict must be "merge" or "add", not {v} swatchConflict måste vara "merge" eller "add", inte {v}
@msg Switch x/y is for scatter graphs Växla x/y gäller spridningsdiagram
@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text måste vara {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}
@msg text must be {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} text måste vara {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}}
@msg Text options: {e} Textalternativ: {e}
@msg text transform is collapsed or unrepresentable texttransformeringen är kollapsad eller kan inte återges
@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: använd "{_1}" eller "{_2}"
@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces måste vara ett heltal från 2 till 100
@msg textureImage: {e} textureImage: {e}
@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: ange {path} eller {name, dataBase64} för en bildfil, eller en textur så som effect.list anger den
@msg textureImage: give {{path}} or {{name, dataBase64}} of an image file, or a texture as effect.list gives it textureImage: ange {{path}} eller {{name, dataBase64}} för en bildfil, eller en textur så som effect.list anger den
@msg that makes more than {MAX_PIECES} slices det ger fler än {MAX_PIECES} skivor
@msg that object is not in the overlap det objektet ingår inte i överlappningen
@msg the action set ids are not a list åtgärdsuppsättningarnas id:n är inte en lista
Can't render this file because it is too large.
+74 -8
View File
@@ -2143,6 +2143,55 @@ fn message_keys_keep_doubled_braces() {
assert_eq!(as_template(r"it doesn't start with {\rtf"), r"it doesn't start with {\rtf");
}
/// The braces of a message that is not a format string (a plain literal or a `&str` constant) are
/// literal, so its catalog key doubles them; a `format!` string or an `#[error(…)]` string keeps
/// its `{{`, `}}` and placeholders.
#[test]
fn plain_message_keys_double_their_braces() {
let code = concat!(
"const SHAPE: &str = \"a shape is {\\\"kind\\\": …}\";\n",
"fn f() -> Result<()> { Err(Error::Output(\"expected {\\\"dataBase64\\\": \\\"…\\\"}\".into())) }\n",
"fn g() -> Result<()> { Err(SHAPE.into()) }\n",
"fn h() -> Result<()> { Err(bad(C, \"not an RTF file: it doesn't start with {\\\\rtf\")) }\n",
"fn i() -> Result<()> { Err(bad(C, format!(\"`{key}` must be {{min?, max?}}\"))) }\n",
"#[error(\"the file is {0} bytes, not {{size}}\")]\n",
);
let consts: std::collections::BTreeMap<String, Vec<String>> = str_consts(code).into_iter().map(|(name, value)| (name, vec![value])).collect();
// The engine's `error(` helper also occurs in each `#[error(` attribute.
let found: std::collections::BTreeSet<String> = source_messages(code, &[("bad(", 1), ("error(", 0)], &consts).into_iter().collect();
let want = [
r#"expected {{"dataBase64": "…"}}"#,
r#"a shape is {{"kind": …}}"#,
r"not an RTF file: it doesn't start with {{\rtf",
"`{key}` must be {{min?, max?}}",
"the file is {0} bytes, not {{size}}",
];
assert_eq!(found, want.iter().map(|s| s.to_string()).collect::<std::collections::BTreeSet<_>>(), "{found:#?}");
assert_eq!(as_literal_key("a {b} c}"), "a {{b}} c}}");
}
/// A catalog row for a message with literal braces translates that message and no other: it has no
/// placeholder to take the rest of a message with the same start.
#[test]
fn literal_brace_rows_match_only_their_message() {
let c = Catalog::parse("@msg\texpected {{\"dataBase64\": \"…\"}}\terwartet: {{\"dataBase64\": \"…\"}}\n");
let (form, caps) = c.message(r#"expected {"dataBase64": "…"}"#).expect("matches");
assert!(caps.is_empty(), "{caps:?}");
assert_eq!(fill_once(form, &caps), r#"erwartet: {"dataBase64": "…"}"#);
assert!(c.message("expected value at line 1 column 1").is_none());
assert!(c.message(r#"expected {"dataBase64"}"#).is_none());
assert_eq!(exact_translation(&c, r#"expected {"dataBase64": "…"}"#).as_deref(), Some(r#"erwartet: {"dataBase64": "…"}"#));
// An exact message is found at any length; a template is matched only up to its limit.
let long = format!("{{{{{}}}}}", "x".repeat(3000));
let c = Catalog::parse(&format!("@msg\t{long}\tlong {{{{}}}}\n@msg\tshort {{x}}\tshort {{x}} (de)\n"));
let shown = format!("{{{}}}", "x".repeat(3000));
assert_eq!(c.message(&shown).map(|(f, _)| f), Some("long {{}}"));
assert_eq!(exact_translation(&c, &shown).as_deref(), Some("long {}"));
assert_eq!(c.message("short y").map(|(f, _)| f), Some("short {x} (de)"));
// In the real catalog, a serde reason after the plug-in output error stays English.
assert_eq!(message(de(), "plug-in output: expected value at line 1 column 1"), "Plug-in-Ausgabe: expected value at line 1 column 1");
}
/// `plugin.run` of an importer, an exporter or a live effect names the command that runs it with
/// the JSON that picks it, and every complete-message catalog translates that error with the
/// plug-in's id and the JSON kept.
@@ -4207,7 +4256,8 @@ const LATER_MESSAGE_VARIANTS: &[(&str, usize)] = &[("Disabled", 1)];
const NOT_MESSAGES: &[&str] = &["quit", "cancelled", "vc_manifest", "[{i}]{place}", ".{k}{place}"];
/// Every status and error message literal in [`MESSAGE_CRATES`] (test modules aside), as catalog
/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`).
/// templates: `{}` becomes `{_1}`, `{_2}` … and format specs are dropped (`{n:?}` → `{n}`), and
/// the braces of a message that is not a format string are doubled.
pub fn message_literals() -> std::collections::BTreeSet<String> {
message_literals_in(MESSAGE_CRATES)
}
@@ -4330,16 +4380,19 @@ fn named_const<'a>(rest: &str, consts: &'a std::collections::BTreeMap<String, Ve
/// and the message argument of each call in `helpers` (a call and the number of arguments before
/// its message), a literal, a `format!` string or the name of one of `consts` (see
/// [`str_consts`]), each one with or without a `&` before it. A key's name alone (`` `tint` ``)
/// is not a message.
/// is not a message. A `format!` string and an `#[error(…)]` string are format strings (see
/// [`as_template`]); in any other literal or constant every brace is literal (see
/// [`as_literal_key`]).
fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collections::BTreeMap<String, Vec<String>>) -> Vec<String> {
let code = &without_comments(code);
let after_markers = MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| &code[at + marker.len()..]));
let after_markers =
MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| (&code[at + marker.len()..], *marker == "#[error(")));
let in_variants = ["Error::", "Fail::"].into_iter().flat_map(|enum_end| code.match_indices(enum_end)).filter_map(|(at, m)| {
let rest = &code[at + m.len()..];
let (variant, args) = rest.split_at(rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len()));
let named = variant.starts_with(|c: char| c.is_ascii_uppercase()) && !NAME_VARIANTS.contains(&variant);
let before = LATER_MESSAGE_VARIANTS.iter().find(|(v, _)| *v == variant).map_or(0, |&(_, n)| n);
if named { skip_args(args.strip_prefix('(')?, before) } else { None }
if named { skip_args(args.strip_prefix('(')?, before).map(|rest| (rest, false)) } else { None }
});
let in_map_err = code.match_indices(".map_err(").flat_map(|(at, m)| {
let rest = code[at + m.len()..].trim_start();
@@ -4358,13 +4411,20 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti
let in_helpers = helpers.iter().flat_map(|&(call, before)| {
code.match_indices(call)
.filter(|&(at, _)| call.starts_with('.') || !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.'))
.filter_map(move |(at, _)| skip_args(&code[at + call.len()..], before))
// `format!(` given as a helper, and an `error(` helper that is an `#[error(…)]` attribute, take a format string.
.filter_map(move |(at, _)| {
skip_args(&code[at + call.len()..], before).map(|rest| (rest, call == "format!(" || code[..at].ends_with("#[")))
})
});
let mut out = vec![];
for rest in after_markers.chain(in_variants).chain(in_map_err).chain(in_handlers).chain(in_helpers) {
let in_map_err = in_map_err.map(|rest| (rest, false));
let in_handlers = in_handlers.map(|rest| (rest, false));
for (rest, takes_format) in after_markers.chain(in_variants).chain(in_map_err).chain(in_handlers).chain(in_helpers) {
let rest = rest.trim_start();
let rest = rest.strip_prefix('&').map_or(rest, str::trim_start);
let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start);
let formatted = rest.strip_prefix("format!(").map(str::trim_start);
let format = takes_format || formatted.is_some();
let rest = formatted.unwrap_or(rest);
let lits = match rest.strip_prefix('"') {
Some(quoted) => string_literal(quoted).into_iter().collect(),
None => named_const(rest, consts).to_vec(),
@@ -4373,7 +4433,7 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti
let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase));
let key_name = lit.len() > 1 && lit.starts_with('`') && lit.ends_with('`') && !lit.contains(' ');
if has_word && !key_name && !NOT_MESSAGES.contains(&lit.as_str()) {
out.push(as_template(&lit));
out.push(if format { as_template(&lit) } else { as_literal_key(&lit) });
}
}
}
@@ -4568,6 +4628,12 @@ fn as_template(s: &str) -> String {
out
}
/// A message that is not a format string as a catalog key: each of its braces is literal, so it is
/// written `{{` or `}}`.
fn as_literal_key(s: &str) -> String {
s.replace('{', "{{").replace('}', "}}")
}
/// Render-site labels looked up in a context fall back to the plain row and then to English, and
/// their templates fill in English as in any language.
#[test]
+7 -6
View File
@@ -2450,6 +2450,7 @@ shape Open Açık
Proportional Metrics Orantılı Metrikler
Insert Aki Left Sola Aki Ekle
Insert Aki Right Sağa Aki Ekle
Tsume Tsume
Digits Rakamlar
Arabic Arapça
Hindi Hintçe
@@ -4519,7 +4520,7 @@ axis Both Her İkisi
@msg not an HP-GL file HP-GL dosyası değil
@msg not a PICT file PICT dosyası değil
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır
@msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor
@msg not an RTF file: it doesn't start with {{\\rtf bir RTF dosyası değil: {{\\rtf ile başlamıyor
@msg not found bulunamadı
@msg not in pattern editing mode desen düzenleme modunda değil
@msg finish editing the pattern first önce deseni düzenlemeyi bitir
@@ -6349,8 +6350,8 @@ axis Y Axis Y ekseni
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` dışa aktarma eklentisi 1 ile {MAX_EXTENSIONS} arasında `extensions` bildirir
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` gruplar dışında en fazla {MAX_UNGROUPED} parametre olabilir: diğerlerine bir `group` verin
@msg can't encode the input girdi kodlanamıyor
@msg expected {"dataBase64"} beklenen: {"dataBase64"}
@msg expected {"objects"} beklenen: {"objects"}
@msg expected {{"dataBase64": "…"}} beklenen: {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} beklenen: {{"objects": [...]}}
@msg export `{name}`: {e} modül dışa aktarımı `{name}`: {e}
@msg extension {e} is listed twice {e} uzantısı iki kez listelenmiş
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot {e} uzantısı: nokta olmadan 1-16 küçük harf ve rakam kullanın
@@ -6414,11 +6415,11 @@ axis Y Axis Y ekseni
@msg a painted live effect returns only `objects` boyama türü canlı efekt yalnızca `objects` döndürür
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` boyama türü canlı efektin bir parçasında yalnızca `path`, `fillRule`, `fills` ve `strokes` bulunur
@msg a read-only object was left out salt okunur bir nesne atlandı
@msg a read-only object was left out: return it, as {"id"} alone or as it was given salt okunur bir nesne atlandı: onu yalnızca {"id"} olarak veya verildiği gibi döndürün
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given salt okunur bir nesne atlandı: onu yalnızca {{"id": …}} olarak veya verildiği gibi döndürün
@msg a string bir dize
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` bir konturda yalnızca `paint`, `width`, `join`, `cap` ve `miterLimit` bulunur
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} bir kontur {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} biçiminde olmalıdır
@msg an effect must be {"id", "params"?, "visible"?} bir efekt {"id", "params"?, "visible"?} biçiminde olmalıdır
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} bir kontur {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} biçiminde olmalıdır
@msg an effect must be {{"id", "params"?, "visible"?}} bir efekt {{"id", "params"?, "visible"?}} biçiminde olmalıdır
@msg an effect on a fill returns only `fills` dolgudaki bir efekt yalnızca `fills` döndürür
@msg an effect on a stroke returns only `strokes` konturdaki bir efekt yalnızca `strokes` döndürür
@msg an object must be a JSON object bir nesne bir JSON nesnesi olmalıdır
Can't render this file because it contains an unexpected character in line 5 and column 25.
+6 -5
View File
@@ -2700,6 +2700,7 @@ mark-style Japanese Японський
Proportional Metrics Пропорційні метрики
Insert Aki Left Вставити акі ліворуч
Insert Aki Right Вставити акі праворуч
Tsume Цуме
Character Direction Напрямок символів
Digits Цифри
Arabic Арабські
@@ -5999,8 +6000,8 @@ axis Y Axis Вісь Y
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` модуль експорту оголошує від 1 до {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` поза групами може бути не більше {MAX_UNGROUPED} параметрів: задайте решті `group`
@msg can't encode the input не вдається закодувати вхідні дані
@msg expected {"dataBase64"} очікувалося {"dataBase64"}
@msg expected {"objects"} очікувалося {"objects"}
@msg expected {{"dataBase64": "…"}} очікувалося {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} очікувалося {{"objects": [...]}}
@msg export `{name}`: {e} експорт модуля `{name}`: {e}
@msg extension {e} is listed twice розширення {e} вказано двічі
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot розширення {e}: використовуйте 1-16 малих літер і цифр, без крапки
@@ -6064,11 +6065,11 @@ axis Y Axis Вісь Y
@msg a painted live effect returns only `objects` малювальний динамічний ефект повертає лише `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` частина малювального динамічного ефекту має лише `path`, `fillRule`, `fills` і `strokes`
@msg a read-only object was left out пропущено об’єкт лише для читання
@msg a read-only object was left out: return it, as {"id"} alone or as it was given пропущено об’єкт лише для читання: поверніть його як один {"id"} або в тому вигляді, у якому його передано
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given пропущено об’єкт лише для читання: поверніть його як один {{"id": …}} або в тому вигляді, у якому його передано
@msg a string рядок
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` обведення має лише `paint`, `width`, `join`, `cap` і `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} обведення має мати вигляд {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} ефект має мати вигляд {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} обведення має мати вигляд {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} ефект має мати вигляд {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` ефект на заливці повертає лише `fills`
@msg an effect on a stroke returns only `strokes` ефект на обведенні повертає лише `strokes`
@msg an object must be a JSON object об’єкт має бути об’єктом JSON
Can't render this file because it is too large.
+6 -5
View File
@@ -3013,6 +3013,7 @@ mark-style Japanese Kiểu Nhật
Proportional Metrics Số liệu theo tỷ lệ
Insert Aki Left Chèn aki bên trái
Insert Aki Right Chèn aki bên phải
Tsume Tsume
Proportional Lining Số tỷ lệ đứng
Position: Vị trí:
Default Position Vị trí mặc định
@@ -6000,8 +6001,8 @@ axis Y Axis Trục Y
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` plug-in xuất khai báo từ 1 đến {MAX_EXTENSIONS} `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` tối đa {MAX_UNGROUPED} tham số nằm ngoài nhóm: hãy đặt `group` cho các tham số còn lại
@msg can't encode the input không thể mã hóa dữ liệu đầu vào
@msg expected {"dataBase64"} cần có {"dataBase64"}
@msg expected {"objects"} cần có {"objects"}
@msg expected {{"dataBase64": "…"}} cần có {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} cần có {{"objects": [...]}}
@msg export `{name}`: {e} export `{name}` của mô-đun: {e}
@msg extension {e} is listed twice phần mở rộng {e} được liệt kê hai lần
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot phần mở rộng {e}: dùng 1-16 chữ thường và chữ số, không có dấu chấm
@@ -6065,11 +6066,11 @@ axis Y Axis Trục Y
@msg a painted live effect returns only `objects` hiệu ứng động kiểu tô chỉ trả về `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` một phần của hiệu ứng động kiểu tô chỉ có `path`, `fillRule`, `fills` và `strokes`
@msg a read-only object was left out một đối tượng chỉ đọc đã bị bỏ sót
@msg a read-only object was left out: return it, as {"id"} alone or as it was given một đối tượng chỉ đọc đã bị bỏ sót: hãy trả lại nó dưới dạng chỉ {"id"} hoặc như khi được nhận
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given một đối tượng chỉ đọc đã bị bỏ sót: hãy trả lại nó dưới dạng chỉ {{"id": …}} hoặc như khi được nhận
@msg a string một chuỗi
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` nét chỉ có `paint`, `width`, `join`, `cap` và `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} nét phải có dạng {"paint", "width"?, "join"?, "cap"?, "miterLimit"?}
@msg an effect must be {"id", "params"?, "visible"?} hiệu ứng phải có dạng {"id", "params"?, "visible"?}
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} nét phải có dạng {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}}
@msg an effect must be {{"id", "params"?, "visible"?}} hiệu ứng phải có dạng {{"id", "params"?, "visible"?}}
@msg an effect on a fill returns only `fills` hiệu ứng trên phần tô chỉ trả về `fills`
@msg an effect on a stroke returns only `strokes` hiệu ứng trên nét chỉ trả về `strokes`
@msg an object must be a JSON object đối tượng phải là một đối tượng JSON
Can't render this file because it contains an unexpected character in line 204 and column 5.
+6 -5
View File
@@ -2563,6 +2563,7 @@ font-script Japanese 汉字
Proportional Metrics 比例度量
Insert Aki Left 在前方插入空白
Insert Aki Right 在后方插入空白
Tsume 比例间距
Character Direction 字符方向
Digits 数字
Arabic 阿拉伯数字
@@ -6232,8 +6233,8 @@ axis Y Axis Y 轴
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 导出增效工具声明 1 到 {MAX_EXTENSIONS} 个 `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 组外最多 {MAX_UNGROUPED} 个参数:请为其余参数指定 `group`
@msg can't encode the input 无法编码输入
@msg expected {"dataBase64"} 应为 {"dataBase64"}
@msg expected {"objects"} 应为 {"objects"}
@msg expected {{"dataBase64": "…"}} 应为 {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} 应为 {{"objects": [...]}}
@msg export `{name}`: {e} 模块导出 `{name}`:{e}
@msg extension {e} is listed twice 扩展名 {e} 列出了两次
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 扩展名 {e}:请使用 1-16 个小写字母和数字,不带点
@@ -6297,11 +6298,11 @@ axis Y Axis Y 轴
@msg a painted live effect returns only `objects` 绘制型实时效果只返回 `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 绘制型实时效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes`
@msg a read-only object was left out 漏掉了一个只读对象
@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一个只读对象:请以单独的 {"id"} 或原样返回它
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 漏掉了一个只读对象:请以单独的 {{"id": …}} 或原样返回它
@msg a string 字符串
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 描边只有 `paint`、`width`、`join`、`cap` 和 `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 描边必须是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式
@msg an effect must be {"id", "params"?, "visible"?} 效果必须是 {"id", "params"?, "visible"?} 形式
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 描边必须是 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 形式
@msg an effect must be {{"id", "params"?, "visible"?}} 效果必须是 {{"id", "params"?, "visible"?}} 形式
@msg an effect on a fill returns only `fills` 填色上的效果只返回 `fills`
@msg an effect on a stroke returns only `strokes` 描边上的效果只返回 `strokes`
@msg an object must be a JSON object 对象必须是 JSON 对象
Can't render this file because it is too large.
+7 -5
View File
@@ -7,6 +7,7 @@
# the translation is a single string (see `plural` in i18n.rs)
# anything else a disambiguating context passed to `tr_ctx`
# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns.
# In @msg rows a literal brace is written {{ or }}, as in a Rust format string.
# lines starting with # and blank lines are ignored.
#
# Clean-room: every entry was written from the plain meaning of the English label using the
@@ -3779,6 +3780,7 @@ font-script Japanese 漢字
Proportional Metrics 比例度量
Insert Aki Left 在前方插入空白
Insert Aki Right 在後方插入空白
Tsume 比例間距
Character Direction 字元方向
Digits 數字
Arabic 阿拉伯數字
@@ -6038,8 +6040,8 @@ axis Y Axis Y 軸
@msg an exporter declares 1 to {MAX_EXTENSIONS} `extensions` 匯出增效模組宣告 1 到 {MAX_EXTENSIONS} 個 `extensions`
@msg at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` 群組外最多 {MAX_UNGROUPED} 個參數:請為其餘參數指定 `group`
@msg can't encode the input 無法編碼輸入
@msg expected {"dataBase64"} 應為 {"dataBase64"}
@msg expected {"objects"} 應為 {"objects"}
@msg expected {{"dataBase64": "…"}} 應為 {{"dataBase64": "…"}}
@msg expected {{"objects": [...]}} 應為 {{"objects": [...]}}
@msg export `{name}`: {e} 模組匯出 `{name}`:{e}
@msg extension {e} is listed twice 副檔名 {e} 列出了兩次
@msg extension {e}: use 1-16 lower-case letters and digits, without the dot 副檔名 {e}:請使用 1-16 個小寫字母和數字,不含點
@@ -6103,11 +6105,11 @@ axis Y Axis Y 軸
@msg a painted live effect returns only `objects` 繪製型即時效果只傳回 `objects`
@msg a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes` 繪製型即時效果的片段只有 `path`、`fillRule`、`fills` 和 `strokes`
@msg a read-only object was left out 漏掉了一個唯讀物件
@msg a read-only object was left out: return it, as {"id"} alone or as it was given 漏掉了一個唯讀物件:請以單獨的 {"id"} 或原樣傳回它
@msg a read-only object was left out: return it, as {{"id": …}} alone or as it was given 漏掉了一個唯讀物件:請以單獨的 {{"id": …}} 或原樣傳回它
@msg a string 字串
@msg a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit` 筆畫只有 `paint`、`width`、`join`、`cap` 和 `miterLimit`
@msg a stroke must be {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 筆畫必須是 {"paint", "width"?, "join"?, "cap"?, "miterLimit"?} 形式
@msg an effect must be {"id", "params"?, "visible"?} 效果必須是 {"id", "params"?, "visible"?} 形式
@msg a stroke must be {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 筆畫必須是 {{"paint", "width"?, "join"?, "cap"?, "miterLimit"?}} 形式
@msg an effect must be {{"id", "params"?, "visible"?}} 效果必須是 {{"id", "params"?, "visible"?}} 形式
@msg an effect on a fill returns only `fills` 填色上的效果只傳回 `fills`
@msg an effect on a stroke returns only `strokes` 筆畫上的效果只傳回 `strokes`
@msg an object must be a JSON object 物件必須是 JSON 物件
Can't render this file because it is too large.
+5 -2
View File
@@ -23,7 +23,8 @@ mod tests_read_only_save;
pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output};
pub(crate) use export::{
export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named,
EXPORT_SELECTION, export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named,
selection_path,
};
pub(crate) use open::report_installed;
pub(crate) use open::{EXPORT_PRESETS, import_presets, open_notes, open_with_on, opened, opened_after_notice, show_opened};
@@ -436,7 +437,9 @@ mod tests {
app.session.execute("file.new", &json!({"width": 60, "height": 40})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
app.services.pick_save = Some(Box::new(|_: &FilePick| Some("/tmp/sel.svg".into())));
assert_eq!(app.run("document.exportSelection", json!({})).unwrap()["path"], "/tmp/sel.svg");
app.run("document.exportSelection", json!({})).unwrap();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("svgOptions"), "the picked format's options follow");
assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["path"], "/tmp/sel.svg");
app.run("document.exportSelection", json!({"path": "/tmp/sel.png"})).unwrap();
let w = written.borrow();
assert!(String::from_utf8_lossy(&w[0].1).contains("<svg"), "SVG from the picked name");
+55 -5
View File
@@ -8,6 +8,33 @@ use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, ca
/// The status once an export was cancelled.
pub const EXPORT_CANCELLED: &str = "Export cancelled";
/// The param that makes an [`export`] write the selection alone, cropped to it, as
/// `document.exportSelection` writes it (`file.exportAs {path, exportSelection: true}`): set by
/// the options dialogs that Export Selection opens.
pub(crate) const EXPORT_SELECTION: &str = "exportSelection";
/// `params` without [`EXPORT_SELECTION`], and whether it asked for the selection. A selection
/// export writes one page, the selection's crop, so the artboard choices are left out too.
fn take_selection(params: &Value) -> Result<(bool, Value), String> {
let mut p = params.clone();
let Some(o) = p.as_object_mut() else { return Ok((false, p)) };
match o.remove(EXPORT_SELECTION) {
None | Some(Value::Null | Value::Bool(false)) => return Ok((false, p)),
Some(Value::Bool(true)) => {}
Some(v) => return Err(format!("{EXPORT_SELECTION} must be true or false, not {v}")),
}
let strip = |o: &mut serde_json::Map<String, Value>| {
for k in fileio::ARTBOARD_PARAMS.iter().chain(&["useArtboards", "selectedOnly"]) {
o.remove(*k);
}
};
strip(o);
if let Some(svg) = o.get_mut("svg").and_then(Value::as_object_mut) {
strip(svg);
}
Ok((true, p))
}
/// File → Cancel Export (the progress dialog's Cancel): every export running in the background
/// stops and writes nothing more → `{cancelled: n}`.
pub fn cancel_exports(app: &mut VectorcraftApp) -> Value {
@@ -29,11 +56,16 @@ pub fn hide_exports(app: &mut VectorcraftApp) -> Value {
/// options…): every file it writes (one per artboard, linked images) goes next to `path`. The
/// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are
/// several). When automation asked for the export, `warnings` also name each option the written
/// format doesn't read, as `document.export`'s do.
/// format doesn't read, as `document.export`'s do. With [`EXPORT_SELECTION`] the file holds the
/// selection alone, cropped to it, as `document.exportSelection` writes it. With Appearance of
/// Black › Printing / Exporting set to rich black, RGB files draw a CMYK black as RGB black, as in
/// `document.export`.
pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<String>, params: &Value) -> Result<Value, String> {
// Options given as anything but an object are none: the path and format still reach the
// encoder (an exporter plug-in is told the file it writes).
let params = &if params.is_object() { params.clone() } else { json!({}) };
let (selection, params) = take_selection(params)?;
let params = &params;
let f = fileio::writable_format(format, path.as_deref())?;
// A Shape Builder merge still being computed for a click is exported with the document.
let r = app.session.finish_tool_edit();
@@ -42,9 +74,13 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
remember_active_artboard(app);
let st = app.session.active().ok_or("no document")?;
fileio::check_linked_scripts("file.exportAs", f.id, st).map_err(|e| e.to_string())?;
let doc = match fileio::export_doc(st, f, params).map_err(|e| e.to_string())? {
std::borrow::Cow::Borrowed(_) => st.doc.clone(),
std::borrow::Cow::Owned(d) => std::sync::Arc::new(d),
let doc = if selection {
std::sync::Arc::new(fileio::selection_doc(st, "document.exportSelection", f.id == "txt").map_err(|e| e.to_string())?.0)
} else {
match fileio::export_doc(st, f, params).map_err(|e| e.to_string())? {
std::borrow::Cow::Borrowed(_) => st.doc.clone(),
std::borrow::Cow::Owned(d) => std::sync::Arc::new(d),
}
};
// A PDF shows its title in the viewer's title bar only when it is the document's own, not the
// file name it was titled after, as `document.export` writes it.
@@ -55,6 +91,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
let mut again = params.clone();
if let Some(o) = again.as_object_mut() {
o.insert("format".into(), json!(f.id));
if selection {
o.insert(EXPORT_SELECTION.into(), json!(true));
}
}
if let Some(r) = ask_before_losing(app, &path, "file.exportAs", &again) {
return Ok(r);
@@ -66,7 +105,8 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
};
// An export that automation asked for, also through a save panel it opened, names the options
// the file doesn't read, as `document.export` does. A person's export gets no such notes.
let unused = if app.automation_asked() { fileio::unused_option_notes("document.export", f, params) } else { Vec::new() };
let cmd = if selection { "document.exportSelection" } else { "document.export" };
let unused = if app.automation_asked() { fileio::unused_option_notes(cmd, f, params) } else { Vec::new() };
let noted = unused.clone();
// Background Export: encoded and written from this snapshot while editing goes on. The
// encoder is told the file it writes (an exporter plug-in gets its name and extension).
@@ -75,9 +115,13 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
// automation asked for the export, also through a save panel it opened (checked now: a
// background export ends after the request).
let view = params.get("viewAfterSaving").and_then(Value::as_bool).unwrap_or(false) && !app.automation_asked();
let rich_black = app.session.prefs.black_output == "rich";
let (mut params, target) = (params.clone(), path.clone());
if let Some(o) = params.as_object_mut() {
o.insert("path".into(), json!(path));
if rich_black {
o.insert("richBlack".into(), json!(true));
}
if f.id == "pdf" {
o.insert("_displayDocTitle".into(), json!(own_title));
}
@@ -190,6 +234,12 @@ fn take_output(v: &mut Value) -> Result<Vec<u8>, String> {
bytes.ok_or_else(|| "no data".into())
}
/// The file an Export Selection writes, picked in a save panel (the web: the suggested name): a
/// typed extension of any format is kept, and names the format.
pub(crate) fn selection_path(app: &mut VectorcraftApp) -> Result<String, String> {
target_path_keeping(app, None, "png", |_| true)
}
/// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to
/// that path (else a picked or suggested name) → (path, the command's result without the data).
/// The command runs before a path is asked for, so bad params never open a save dialog, except
+2
View File
@@ -171,6 +171,8 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
r
})
}
// Without params the save panel picks the file, then its format's options dialog follows.
"document.exportSelection" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::export_selection(app),
"document.exportSelection" => {
io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p}))
}
+1 -1
View File
@@ -1331,7 +1331,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.exportAs",
"Export As…",
"",
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
"{format?, useArtboards?, range?} opens Export As (then the format's options); with {path, …document.export options} writes the file(s) → {path, warnings, files?}; exportSelection: true writes the selection alone, cropped to it, as document.exportSelection writes it (artboard options are not used); under automation read roots, an SVG or SVGZ is refused while the document links a JavaScript file outside them, as document.export refuses it",
),
(
"ui.fileInfoDialog",
+13 -3
View File
@@ -26,6 +26,8 @@ pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0
/// Insert Aki Left and Right presets, in thousandths of an em: none, a quarter, half and a whole
/// full-width space.
pub const AKI_PRESETS: [f64; 4] = [0.0, 250.0, 500.0, 1000.0];
/// Tsume presets, in percent.
pub const TSUME_PRESETS: [f64; 5] = [0.0, 25.0, 50.0, 75.0, 100.0];
/// The stored character style: the selected range's (or the caret's) while the Type
/// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph
@@ -846,7 +848,7 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) {
/// The Character panel's grid of fields, `w` wide: Font Size, Leading, Kerning and Tracking,
/// then with `more` the scales, Baseline Shift and Character Rotation, and a row with the
/// Anti-aliasing Method dropdown under Character Rotation (`s`: the style shown), and with Show East
/// Asian Options on a row with Insert Aki Left and Right. With Show Font
/// Asian Options on a row with Insert Aki Left and Right and a row with Tsume. With Show Font
/// Height Options on, a row under Font Size holds the Set Font Height Reference dropdown. The
/// Properties panel shows its first rows.
pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) {
@@ -942,6 +944,14 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
});
}
ui.end_row();
// Tsume: the share of the blank space beside each character's ink taken away.
cell(ui, "→A←", tl!("Tsume"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-tsume", shared(app, |s| s.tsume), "%", 1, fw, 1.0, 0.0, &TSUME_PRESETS) {
// The field stops at the largest value the command takes.
format(app, json!({ "tsume": v.min(vectorcraft_doc::text::MAX_TSUME) }));
}
});
ui.end_row();
}
});
}
@@ -1686,7 +1696,7 @@ fn fractional_widths_on(app: &VectorcraftApp) -> bool {
/// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline
/// character alignment, Proportional Metrics, Tate-chu-yoko, Warichu, Standard Vertical Roman Alignment and
/// No Break off, Tate-chu-yoko Settings Up/Down and Left/Right 0, the default Warichu Settings (2 lines, no
/// line gap, 50%, Auto alignment), Insert Aki Left and Right 0, the Default character direction,
/// line gap, 50%, Auto alignment), Insert Aki Left and Right 0, Tsume 0, the Default character direction,
/// Kashidas on, the Default digits and the Em Box font height reference, whether or not Show East Asian
/// Options, Show Indic Options and Show Font Height Options are on. Inside an undo group that is
/// already open, the reset is part of that group's step.
@@ -1706,7 +1716,7 @@ fn reset_panel(app: &mut VectorcraftApp) {
"allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align,
"proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko,
"tcyUpDown": d.tcy_up_down, "tcyLeftRight": d.tcy_left_right, "akiLeft": d.aki_left, "akiRight": d.aki_right,
"warichu": d.warichu, "warichuLines": d.warichu_lines, "warichuGap": d.warichu_gap, "warichuScale": d.warichu_scale,
"tsume": d.tsume, "warichu": d.warichu, "warichuLines": d.warichu_lines, "warichuGap": d.warichu_gap, "warichuScale": d.warichu_scale,
"warichuAlign": d.warichu_align,
"standardVerticalRoman": d.standard_vertical_roman, "noBreak": d.no_break, "charDirection": "default", "kashidas": d.kashidas,
"digits": d.digits, "fontHeightReference": d.font_height_reference
+39 -3
View File
@@ -780,6 +780,41 @@ fn insert_aki_fields_show_with_the_east_asian_options() {
assert!(left(&app).iter().all(|v| *v == 1000.0), "{:?}", left(&app));
}
/// Tsume is a field of the panel with the East Asian options only, and shows the selected
/// characters' value, blank where they differ. A value typed past 100 sets 100.
#[test]
fn tsume_field_shows_with_the_east_asian_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "(日本)"})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run("text.setFormat", json!({"tsume": 37.5})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(!text.contains("→A←") && !text.contains("37.5"), "{text}");
app.session.prefs.show_east_asian_options = true;
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(text.contains("→A←") && text.contains("37.5"), "{text}");
// The characters differ: the field is blank.
app.run("text.setRangeStyle", json!({"id": id, "start": 3, "end": 12, "tsume": 62.5})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(!text.contains("37.5") && !text.contains("62.5"), "{text}");
app.run("text.setFormat", json!({"tsume": 62.5})).unwrap();
use super::super::tests_appearance::{click, frame_events, text_rect};
let ctx = egui::Context::default();
let texts = frame_events(&ctx, &mut app, vec![], show);
click(&ctx, &mut app, text_rect(&texts, "62.5%").center(), show);
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
frame_events(&ctx, &mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)], show);
frame_events(&ctx, &mut app, vec![egui::Event::Text("150".into())], show);
frame_events(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)], show);
frame_events(&ctx, &mut app, vec![], show);
let tsume = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.iter().map(|r| r.style.tsume).collect::<Vec<_>>(),
_ => panic!("text"),
};
assert!(tsume(&app).iter().all(|v| *v == 100.0), "{:?}", tsume(&app));
}
/// The panel menu's Show Font Height Options shows the Set Font Height Reference dropdown,
/// which shows the selected type's reference and sets the one picked (one undo step); Hide
/// Font Height Options hides it again.
@@ -1444,6 +1479,7 @@ fn reset_panel_resets_every_attribute_but_the_font() {
s.tcy_left_right != d.tcy_left_right,
s.aki_left != d.aki_left,
s.aki_right != d.aki_right,
s.tsume != d.tsume,
s.standard_vertical_roman != d.standard_vertical_roman,
s.no_break != d.no_break,
s.char_direction != d.char_direction,
@@ -1458,16 +1494,16 @@ fn reset_panel_resets_every_attribute_but_the_font() {
"id": id, "tracking": 50, "leading": 30, "kerning": 20, "baselineShift": 3, "hScale": 120, "vScale": 90, "rotation": 15,
"underline": true, "strikethrough": true, "allCaps": true, "smallCaps": true, "position": "superscript",
"charAlign": "emBoxCenter", "proportionalMetrics": true, "tateChuYoko": true, "tcyUpDown": 2, "tcyLeftRight": -3,
"akiLeft": 250, "akiRight": 500, "standardVerticalRoman": true, "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight"
"akiLeft": 250, "akiRight": 500, "tsume": 50, "standardVerticalRoman": true, "noBreak": true, "charDirection": "rightToLeft", "digits": "hindi", "fontHeightReference": "capHeight"
}),
)
.unwrap();
};
let plain = runs(&app)[0].1.clone();
assert_eq!(differs(&plain, &CharStyle::default()), [false; 24]);
assert_eq!(differs(&plain, &CharStyle::default()), [false; 25]);
custom(&mut app);
let styled = runs(&app)[0].1.clone();
assert_eq!(differs(&styled, &CharStyle::default()), [true; 24]);
assert_eq!(differs(&styled, &CharStyle::default()), [true; 25]);
app.run("select.set", json!({"ids": [id]})).unwrap();
reset_panel(&mut app);
assert_eq!(runs(&app), [("One 12".to_string(), plain.clone())]);
+9
View File
@@ -378,6 +378,12 @@ fn every_write_through_a_save_panel_asks_before_replacing() {
let d = app.ui.dialog.clone().expect(cmd);
assert_eq!((d.kind.as_str(), d.str("path")), (dialogs::confirm::REPLACE, path.to_string()), "{cmd}");
dialogs::confirm(&mut app).unwrap();
// Export Selection without params continues in the format's options, whose OK writes it.
if cmd == "document.exportSelection" {
assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.str("path"))), Some(("pngOptions", path.to_string())), "{cmd}");
assert!(w.borrow().is_empty(), "{cmd}: nothing is written before the options' OK");
dialogs::confirm(&mut app).unwrap();
}
assert_eq!(last(&w), path, "{cmd}");
}
// Export As: the question's OK picks the file again, and PNG Options follows.
@@ -480,5 +486,8 @@ fn an_export_keeps_a_typed_extension_of_the_format_it_writes() {
let (mut app, w) = saving_app("/tmp/sel.pdf");
app.run("select.all", json!({})).unwrap();
app.run("document.exportSelection", json!({})).unwrap();
// Save PDF follows the save panel, and its OK writes the file.
assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.str("path"))), Some(("savePdf", "/tmp/sel.pdf".to_string())));
dialogs::confirm(&mut app).unwrap();
assert_eq!(last(&w), "/tmp/sel.pdf", "Export Selection takes its format from the typed name");
}
+1 -1
View File
@@ -123,7 +123,7 @@ the two can still win that race.
| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call; an `app.export` given an option the format doesn't read: also `warnings`, naming the option), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written |
| `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes |
| Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`). The dialog has the format presets menu (☰). In the dialog, `exportPreset` names the preset applied last (`ui.dialog.set` of it applies the preset it names, as picking it does; a name that is not a preset's is an error and leaves the dialog as it was), `__exportPresets` lists the presets, and `__savePreset` holds the name being typed after Save Preset…. The panel menu applies presets through `assets.settings.set {exportPreset}` and holds Save As Preset…, Save Changes, Delete Preset, Import Preset… and Export Preset…. Import and Export use picked files. |
| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog |
| `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` with params still exports the selection in one go, without the dialog; with no params or `{}` the save panel picks the file and its format's options dialog follows, as after Export As, and the call returns `null` while the dialog is open (OK returns what `file.exportAs` returns, without `format` or `bounds`). `file.exportAs {path, exportSelection: true, …options}` writes the selection without a dialog |
| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows `warnings` as Export As shows an export's notes |
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF, `.ai` or `.ait` with a password, or one with several pages unless it is a `.ai` or `.ait` with its editing data or carries its VectorCraft document (those open whole), or placing (`file.place` without `page`) any of them with several pages or a password, opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
+5 -2
View File
@@ -126,7 +126,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar**
The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature:
- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol (`wl_data_device`) on the window's own connection, and only while the window has focus. One Copy offers the PNG as `image/png`, the SVG as `image/svg+xml` (with Include SVG Code on) and the text as `text/plain;charset=utf-8`, `UTF8_STRING` and `text/plain`; a format with no data is not offered. Paste reads `image/svg+xml`, then text, then `image/png`, and blank text or text that only names a picture gives way to the PNG; the Paste menu item is enabled by the types offered, without reading any data. Whether the clipboard still holds VectorCraft's Copy is told by its data source, which the compositor cancels when another app copies. A read takes 64 MiB at most and fails beyond that. Reads run on a worker thread: text waits 2 s at most on the UI thread, SVG and PNG 10 s on the paste's background thread. An owner that never sends its data costs one read its wait, and later reads return at once until it answers. When a text read gets no answer in its 2 s, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, then reads the clipboard again unless it holds VectorCraft's Copy. Another app's paste of a Copy is sent on a thread of its own, 8 at once at most; a paste beyond them gets nothing. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way.
- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager when one runs. That includes a read of the Paste menu check's handle: quitting waits up to 27 s for the background thread to drop it before the app drops its own, and quits after that wait even when the handle has not been dropped. The app hides its main window before these waits (`hide_before_teardown` in `apps/vectorcraft/src/app.rs`), so an X11 window, an XWayland one included, closes at once; winit cannot hide a native Wayland window, which stays on screen until the waits end. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server.
- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager when one runs. That includes a read of the Paste menu check's handle: quitting waits up to 27 s for the background thread to drop it before the app drops its own, and quits after that wait even when the handle has not been dropped. The app takes its windows off screen before these waits (`apps/vectorcraft/src/quit_windows.rs`). An X11 main window, an XWayland one included, is hidden. winit cannot hide a native Wayland window, so on Linux the app attaches no buffer to the main window's surface, which unmaps it while the window and its connection stay open for the Wayland clipboard. On Linux the X11 windows of floating panels and documents are unmapped through a second X11 connection. On native Wayland, floating panels and documents are normally drawn inside the main window; on the BSDs a native Wayland main window stays on screen until the waits end. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server.
- **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text).
- **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own.
- **Pens and pen displays under Wayland (#491, #2028):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336), commit [25c65abd](https://invent.kde.org/plasma/kwin/-/commit/25c65abddd95eb87fb892a8594faccbf509ff62f)), and GNOME's Mutter does not turn it into pointer input either (that commit's message): on KWin the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, the app runs under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer: Preferences › Performance › Linux Display Server's Automatic (the default) and X11 (XWayland) When a Pen Is Attached (`x11WithPen`) open the window through XWayland when `/proc/bus/input/devices` lists a pen as the app starts, a device with the `BTN_TOOL_PEN` key and the `ABS_X` and `ABS_Y` axes, and log `pen found: opening the window through XWayland`, as PhotoCraft does (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps the window on Wayland). For a pen attached after the app started, either run the app under XWayland (Preferences › Performance › Linux Display Server › X11 (XWayland); `WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse. Under XWayland the app also reads the pen's pressure (next item); with KWin's emulation on native Wayland the pen has no pressure. Other compositors that don't emulate a pointer for tablets behave the same way.
@@ -294,7 +294,10 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE
control channel, MCP and tests read them) and are translated only where the status bar draws them
(`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as
`Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the
placeholders are translated in turn (the reason after `: {e}` is often a message too). Czech, German, Greek, Spanish, French, Georgian, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish, Catalan and Dutch
placeholders are translated in turn (the reason after `: {e}` is often a message too). A literal brace in a
row is written `{{` or `}}`, in the English and in the translation: a message made with `format!` keeps the
doubled braces of its format string, and a plain string literal has each of its braces doubled, so the row for
`"expected {\"objects\": [...]}"` is `expected {{"objects": [...]}}`. Czech, German, Greek, Spanish, French, Georgian, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish, Catalan and Dutch
translate status and error messages (`COMPLETE_MESSAGES` in `i18n::tests`); the untranslated report lists the
message literals the scan finds, and the phrases a parameter schema's error builds at run time
(`SCHEMA_PHRASES`), that one of them has no row for. The scan covers the crates in `MESSAGE_CRATES`, the
+1 -1
View File
@@ -178,7 +178,7 @@ VectorCraft choices; an original approximation, never compared with another appl
| Item | Ours |
|---|---|
| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, overlapping faces drawn in the order of their depths where they overlap, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
| Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle, and wrapped around Revolve's side ([three-d.md](three-d.md#graphics)); no base materials, graphics on Inflate or on Extrude & Bevel's walls and bevels, soft shadows or ray tracing |
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none |
+36 -4
View File
@@ -1073,7 +1073,14 @@ apps (`editingData`), and always comes back with the warning `Preserve editing w
bounds would replace the document's artboards when reopened`; with `pdfCompatible: false` it is refused, whatever
`editingData` says. A legacy version 7 `.ai` (`aiVersion: "7"`) of the art's bounds is written as before.
Export As PDF continues in the Save PDF dialog with Use Artboards on or off; with it off, the dialog has no
Artboards section and the page is the bounds of the visible art.
Artboards section and the page is the bounds of the visible art. `document.exportSelection` run in the app with no
params or `{}` (a shortcut, or an automation call) opens the save panel, and the picked format's options dialog
follows as after Export As: Save PDF without its Artboards section, SVG Options without its artboards, the raster
options, EPS Options, DXF Options without Export Selected Art Only, Text Export Options with Selection Only on, or an
exporter plug-in's parameters. Other formats are written at once. OK writes the selection alone, cropped to it, as
`document.exportSelection` writes it, with the chosen options. While a dialog is open the call returns `null`, and
OK returns what `file.exportAs` returns, which has no `format` or `bounds` (Text Export Options: `{path}`). To
write without a dialog, pass `path` or any option. `file.exportAs {path, exportSelection: true, …options}` writes the selection the same way.
Raster exports default to the document's raster effects resolution. An explicit `scale` sets pixels per
point (72 times that value in ppi); `options.ppi` wins over either scale option. CLI `run --export` and
@@ -3707,7 +3714,9 @@ A graphic style holds an appearance (fills, strokes, effects) plus opacity, blen
object's, type its characters'). `graphicStyle.apply {name, ids?}` gives the objects the style and links them;
`add: true` adds the style on top of the existing appearance instead. Linked objects stay linked while they keep the
style's look: editing their appearance or transparency breaks the link, and `graphicStyle.redefine {name?, id?}`
updates only the objects still linked. `graphicStyle.list` returns each style with the ids linked to it and the
updates only the objects still linked. The art of a column or marker design in a graph series is never linked:
`graphicStyle.new` can make a style from it, and `graphicStyle.redefine` leaves its look as it was and unlinks it,
whether it is the source or a linked object. `graphicStyle.list` returns each style with the ids linked to it and the
style of the first selected object; `select.same.graphicStyle` selects an object's fellow users.
Styles keep placed gradients relative to the bounds of the object they were made from, and each object a
style is applied to gets them at the same place relative to its own bounds.
@@ -8810,6 +8819,28 @@ Insert Aki values; save it with Preserve Editing to keep them editable.
{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"akiRight":500}}}
```
## Tsume
`text.setFormat` and `text.setRangeStyle {tsume?}` set Tsume of the characters, a percentage that narrows the space
around them ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)). The range,
the default and how the space is measured are VectorCraft's own rules: the value is from 0 to 100, 0 by default and
saved only when not 0, and it is the share of the blank space on each side of a character's ink, inside the
character's own width, that is taken away. 50 halves that space, and 100 sets the ink against the characters before and
after it; the glyph keeps its shape. A character without ink, such as a space, keeps its width. A ligature or a letter
with its marks takes the value of its first character. Tracking, kerning and Insert Aki are added after it, and
justification keeps it. Tsume applies to horizontal type; vertical type keeps its spacing. Character styles carry it
(`charStyle.new`, `charStyle.setAttrs` and `charStyle.edit` refuse a value outside the range). A value that is not a
number is refused with `` `tsume` must be a number ``, and one outside the range with `` `tsume` must be from 0 to
100 ``. One undo step, and a call that gives the value the characters already have records none. With Show East Asian
Options on, the Character panel shows a Tsume field, blank where the selected characters differ, with 0, 25, 50, 75 and
100 in its menu, and a value typed past 100 sets 100; Reset Panel sets it to 0. Character Style Options shows it in
Advanced Character Formats. Live SVG text places each character Tsume moves, and type on a path with Tsume is written
as outlines.
```json
{"name":"run_command","arguments":{"command":"text.setRangeStyle","params":{"id":12,"start":0,"end":3,"tsume":50}}}
```
## Composite Fonts
A composite font sets each class of character in a font of its own
@@ -9512,7 +9543,7 @@ attribute map in one undo step. `to` is trimmed and must be unique. The command
while preserving local overrides; Undo/Redo restores the definition and its users. The `charStyleOptions`
dialog exposes `name`, `font_family`, `font_style`, `size`, `leading`, `kerning`, `tracking`, `case`,
`position`, `underline`, `strikethrough`, `charAlign`, `h_scale`, `v_scale`, `baseline_shift`, `tcyUpDown`,
`tcyLeftRight`, `rotation`, `akiLeft`, `akiRight`, `language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`,
`tcyLeftRight`, `rotation`, `akiLeft`, `akiRight`, `tsume`, `language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`,
`ot_figure`, `ot_position`, `ot_liga`, `ot_calt`, `ot_dlig`, `ot_swsh`, `ot_salt`, `ot_titl`, `ot_ordn`, `ot_frac`,
`ot_sets`, `section` (`general`, `basic`, `advanced`, `color` or `opentype`), and `preview`. An empty field leaves its
attribute unset. `leading` and `kerning` also accept `Auto` to store an explicit automatic value.
@@ -9520,7 +9551,8 @@ Advanced Character Formats holds `h_scale` and `v_scale` (percent, 1 to 10000),
`baseline_shift` (points, -1296 to 1296), the Tate-chu-yoko Settings `tcyUpDown` and `tcyLeftRight` (points,
-1296 to 1296, as `text.setRangeStyle` takes them; an explicit 0 stays distinct from unset), `rotation` (degrees, stored from -180 to 180), Insert Aki Left and Right `akiLeft` and `akiRight` (thousandths of
an em, with the Character panel's 0, 250, 500 and 1000 in their menus; a value typed below 0 or past 1000 sets 0 or
1000) and `language` (a name or tag from Document
1000), Tsume `tsume` (percent, with the Character panel's 0, 25, 50, 75 and 100 in its menu; a value typed below 0 or
past 100 sets 0 or 100; an explicit 0 stays distinct from unset) and `language` (a name or tag from Document
Setup's languages; English: USA stores the explicit default `null`, and a tag not in the list that the style
already has is kept). Language sets the language the characters are shaped in; it does not change hyphenation
or spelling.
+1 -1
View File
@@ -102,7 +102,7 @@ with the standard command.
- One Revolve per source object. Edit its options rather than applying another.
- Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint.
- Smooth diffuse shading, one to ten directional lights, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights.
- Faces use back-to-front depth sorting. Caps facing the camera cover their rim seams; caps facing away sit behind the body. Intersecting profiles can have incorrect visibility.
- Faces are drawn back to front by the depths of their centers. When that order draws the nearer of two overlapping faces of a surface first, every pair of overlapping faces goes in the order of their depths at the middle of where they overlap; the sort compares at most 4,000,000 pairs (VectorCraft's own limit), and the pairs past that keep the depths of their centers. Caps facing the camera cover their rim seams; a cap facing away is drawn behind the body except where it is nearer than a body face it overlaps. Intersecting profiles can have incorrect visibility.
- Curves use sixteen samples per segment, and as few as four when a surface would otherwise pass its limits. Surfaces are limited to 16,384 profile points and 163,840 faces, the members of a group together. A straight segment takes one point, a curve takes at least four, and each contour takes one. Smooth shaded side quads become two triangles and share the face limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit.
- Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile.
- Extrude & Bevel, Inflate and Rotate are described in [three-d.md](three-d.md). Materials remain unimplemented.
+6 -1
View File
@@ -904,6 +904,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The Polish catalog (`crates/ui-egui/src/i18n/pl.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,439 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 46 Polish messages: 44 that open #7211, #7214, #7116, #7330 and #1931 translate, and 2 that no language has a row for. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English.
- The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`).
- The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`).
- A status or error message whose braces are literal, such as a plug-in's `expected {"objects": [...]}` or a command's `quotes must be {double?, single?}`, is translated only when the whole message matches its row: the row writes those braces doubled and has no placeholder, so it no longer replaces a different message that starts with the same words, such as a JSON reason `expected value at line 1 column 1`, which stays in English. A row without placeholders is found at any message length (`plain_message_keys_double_their_braces`, `literal_brace_rows_match_only_their_message`).
- The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`).
- The Traditional Chinese catalog (`crates/ui-egui/src/i18n/zh-hant.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,438 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 40 Traditional Chinese messages: open #7211, #7116 and #7330 add the rows for 38 of them, and the other 2 have no row in any catalog. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian, Georgian and Catalan ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
@@ -1264,6 +1265,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- A 3D effect on one fill or stroke ([the help page](https://helpx.adobe.com/illustrator/using/effects.html)) leaves the object's other fills and strokes drawn with the path's or compound path's fill rule, so an even-odd compound path keeps its holes in them; this holds for Extrude & Bevel, Inflate, Rotate and Revolve, and for the Classic Extrude & Bevel, Rotate and Revolve. Tests: `solids::tests::a_fill_or_stroke_without_the_effect_keeps_the_even_odd_hole`, `revolve::tests::a_fill_without_the_revolve_keeps_the_even_odd_hole`.
- With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`.
- When the fill and strokes are at full opacity with Normal blending, under a stroke that leaves parts of the fill's edge uncovered (dashed, with a width profile or a brush, on an open subpath, or in a paint that may show through), Extrude & Bevel (both kinds) and Inflate cut each stroke face that passes through a face of the fill's solid, or of another of the shape's strokes, where their depths meet, and draw the faces of the shape's fill and strokes that overlap in the order of their depths where they overlap, instead of by the depths of their centers. Inflate orders a plain stroke the same way, since its fill can rise in front of the stroke. The fill no longer paints over a nearer dash, and a dash's end no longer covers the fill wall in front of it; a fill wall in a gap between dashes stays visible. The same order applies inside a group member's own opacity mask or blending mode, to a gradient with transparent stops under a colored light with graphics on its caps, and to Inflate in gradients under a colored light. Tests: `solids::tests::fill_walls_and_strokes_that_leave_gaps_draw_in_the_order_of_their_depths_where_they_overlap`, `solids::tests::local_order_puts_overlapping_fill_walls_and_stroke_faces_in_depth_order`, `solids::tests::a_stroke_face_through_a_fill_wall_is_cut_where_their_depths_meet`, `solids::tests::a_masked_members_fill_and_stroke_faces_go_in_one_order`, `solids::tests::a_colored_inflate_in_gradients_draws_its_faces_in_one_order`, `solids::tests::a_depth_cycle_keeps_the_cap_last`, `solids::graphics::tests::a_transparent_gradient_with_a_graphic_keeps_the_shared_face_order`.
- Revolve and Revolve (Classic) draw two overlapping faces of a surface in the order of their depths where they overlap, instead of by the depths of their centers, on one object and across a group's members, so a tilted profile with long edges or a profile of several contours no longer draws a nearer face under a farther one. A surface whose faces were already in the right order keeps it. Tests: `solids::tests_revolve_order::a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last`, `solids::tests_revolve_order::a_surface_already_in_order_keeps_it`, `solids::tests_revolve_order::surface_order_puts_the_nearer_face_where_they_overlap_last`, `tests::a_revolved_face_measures_its_depth_on_its_plane`.
- Under a colored light, an Inflate fill and strokes in solid colors, opaque with Normal blending, also draw their faces in that one order, inside a group member's own opacity mask too, instead of each solid as one composite with the stroke's faces over every fill face. Each face keeps the composite's paint, light ramps and seam width; a lone solid, the solids of an object with a translucent or blended fill or stroke, and such solids holding more than 30,720 faces together (VectorCraft's own limit) are still drawn as one composite each. Tests: `solids::tests::colored_solid_fills_and_strokes_share_face_depth_order`, `solids::tests::a_colored_solid_face_on_its_own_keeps_the_composites_colors`, `solids::tests::colored_solid_inflate_batches_channels_and_clips_once_per_surface`, `solids::tests::colored_solids_past_the_face_limit_draw_as_composites`.
- Without a twist, a tapered Extrude & Bevel wall (and each bevel band) is one flat face; only a twist cuts walls into slices, so no seam between slices paints a light tick or a light line on a tapered, untwisted wall (#7019). Test: `solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced`.
- Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715).
@@ -1402,6 +1404,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, on a vertical path it moves toward the path's start and to the path's left, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them.
A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved.
Character panel › Insert Aki Left and Insert Aki Right (with the East Asian options; `text.setFormat` or `text.setRangeStyle {akiLeft, akiRight}`, thousandths of an em from 0 to 1000, 0 by default) add space before and after each selected character, measured as tracking is, and kept as set through justification ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); before and after follow the reading order in vertical and right-to-left type, character styles carry both values (Character Style Options › Advanced Character Formats edits them), and a file holding a value outside the range opens with the nearer end.
Character panel › Tsume (with the East Asian options; `text.setFormat` or `text.setRangeStyle {tsume}`) narrows the space around each selected character ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); by VectorCraft's own rule the value is a percentage from 0 to 100, 0 by default, of the blank space on each side of the character's ink that horizontal type takes away without changing the glyph's shape, with tracking, kerning and Insert Aki added after it. Character styles carry it (Character Style Options › Advanced Character Formats edits it), and a file holding a value outside the range opens with the nearer end.
The Character panel's All Caps, Underline and Strikethrough buttons and its menu's All Caps, Underline, Strikethrough, Proportional Metrics, Tate-chu-yoko and No Break show off while only some of the selected characters have the attribute, and choosing one then turns it on for all of them, as `type.underline` (Cmd+Shift+U) does for underlining; the Character Alignment and Digits submenus check no item while the selected characters differ. `text.setFormat` refuses a value that is not true or false for its on/off attributes, as `text.setRangeStyle` does.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group.
@@ -1712,6 +1715,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`).
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, on by default; `.ai` saves leave it off) writes such pages in points without a `UserUnit` at every PDF version, in files without a PDF/X or PDF/A standard, so readers that ignore `UserUnit` show them at full size.
Export As PDF with Use Artboards off also opens the Save PDF dialog, without its Artboards section, and writes one page of the visible art's bounds with the dialog's options (`document.exportPdf {useArtboards: false}`; `crates/ui-egui/src/dialogs/tests_export.rs`, `crates/engine/src/cmd/fileio/tests_pdf.rs`).
`document.exportSelection` run without params (a shortcut) takes the same route after its save panel: a picked PDF opens the Save PDF dialog without its Artboards section, and SVG, the raster formats, EPS, DXF, text and exporter plug-ins with parameters open their options, whose OK writes the selection alone, cropped to it, with the chosen options (`crates/ui-egui/src/dialogs/tests_export_selection.rs`).
A `.ai` export with Use Artboards off writes one page of the visible art's bounds without the native document or the editing copy for other apps, with a warning, so the file does not reopen with one artboard in place of the document's; without PDF content it is refused (`crates/engine/src/cmd/fileio/tests_pdfedit.rs`).
PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked.
In a PDF without a standard, the warning for a page longer than 14,400 pt written with a `UserUnit` names "Write pages longer than 200 in at full size in points" (`crates/engine/src/cmd/fileio/tests_pdf.rs`).
@@ -1771,7 +1775,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets.
- Print's Copies per Sheet (`copiesPerSheet`, `gutter`; off by default) prints as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, as one grid placed like a single copy; the preview shows them, and an artboard that does not fit prints one copy per sheet with a warning (#1283).
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- On X11, quitting while the Paste menu's background check waits on a clipboard owner that never answers waits up to 27 s for that check to drop its clipboard handle, so the app's own handle is the last one dropped when the check drops its handle within that wait, and the app quits after the wait when it has not; the window is hidden before that wait, so an X11 window closes at once (`crates/ui-egui/src/tests/sysclip_probe.rs`, `apps/vectorcraft/src/clipboard.rs`, `apps/vectorcraft/src/tests.rs`).
- On X11, quitting while the Paste menu's background check waits on a clipboard owner that never answers waits up to 27 s for that check to drop its clipboard handle, so the app's own handle is the last one dropped when the check drops its handle within that wait, and the app quits after the wait when it has not; the window is hidden before that wait, so an X11 window closes at once (`crates/ui-egui/src/tests/sysclip_probe.rs`, `apps/vectorcraft/src/clipboard.rs`, `apps/vectorcraft/src/quit_windows.rs`).
- On Linux, quitting takes every window off screen before that wait: a native Wayland main window is unmapped (its surface gets no buffer) and stays open with the connection the Wayland clipboard uses, and the X11 windows of floating panels and documents are unmapped with the hidden main window (`apps/vectorcraft/src/quit_windows.rs`).
- File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`).
- File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests/textimport.rs`).
- A Word paragraph justified with a low, medium or high kashida length places justified with Insert Kashidas set to Short, Medium or Long (`file.place`, `crates/engine/src/tests/textimport.rs`).