mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 18:15:49 +00:00
Merge main into fix/script-sleep-reads-doc-cancel
# Conflicts: # docs/target-app-parity.md
This commit is contained in:
@@ -3,7 +3,7 @@ name = "vectorcraft-cad"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018)"
|
||||
description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018) and an HP-GL/2 plot file reader"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! HP-GL import: a plotter file (HP-GL/2, `.plt`, `.hpgl`, `.hgl`, `.hpg`) as a document. Older
|
||||
//! HP-GL files are read by the HP-GL/2 rules for the instructions the two share.
|
||||
//!
|
||||
//! The pen's moves become paths: each run drawn with the pen down is one stroked path in the
|
||||
//! selected pen's color and width (0.35 mm unless PW sets another), with the line type as a dash
|
||||
//! pattern and the line attributes as caps and joins. Circles, arcs, wedges, rectangles and Bézier
|
||||
//! curves are drawn as curves (the chord angle isn't used). Filled rectangles, wedges and polygons
|
||||
//! (polygon mode, FP) become filled paths, edged ones stroked paths. Plotter units (1016 to the
|
||||
//! inch, y up) become points, through user units (SC, IP, IR) and the rotation (RO). The artboard
|
||||
//! is the bounds of what the art paints, its strokes included.
|
||||
//!
|
||||
//! VectorCraft's own rules where the device decides: before the first SP the pen is pen 1 (a
|
||||
//! device draws nothing until SP), and the hard-clip limits are 11 × 8.5 in from the origin, so
|
||||
//! P2 lies there when the file sets no scaling points. SP0 draws nothing. Labels, symbol mode's symbols and raster
|
||||
//! images (HP RTL) aren't drawn, the input window doesn't clip, hatched fills are filled solid,
|
||||
//! and the art of every page goes on one artboard; each comes back as a warning. Printer escape
|
||||
//! sequences and PJL lines around the instructions are skipped.
|
||||
//!
|
||||
//! - `lex`: the bytes as instructions, numbers, labels and PE data.
|
||||
//! - `plot`: the instructions played on the pen, as shapes.
|
||||
|
||||
mod lex;
|
||||
mod plot;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, LayerColor, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect};
|
||||
|
||||
use crate::Imported;
|
||||
use lex::Lexer;
|
||||
use plot::Plot;
|
||||
|
||||
/// Points per plotter unit.
|
||||
const PT_PER_PLU: f64 = 72.0 / 1016.0;
|
||||
/// The weight of a zero-width pen (one device dot), in points.
|
||||
const THINNEST: f64 = 0.25;
|
||||
|
||||
/// Instructions the reader knows and draws nothing for (their parameters are read and dropped).
|
||||
const IGNORED: &[[u8; 2]] = &[
|
||||
*b"AC", *b"AD", *b"AF", *b"AH", *b"AP", *b"AS", *b"CA", *b"CF", *b"CM", *b"CP", *b"CS", *b"CT", *b"CV", *b"DC", *b"DI", *b"DL", *b"DP", *b"DR",
|
||||
*b"DS", *b"DV", *b"EC", *b"ES", *b"FI", *b"FN", *b"FS", *b"GM", *b"IM", *b"IV", *b"KY", *b"LM", *b"LO", *b"MC", *b"MG", *b"MT", *b"NR", *b"OA",
|
||||
*b"OC", *b"OD", *b"OE", *b"OF", *b"OG", *b"OH", *b"OI", *b"OL", *b"OP", *b"OS", *b"OT", *b"OW", *b"PB", *b"PP", *b"PS", *b"PT", *b"QL", *b"RF",
|
||||
*b"RP", *b"SA", *b"SB", *b"SD", *b"SG", *b"SI", *b"SL", *b"SR", *b"SS", *b"ST", *b"SV", *b"TD", *b"TL", *b"TR", *b"UC", *b"VA", *b"VN", *b"VS",
|
||||
*b"XT", *b"YT",
|
||||
];
|
||||
|
||||
/// Instructions the reader plays.
|
||||
const PLAYED: &[[u8; 2]] = &[
|
||||
*b"AA", *b"AR", *b"AT", *b"BL", *b"BP", *b"BR", *b"BZ", *b"CI", *b"CO", *b"CR", *b"DF", *b"DT", *b"EA", *b"EP", *b"ER", *b"EW", *b"FP", *b"FR",
|
||||
*b"FT", *b"IN", *b"IP", *b"IR", *b"IW", *b"LA", *b"LB", *b"LT", *b"NP", *b"PA", *b"PC", *b"PD", *b"PE", *b"PG", *b"PM", *b"PR", *b"PU", *b"PW",
|
||||
*b"RA", *b"RO", *b"RR", *b"RT", *b"SC", *b"SM", *b"SP", *b"UL", *b"WD", *b"WG", *b"WU",
|
||||
];
|
||||
|
||||
/// Instructions a plot file starts with.
|
||||
const FIRST: &[[u8; 2]] = &[
|
||||
*b"IN", *b"DF", *b"BP", *b"CO", *b"PS", *b"SP", *b"PU", *b"PD", *b"PA", *b"PR", *b"IP", *b"IR", *b"SC", *b"NP", *b"PC", *b"PW", *b"WU", *b"RO",
|
||||
*b"QL", *b"MC", *b"PP", *b"TR", *b"CR", *b"LT", *b"LA", *b"FT", *b"VS", *b"FS", *b"AC", *b"PG", *b"PE", *b"DT",
|
||||
];
|
||||
|
||||
/// Does `bytes` start like an HP-GL file? After any printer escapes and PJL lines (however long),
|
||||
/// it must hold nothing but instructions the reader knows, the first one of those a plot file
|
||||
/// starts with, up to the eighth instruction or 4 KB past the first. Their parameters must be
|
||||
/// numbers, but for a comment's quoted string and the text, characters and encoded data of LB, BL,
|
||||
/// WD, DT, SM and PE. An instruction the reader doesn't know counts when its numbers end with a
|
||||
/// semicolon.
|
||||
pub fn is_hpgl(bytes: &[u8]) -> bool {
|
||||
holds_instructions(bytes, true)
|
||||
}
|
||||
|
||||
/// Does `bytes` hold HP-GL instructions as [`is_hpgl`] checks? When `strict` is false, any of them
|
||||
/// may come first: a file picked by its extension may start with one a plot file seldom starts
|
||||
/// with (`SI`, `DI`). Either way, at least one of the instructions checked must be one the reader
|
||||
/// knows.
|
||||
fn holds_instructions(bytes: &[u8], strict: bool) -> bool {
|
||||
let mut lex = Lexer::new(bytes);
|
||||
let mut terminator = lex::ETX;
|
||||
let mut first = 0;
|
||||
let mut any_known = false;
|
||||
for n in 0..8 {
|
||||
if n > 0 && lex.pos() > first + 4096 {
|
||||
return any_known;
|
||||
}
|
||||
let Some(op) = lex.op(true) else { return n > 0 && any_known && lex.done() };
|
||||
if n == 0 {
|
||||
first = lex.pos();
|
||||
}
|
||||
if strict && n == 0 && !FIRST.contains(&op) {
|
||||
return false;
|
||||
}
|
||||
let known = PLAYED.contains(&op) || IGNORED.contains(&op);
|
||||
any_known |= known;
|
||||
match &op {
|
||||
b"CO" => {
|
||||
if !lex.comment() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
b"LB" | b"BL" | b"WD" => lex.label(terminator),
|
||||
b"DT" => {
|
||||
terminator = lex.char().unwrap_or(lex::ETX);
|
||||
lex.params();
|
||||
}
|
||||
b"SM" => {
|
||||
// The symbol character is read so it isn't taken for an instruction.
|
||||
let _ = lex.char();
|
||||
}
|
||||
b"PE" => lex.skip_pe(),
|
||||
_ if known => lex.params(),
|
||||
// An instruction the reader doesn't know (of an older or a device's own set) counts
|
||||
// when numbers and a semicolon follow it.
|
||||
_ => {
|
||||
if !lex.closed_params() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
any_known
|
||||
}
|
||||
|
||||
/// Read an HP-GL file. It may start with any instruction the reader knows, or one of another set
|
||||
/// that a semicolon ends; the rest of [`is_hpgl`]'s check applies, and at least one instruction the
|
||||
/// reader knows must be among those it checks.
|
||||
pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||||
if !holds_instructions(bytes, false) {
|
||||
return Err("not an HP-GL file".into());
|
||||
}
|
||||
let mut lex = Lexer::new(bytes);
|
||||
let mut plot = Plot::new();
|
||||
while !plot.full
|
||||
&& let Some(op) = lex.op(false)
|
||||
{
|
||||
plot.op(op, &mut lex);
|
||||
}
|
||||
Ok(build(plot.finish(lex.raster)))
|
||||
}
|
||||
|
||||
fn rgb(c: [u8; 3]) -> Paint {
|
||||
Paint::solid(Color::rgb8(c[0], c[1], c[2]))
|
||||
}
|
||||
|
||||
/// The plot's shapes as paths on one layer, the artboard round what they paint.
|
||||
fn build(plot: Plot) -> Imported {
|
||||
// Plotter units, y up, to points, y down.
|
||||
let flip = Affine::scale_non_uniform(PT_PER_PLU, -PT_PER_PLU);
|
||||
let mut nodes: Vec<Node> = plot.shapes.iter().map(|s| node(s, flip)).collect();
|
||||
// The strokes' caps and miter joins included.
|
||||
let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite());
|
||||
let frame = nodes.iter().filter_map(Node::visual_bounds).filter(finite).reduce(|a, b| a.union(b)).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
|
||||
let mut doc = Document::new(frame.width().max(1.0), frame.height().max(1.0));
|
||||
doc.layers.clear();
|
||||
let shift = Affine::translate(-frame.origin().to_vec2());
|
||||
for n in &mut nodes {
|
||||
n.id = doc.alloc_id();
|
||||
if let NodeKind::Path { path, .. } = &mut n.kind {
|
||||
path.transform(shift);
|
||||
}
|
||||
}
|
||||
let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
|
||||
if let Some(c) = layer.children_mut() {
|
||||
*c = nodes.into_iter().map(Arc::new).collect();
|
||||
}
|
||||
doc.layers = vec![Arc::new(layer)];
|
||||
Imported { document: doc, warnings: plot.warnings }
|
||||
}
|
||||
|
||||
/// A shape as a path, its plotter units through `to_points`.
|
||||
fn node(s: &plot::Shape, to_points: Affine) -> Node {
|
||||
let mut path = s.path.clone();
|
||||
path.apply_affine(to_points);
|
||||
let mut items = vec![];
|
||||
if let Some((c, _)) = s.fill {
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(rgb(c))));
|
||||
}
|
||||
if let Some(st) = &s.stroke {
|
||||
let mut layer = StrokeLayer::new(rgb(st.color), (st.width * PT_PER_PLU).max(THINNEST));
|
||||
(layer.cap, layer.join, layer.miter_limit) = (st.cap, st.join, st.miter);
|
||||
layer.dash = st.dash.as_ref().map(|d| Dash {
|
||||
pattern: d.pattern.iter().map(|v| v * PT_PER_PLU).collect(),
|
||||
offset: d.offset * PT_PER_PLU,
|
||||
align_corners: d.adaptive,
|
||||
});
|
||||
items.push(AppearanceItem::Stroke(layer));
|
||||
}
|
||||
let mut n = Node::path(NodeId(0), PathData::from_bezpath(&path), Appearance { items, ..Appearance::default() });
|
||||
if let (NodeKind::Path { rule, .. }, Some((_, r))) = (&mut n.kind, s.fill) {
|
||||
*rule = r;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,428 @@
|
||||
//! The bytes of an HP-GL file as instructions: two-letter mnemonics and the parameters that follow
|
||||
//! them, read one at a time as the plot asks for them. Printer escape sequences (PCL, HP RTL, the
|
||||
//! HP-GL device-control escapes) and PJL lines around and between the instructions are skipped,
|
||||
//! with the data their escapes carry.
|
||||
|
||||
/// The end-of-text character, the default label terminator.
|
||||
pub(super) const ETX: u8 = 3;
|
||||
const ESC: u8 = 0x1b;
|
||||
|
||||
/// The most digits of a number that count: longer ones are read as their first digits.
|
||||
const MAX_DIGITS: usize = 18;
|
||||
|
||||
/// What the bytes being read hold.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
/// HP-GL instructions.
|
||||
Hpgl,
|
||||
/// PCL or HP RTL: escape sequences and their data, nothing to draw.
|
||||
Pcl,
|
||||
/// After a Universal Exit Language: PJL lines until a language starts.
|
||||
Pjl,
|
||||
}
|
||||
|
||||
/// One item of a PE (Polyline Encoded) instruction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum PeItem {
|
||||
/// Select pen.
|
||||
Pen(i64),
|
||||
/// Rectangle mode: the following coordinates come in fours.
|
||||
Rect,
|
||||
/// A coordinate pair, after a pen-up flag (`up`) and an absolute flag (`absolute`).
|
||||
Point { x: f64, y: f64, up: bool, absolute: bool },
|
||||
}
|
||||
|
||||
/// The state of one PE instruction being read.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(super) struct Pe {
|
||||
seven_bit: bool,
|
||||
fraction_bits: u32,
|
||||
up: bool,
|
||||
absolute: bool,
|
||||
}
|
||||
|
||||
pub(super) struct Lexer<'a> {
|
||||
b: &'a [u8],
|
||||
at: usize,
|
||||
mode: Mode,
|
||||
/// The file holds HP RTL or PCL raster image data.
|
||||
pub raster: bool,
|
||||
}
|
||||
|
||||
/// Is `c` a byte that separates numeric parameters (or white space between instructions)?
|
||||
fn separator(c: u8) -> bool {
|
||||
matches!(c, b' ' | b',' | b'\t' | b'\r' | b'\n' | 0)
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
pub fn new(b: &'a [u8]) -> Self {
|
||||
Self { b, at: 0, mode: Mode::Hpgl, raster: false }
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.b.get(self.at).copied()
|
||||
}
|
||||
|
||||
/// Whether everything has been read.
|
||||
pub fn done(&self) -> bool {
|
||||
self.at >= self.b.len()
|
||||
}
|
||||
|
||||
/// How many bytes have been read.
|
||||
pub fn pos(&self) -> usize {
|
||||
self.at
|
||||
}
|
||||
|
||||
/// The next instruction's mnemonic, in upper case; `strict`: only white space, semicolons and
|
||||
/// escape sequences may come before it (anything else gives `None`, as the end does).
|
||||
pub fn op(&mut self, strict: bool) -> Option<[u8; 2]> {
|
||||
loop {
|
||||
let c = self.peek()?;
|
||||
if c == ESC {
|
||||
self.escape();
|
||||
continue;
|
||||
}
|
||||
match self.mode {
|
||||
Mode::Pcl => {
|
||||
self.at += 1;
|
||||
continue;
|
||||
}
|
||||
Mode::Pjl => {
|
||||
if separator(c) {
|
||||
self.at += 1;
|
||||
} else if c == b'@' {
|
||||
self.pjl_line();
|
||||
} else {
|
||||
self.mode = Mode::Hpgl;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Mode::Hpgl => {}
|
||||
}
|
||||
let next = self.b.get(self.at + 1).copied();
|
||||
if c.is_ascii_alphabetic()
|
||||
&& let Some(n) = next.filter(u8::is_ascii_alphabetic)
|
||||
{
|
||||
self.at += 2;
|
||||
return Some([c.to_ascii_uppercase(), n.to_ascii_uppercase()]);
|
||||
}
|
||||
if strict && !(separator(c) || c == b';' || c < 0x20) {
|
||||
return None;
|
||||
}
|
||||
self.at += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// One `@PJL` line: an `ENTER LANGUAGE` command picks what follows.
|
||||
fn pjl_line(&mut self) {
|
||||
let rest = &self.b[self.at..];
|
||||
let end = rest.iter().position(|&c| c == b'\n').map_or(rest.len(), |n| n + 1);
|
||||
// A command names its language within its first 256 bytes; longer lines aren't copied.
|
||||
let line = String::from_utf8_lossy(&rest[..end.min(256)]).to_ascii_uppercase();
|
||||
if let Some(lang) = line.split_once("LANGUAGE").map(|(_, l)| l.trim_start_matches([' ', '=']).trim()) {
|
||||
self.mode = if lang.starts_with("HPGL") { Mode::Hpgl } else { Mode::Pcl };
|
||||
}
|
||||
self.at += end;
|
||||
}
|
||||
|
||||
/// An escape sequence at the current byte, with the data it carries.
|
||||
fn escape(&mut self) {
|
||||
self.at += 1;
|
||||
let Some(c) = self.peek() else { return };
|
||||
self.at += 1;
|
||||
match c {
|
||||
// Universal Exit Language (`ESC%-12345X`) and the language switches (`ESC%#B` to HP-GL,
|
||||
// `ESC%#A` to PCL or HP RTL).
|
||||
b'%' => {
|
||||
let (_, end) = self.value();
|
||||
match end {
|
||||
Some(b'X') => self.mode = Mode::Pjl,
|
||||
Some(b'B') => self.mode = Mode::Hpgl,
|
||||
Some(b'A') => self.mode = Mode::Pcl,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Reset: PCL follows, until a language switch.
|
||||
b'E' => self.mode = Mode::Pcl,
|
||||
// HP-GL device control: `ESC.` and a letter, with parameters up to a colon.
|
||||
b'.' => {
|
||||
let Some(kind) = self.peek() else { return };
|
||||
self.at += 1;
|
||||
if !b"()YZBEJKLORUX".contains(&kind) {
|
||||
let rest = &self.b[self.at..self.b.len().min(self.at + 80)];
|
||||
if let Some(n) = rest.iter().position(|&c| c == b':')
|
||||
&& rest[..n].iter().all(|c| c.is_ascii_digit() || matches!(c, b';' | b' ' | b','))
|
||||
{
|
||||
self.at += n + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parameterized PCL: a group character, then values each ending in a letter (upper case
|
||||
// ends the sequence); `W`, `*b…V` (raster data by plane) and `&p…X` (transparent print
|
||||
// data) are followed by that many bytes of data. Without a group character (`ESC(8U`,
|
||||
// `ESC)0N`), one value and the letter ending it.
|
||||
0x21..=0x2f => {
|
||||
let Some(group) = self.peek().filter(|g| (0x60..=0x7e).contains(g)) else {
|
||||
self.value();
|
||||
return;
|
||||
};
|
||||
self.at += 1;
|
||||
loop {
|
||||
let (v, end) = self.value();
|
||||
let Some(end) = end else { return };
|
||||
let upper = end.to_ascii_uppercase();
|
||||
if upper == b'W' || (c, group, upper) == (b'*', b'b', b'V') || (c, group, upper) == (b'&', b'p', b'X') {
|
||||
let n = v.max(0.0).min((self.b.len() - self.at) as f64) as usize;
|
||||
if (c, group) == (b'*', b'b') && n > 0 {
|
||||
self.raster = true;
|
||||
}
|
||||
self.at += n;
|
||||
}
|
||||
if (c, group, end) == (b'*', b'r', b'A') {
|
||||
self.raster = true;
|
||||
}
|
||||
if !end.is_ascii_lowercase() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A two-character escape.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A PCL value (`[+-]digits[.digits]`, any part missing) and the character that ends it; `None`
|
||||
/// when that is not a letter or `@` (which is then left unread).
|
||||
fn value(&mut self) -> (f64, Option<u8>) {
|
||||
let v = self.number_here().unwrap_or(0.0);
|
||||
match self.peek() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == b'@' => {
|
||||
self.at += 1;
|
||||
(v, Some(c))
|
||||
}
|
||||
_ => (v, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A number starting at the current byte (`[+-]digits[.digits]`), `None` when it has no digit
|
||||
/// (its sign and point are read all the same).
|
||||
fn number_here(&mut self) -> Option<f64> {
|
||||
let mut neg = false;
|
||||
if let Some(c @ (b'+' | b'-')) = self.peek() {
|
||||
neg = c == b'-';
|
||||
self.at += 1;
|
||||
}
|
||||
let (mut v, mut digits, mut scale) = (0.0f64, 0usize, 1.0f64);
|
||||
let mut point = false;
|
||||
while let Some(c) = self.peek() {
|
||||
if c.is_ascii_digit() {
|
||||
if digits < MAX_DIGITS {
|
||||
if point {
|
||||
scale /= 10.0;
|
||||
v += f64::from(c - b'0') * scale;
|
||||
} else {
|
||||
v = v * 10.0 + f64::from(c - b'0');
|
||||
}
|
||||
} else if !point {
|
||||
// Past the digits that count, an integer part keeps its size.
|
||||
v *= 10.0;
|
||||
}
|
||||
digits += 1;
|
||||
} else if c == b'.' && !point {
|
||||
point = true;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
self.at += 1;
|
||||
}
|
||||
let v = v.min(f64::MAX);
|
||||
(digits > 0).then_some(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
/// The next numeric parameter of the instruction being read, or `None` at its end (a semicolon
|
||||
/// ending it is read).
|
||||
pub fn number(&mut self) -> Option<f64> {
|
||||
loop {
|
||||
let c = self.peek()?;
|
||||
if separator(c) {
|
||||
self.at += 1;
|
||||
} else if c.is_ascii_digit() || matches!(c, b'+' | b'-' | b'.') {
|
||||
if let Some(v) = self.number_here() {
|
||||
return Some(v);
|
||||
}
|
||||
} else {
|
||||
if c == b';' {
|
||||
self.at += 1;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Up to `out.len()` numeric parameters into `out` (the rest are read and dropped) → how many.
|
||||
pub fn numbers(&mut self, out: &mut [f64]) -> usize {
|
||||
let mut n = 0;
|
||||
while let Some(v) = self.number() {
|
||||
if let Some(slot) = out.get_mut(n) {
|
||||
*slot = v;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
n.min(out.len())
|
||||
}
|
||||
|
||||
/// Read and drop the parameters of the instruction being read: numbers and quoted strings.
|
||||
pub fn params(&mut self) {
|
||||
loop {
|
||||
while self.number().is_some() {}
|
||||
if self.peek() != Some(b'"') {
|
||||
return;
|
||||
}
|
||||
self.quoted();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and drop numeric parameters → whether a semicolon ended them (an instruction the reader
|
||||
/// doesn't know, written out in full).
|
||||
pub fn closed_params(&mut self) -> bool {
|
||||
while self.number().is_some() {}
|
||||
self.at > 0 && self.b.get(self.at - 1) == Some(&b';')
|
||||
}
|
||||
|
||||
/// A comment (CO): its quoted string or nothing, then the terminator or the end → whether it
|
||||
/// is one.
|
||||
pub fn comment(&mut self) -> bool {
|
||||
while self.peek().is_some_and(separator) {
|
||||
self.at += 1;
|
||||
}
|
||||
match self.peek() {
|
||||
Some(b'"') => self.quoted(),
|
||||
Some(b';') => {
|
||||
self.at += 1;
|
||||
true
|
||||
}
|
||||
None => true,
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a label, up to and including `terminator` (or the end).
|
||||
pub fn label(&mut self, terminator: u8) {
|
||||
let rest = &self.b[self.at..];
|
||||
self.at += rest.iter().position(|&c| c == terminator).map_or(rest.len(), |n| n + 1);
|
||||
}
|
||||
|
||||
/// A quoted string (CO): the parameters up to its closing quote, then the terminator → whether
|
||||
/// there was one.
|
||||
pub fn quoted(&mut self) -> bool {
|
||||
while self.peek().is_some_and(separator) {
|
||||
self.at += 1;
|
||||
}
|
||||
let found = self.peek() == Some(b'"');
|
||||
if found {
|
||||
self.at += 1;
|
||||
let rest = &self.b[self.at..];
|
||||
self.at += rest.iter().position(|&c| c == b'"').map_or(rest.len(), |n| n + 1);
|
||||
}
|
||||
if self.peek() == Some(b';') {
|
||||
self.at += 1;
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The character right after the mnemonic (DT, SM), `None` when it is a semicolon or the end
|
||||
/// (the semicolon is read).
|
||||
pub fn char(&mut self) -> Option<u8> {
|
||||
let c = self.peek()?;
|
||||
self.at += 1;
|
||||
(c != b';').then_some(c)
|
||||
}
|
||||
|
||||
/// The next item of a PE instruction, `None` at its end (its semicolon is read).
|
||||
pub fn pe(&mut self, st: &mut Pe) -> Option<PeItem> {
|
||||
loop {
|
||||
let raw = self.peek()?;
|
||||
let c = raw & 0x7f;
|
||||
// Spaces, deletes, control characters and bytes 128 to 160 and 255 are ignored.
|
||||
if raw <= 32 || raw == 127 || (128..=160).contains(&raw) || raw == 255 {
|
||||
self.at += 1;
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
b';' => {
|
||||
self.at += 1;
|
||||
return None;
|
||||
}
|
||||
// Flags keep their meaning with the eighth bit set; digits are 63 and up (191 and up).
|
||||
b':' | b'<' | b'>' | b'=' | b'7' | b'9' if raw < 63 || (0xb7..191).contains(&raw) => {
|
||||
self.at += 1;
|
||||
match c {
|
||||
b':' => return self.pe_value(st).map(PeItem::Pen).or_else(|| self.end_pe()),
|
||||
b'<' => st.up = true,
|
||||
b'>' => match self.pe_value(st) {
|
||||
Some(n) => st.fraction_bits = n.clamp(0, 52) as u32,
|
||||
None => return self.end_pe(),
|
||||
},
|
||||
b'=' => st.absolute = true,
|
||||
b'7' => st.seven_bit = true,
|
||||
_ => return Some(PeItem::Rect),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let scale = 0.5f64.powi(st.fraction_bits as i32);
|
||||
let (Some(x), Some(y)) = (self.pe_value(st), self.pe_value(st)) else { return self.end_pe() };
|
||||
let item = PeItem::Point { x: x as f64 * scale, y: y as f64 * scale, up: st.up, absolute: st.absolute };
|
||||
(st.up, st.absolute) = (false, false);
|
||||
return Some(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rest of a PE instruction that can't be decoded: see [`Lexer::skip_pe`].
|
||||
fn end_pe(&mut self) -> Option<PeItem> {
|
||||
self.skip_pe();
|
||||
None
|
||||
}
|
||||
|
||||
/// The data of a PE instruction, up to and including its semicolon, unread: its data is never
|
||||
/// read as instructions.
|
||||
pub fn skip_pe(&mut self) {
|
||||
let rest = &self.b[self.at..];
|
||||
self.at += rest.iter().position(|&c| c == b';').map_or(rest.len(), |n| n + 1);
|
||||
}
|
||||
|
||||
/// One encoded PE number, `None` when the instruction ends inside it (or a byte that is no
|
||||
/// digit comes first, which is read).
|
||||
fn pe_value(&mut self, st: &Pe) -> Option<i64> {
|
||||
let (base, low, high): (u64, std::ops::RangeInclusive<u8>, std::ops::RangeInclusive<u8>) =
|
||||
if st.seven_bit { (32, 63..=94, 95..=126) } else { (64, 63..=126, 191..=254) };
|
||||
let (mut v, mut place) = (0u64, 1u64);
|
||||
loop {
|
||||
let raw = self.peek()?;
|
||||
let c = if st.seven_bit { raw & 0x7f } else { raw };
|
||||
if raw <= 32 || raw == 127 || (128..=160).contains(&raw) || raw == 255 {
|
||||
self.at += 1;
|
||||
continue;
|
||||
}
|
||||
if c == b';' {
|
||||
return None;
|
||||
}
|
||||
self.at += 1;
|
||||
let (digit, last) = if low.contains(&c) {
|
||||
(u64::from(c - 63), false)
|
||||
} else if high.contains(&c) {
|
||||
(u64::from(c - high.start()), true)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
v = v.saturating_add(digit.saturating_mul(place));
|
||||
place = place.saturating_mul(base);
|
||||
if last {
|
||||
// The low bit is the sign.
|
||||
let n = i64::try_from(v >> 1).unwrap_or(i64::MAX);
|
||||
return Some(if v & 1 == 1 { -n } else { n });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,630 @@
|
||||
//! HP-GL import: units and the y axis, pens, runs, curves, filled and edged areas, polygon mode,
|
||||
//! line types, scaling, rotation, encoded polylines, escapes around the instructions, what is
|
||||
//! skipped with a warning, which files are taken to be HP-GL, and hostile files.
|
||||
|
||||
use kurbo::{BezPath, PathEl, Point, Shape as _};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::FillRule;
|
||||
|
||||
use super::plot::{FAR_AWAY, HATCHES, LABELS, PAGES, RASTER, SYMBOLS, TOO_MANY_POINTS, WINDOW};
|
||||
use super::*;
|
||||
|
||||
/// Points per millimeter.
|
||||
const MM: f64 = 72.0 / 25.4;
|
||||
|
||||
fn plot(s: &str) -> Imported {
|
||||
plot_bytes(s.as_bytes())
|
||||
}
|
||||
|
||||
fn plot_bytes(b: &[u8]) -> Imported {
|
||||
match import(b) {
|
||||
Ok(i) => i,
|
||||
Err(e) => panic!("{e}: {:?}", String::from_utf8_lossy(b)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every object, in paint order.
|
||||
fn leaves(doc: &Document) -> Vec<Node> {
|
||||
let mut out = vec![];
|
||||
doc.walk(|n| {
|
||||
if n.children().is_none() {
|
||||
out.push(n.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn bez(n: &Node) -> BezPath {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => path.to_bezpath(),
|
||||
k => panic!("a path, got {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke(n: &Node) -> StrokeLayer {
|
||||
n.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }).expect("a stroke")
|
||||
}
|
||||
|
||||
fn fill(n: &Node) -> Option<Paint> {
|
||||
n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None })
|
||||
}
|
||||
|
||||
fn rule(n: &Node) -> FillRule {
|
||||
match &n.kind {
|
||||
NodeKind::Path { rule, .. } => *rule,
|
||||
k => panic!("a path, got {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb(r: u8, g: u8, b: u8) -> Paint {
|
||||
Paint::solid(Color::rgb8(r, g, b))
|
||||
}
|
||||
|
||||
/// The end points of `bp`'s elements.
|
||||
fn ends(bp: &BezPath) -> Vec<Point> {
|
||||
bp.elements().iter().filter_map(|e| e.end_point()).collect()
|
||||
}
|
||||
|
||||
fn near(a: Point, b: Point) -> bool {
|
||||
(a - b).hypot() < 1e-6
|
||||
}
|
||||
|
||||
fn assert_points(got: &[Point], want: &[(f64, f64)]) {
|
||||
assert_eq!(got.len(), want.len(), "{got:?}");
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
assert!(near(*g, Point::new(w.0, w.1)), "{got:?} != {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A PE number as the reference guide's encoding writes it (base 64, or base 32 for 7-bit data).
|
||||
fn pe_number(v: i64, seven_bit: bool) -> Vec<u8> {
|
||||
let mut n = if v >= 0 { 2 * v as u64 } else { 2 * v.unsigned_abs() + 1 };
|
||||
let (base, high) = if seven_bit { (32, 95) } else { (64, 191) };
|
||||
let mut out = vec![];
|
||||
while n >= base {
|
||||
out.push(63 + (n % base) as u8);
|
||||
n /= base;
|
||||
}
|
||||
out.push(high + n as u8);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plotter_units_become_points_with_y_down() {
|
||||
// An inch right and an inch up, with the thinnest pen (0.25 pt): the artboard reaches 0.125 pt
|
||||
// past the line at the corner's miter, and stops at the line's butt ends.
|
||||
let i = plot("IN;SP1;PW0;PU0,0;PD1016,0,1016,1016;PU;");
|
||||
let doc = &i.document;
|
||||
let r = doc.artboards[0].rect;
|
||||
assert!((r.width() - 72.125).abs() < 1e-9 && (r.height() - 72.125).abs() < 1e-9, "{r:?}");
|
||||
let shapes = leaves(doc);
|
||||
assert_eq!(shapes.len(), 1);
|
||||
assert_points(&ends(&bez(&shapes[0])), &[(0.0, 72.0), (72.0, 72.0), (72.0, 0.0)]);
|
||||
let s = stroke(&shapes[0]);
|
||||
assert_eq!((s.width, s.paint.clone(), s.cap, s.join, s.miter_limit), (0.25, rgb(0, 0, 0), LineCap::Butt, LineJoin::Miter, 5.0));
|
||||
assert!(fill(&shapes[0]).is_none() && i.warnings.is_empty(), "{:?}", i.warnings);
|
||||
|
||||
// The artboard holds what the strokes paint, the tip of a sharp miter join included.
|
||||
let i = plot("IN;SP1;PW2;PD2032,0,0,1000;");
|
||||
let r = i.document.artboards[0].rect;
|
||||
let painted = leaves(&i.document)[0].visual_bounds().unwrap();
|
||||
let path = bez(&leaves(&i.document)[0]).bounding_box();
|
||||
assert!(painted.x1 - path.x1 > 2.0 * MM, "the miter reaches past half the width: {painted:?} {path:?}");
|
||||
let inside = r.x0 <= painted.x0 + 1e-9 && r.y0 <= painted.y0 + 1e-9 && r.x1 >= painted.x1 - 1e-9 && r.y1 >= painted.y1 - 1e-9;
|
||||
assert!(inside, "{r:?} {painted:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pens_pick_colors_and_widths() {
|
||||
// Before any SP the pen is pen 1; SP0 draws nothing; pens past the palette wrap round it.
|
||||
let i = plot("IN;PD0,0,100,0;PU;SP2;PD0,100,100,100;PU;SP0;PD0,200,100,200;PU;SP9;PD0,300,100,300;PU;SP3;PD0,400,100,400;PU;");
|
||||
let colors: Vec<Paint> = leaves(&i.document).iter().map(|n| stroke(n).paint).collect();
|
||||
assert_eq!(colors, [rgb(0, 0, 0), rgb(255, 0, 0), rgb(255, 0, 0), rgb(0, 255, 0)]);
|
||||
|
||||
// PC gives a pen a color in the color range; PC with the pen alone gives back its default.
|
||||
let i = plot("IN;CR0,1,0,1,0,1;PC3,0,0,1;SP3;PD0,0,10,0;PU;PC3;PD0,10,10,10;PU;");
|
||||
let colors: Vec<Paint> = leaves(&i.document).iter().map(|n| stroke(n).paint).collect();
|
||||
assert_eq!(colors, [rgb(0, 0, 255), rgb(0, 255, 0)]);
|
||||
|
||||
// PW in millimeters for every pen, or for one; IN gives back 0.35 mm.
|
||||
let i = plot("IN;SP1;PW1;PD0,0,10,0;PU;PW2,2;SP2;PD0,10,10,10;PU;SP1;PD0,20,10,20;PU;IN;SP1;PD0,30,10,30;");
|
||||
let widths: Vec<f64> = leaves(&i.document).iter().map(|n| stroke(n).width).collect();
|
||||
let want = [MM, 2.0 * MM, MM, 0.35 * MM];
|
||||
assert!(widths.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "{widths:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_run_with_the_pen_down_is_one_path() {
|
||||
// Relative moves; a run that comes back to its start is closed; a pen-up move elsewhere starts
|
||||
// another path, and one back to where the line ended continues it.
|
||||
let i = plot("IN;SP1;PW0;PA0,0;PR;PD1016,0,0,1016,-1016,0,0,-1016;PU2032,0;PD0,1016;PU0,1016;PU0,-1016;PD508,0;PU0,500;PD0,500;");
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!(shapes.len(), 3, "{shapes:#?}");
|
||||
let NodeKind::Path { path: square, .. } = &shapes[0].kind else { panic!("a path") };
|
||||
assert_eq!((square.subpaths.len(), square.subpaths[0].anchors.len(), square.subpaths[0].closed), (1, 4, true));
|
||||
assert_eq!(ends(&bez(&shapes[1])).len(), 3, "the run went on from where it ended");
|
||||
assert_eq!(ends(&bez(&shapes[2])).len(), 2);
|
||||
|
||||
// A pen-down move of no length draws a dot.
|
||||
let i = plot("IN;SP1;PA500,500;PD;PA500,500;PU;");
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!(shapes.len(), 1);
|
||||
let b = bez(&shapes[0]).bounding_box();
|
||||
assert!((b.width() - 72.0 / 1016.0).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circles_arcs_and_curves_are_drawn_as_curves() {
|
||||
// A circle of an inch round the pen, which stays at its center (the next line starts there).
|
||||
let i = plot("IN;SP1;PW0;PA2032,2032;CI1016;PD3048,2032;");
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!(shapes.len(), 2);
|
||||
let c = bez(&shapes[0]);
|
||||
assert!(c.elements().iter().any(|e| matches!(e, PathEl::CurveTo(..))) && matches!(c.elements().last(), Some(PathEl::ClosePath)));
|
||||
// The curves stay within a thousandth of a point of the circle.
|
||||
let b = c.bounding_box();
|
||||
assert!((b.width() - 144.0).abs() < 1e-3 && (b.height() - 144.0).abs() < 1e-3, "{b:?}");
|
||||
let line = ends(&bez(&shapes[1]));
|
||||
assert!((line[0] - b.center()).hypot() < 1e-3, "{line:?} {b:?}");
|
||||
|
||||
// AA: a quarter turn counterclockwise round (0, 0) from (1016, 0) ends at (0, 1016), and the
|
||||
// pen goes on from there. AR takes its center relative to the pen.
|
||||
for s in ["IN;SP1;PW0;PA1016,0;PD;AA0,0,90;PA0,2032;", "IN;SP1;PW0;PA1016,0;PD;AR-1016,0,90;PA0,2032;"] {
|
||||
let shapes = leaves(&plot(s).document);
|
||||
assert_eq!(shapes.len(), 1, "{s}");
|
||||
let p = bez(&shapes[0]);
|
||||
let pts = ends(&p);
|
||||
// y down: (1016, 0) is at the bottom right, the arc's end on the left a quarter turn up.
|
||||
let (start, end) = (pts[0], pts[pts.len() - 2]);
|
||||
assert!(near(start, Point::new(72.125, 144.0)) && near(end, Point::new(0.125, 72.0)), "{s}: {pts:?}");
|
||||
assert!(near(*pts.last().unwrap(), Point::new(0.125, 0.0)), "{s}: {pts:?}");
|
||||
}
|
||||
|
||||
// AT: the arc from the pen through a point to an end; points in a line give a line.
|
||||
let shapes = leaves(&plot("IN;SP1;PW0;PA1016,0;PD;AT0,1016,-1016,0;").document);
|
||||
let pts = ends(&bez(&shapes[0]));
|
||||
let b = bez(&shapes[0]).bounding_box();
|
||||
assert!((b.width() - 144.0).abs() < 1e-6 && (b.height() - 72.0).abs() < 1e-6, "a half circle: {b:?}");
|
||||
assert!(near(pts[0], Point::new(144.125, 72.125)) && near(*pts.last().unwrap(), Point::new(0.125, 72.125)), "{pts:?}");
|
||||
let shapes = leaves(&plot("IN;SP1;PW0;PA0,0;PD;AT508,0,1016,0;").document);
|
||||
assert_eq!(ends(&bez(&shapes[0])).len(), 2);
|
||||
|
||||
// BZ and BR: cubic Béziers, BR's points relative to each curve's start.
|
||||
for s in [
|
||||
"IN;SP1;PW0;PA0,0;PD;BZ0,1016,1016,1016,1016,0,1016,-1016,2032,-1016,2032,0;",
|
||||
"IN;SP1;PW0;PA0,0;PD;BR0,1016,1016,1016,1016,0,0,-1016,1016,-1016,1016,0;",
|
||||
] {
|
||||
let shapes = leaves(&plot(s).document);
|
||||
let els = bez(&shapes[0]).elements().to_vec();
|
||||
assert_eq!(els.iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count(), 2, "{s}");
|
||||
// The curves reach 762 plotter units (three quarters of their control points) up and down.
|
||||
assert!(near(els.last().and_then(PathEl::end_point).unwrap(), Point::new(144.125, 54.125)), "{s}: {els:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangles_wedges_and_polygons_fill_and_edge() {
|
||||
// RA fills, EA edges; neither moves the pen.
|
||||
let i = plot("IN;SP2;PW0;PA0,0;RA1016,1016;EA1016,1016;PD508,0;");
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!(shapes.len(), 3);
|
||||
assert_eq!((fill(&shapes[0]), shapes[0].appearance.items.len()), (Some(rgb(255, 0, 0)), 1));
|
||||
assert!(fill(&shapes[1]).is_none() && stroke(&shapes[1]).paint == rgb(255, 0, 0));
|
||||
assert!(near(ends(&bez(&shapes[2]))[0], Point::new(0.125, 72.125)), "the line starts where the pen was");
|
||||
|
||||
// WG fills a wedge, EW edges one; a whole turn is a circle.
|
||||
let i = plot("IN;SP1;PA0,0;WG1016,0,90;EW1016,0,360;");
|
||||
let shapes = leaves(&i.document);
|
||||
let wedge = bez(&shapes[0]);
|
||||
assert!(fill(&shapes[0]).is_some() && ends(&wedge).len() >= 3);
|
||||
let ring = bez(&shapes[1]).bounding_box();
|
||||
assert!((ring.width() - 144.0).abs() < 1e-6);
|
||||
|
||||
// Polygon mode: two squares, one inside the other; FP fills even-odd by default, FP1
|
||||
// non-zero; EP edges only what was drawn with the pen down.
|
||||
let poly = "IN;SP1;PW0;PA0,0;PM0;PD3048,0,3048,3048,0,3048,0,0;PM1;PU1016,1016;PD2032,1016;PU2032,2032;PD1016,2032,1016,1016;PM2;";
|
||||
let i = plot(&format!("{poly}FP;FP1;EP;"));
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!(shapes.len(), 3);
|
||||
assert_eq!((rule(&shapes[0]), rule(&shapes[1])), (FillRule::EvenOdd, FillRule::NonZero));
|
||||
let filled = bez(&shapes[0]);
|
||||
assert_eq!(filled.elements().iter().filter(|e| matches!(e, PathEl::ClosePath)).count(), 2);
|
||||
let edged = bez(&shapes[2]);
|
||||
assert_eq!(edged.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(..))).count(), 3, "the pen-up side isn't edged: {edged:?}");
|
||||
// Nothing is drawn in polygon mode itself.
|
||||
assert!(leaves(&plot(poly).document).is_empty());
|
||||
|
||||
// A circle in polygon mode is a subpolygon of its own; CI and RA after PM2 work as before.
|
||||
let i = plot("IN;SP1;PA2032,2032;PM0;CI1016;PM2;FP;");
|
||||
assert_eq!(leaves(&i.document).len(), 1);
|
||||
|
||||
// Fill types: shading mixes the pen with white; hatching fills solid with a warning.
|
||||
let i = plot("IN;SP5;PA0,0;FT10,50;RA100,100;FT3,10,45;RA200,200;");
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!((fill(&shapes[0]), fill(&shapes[1])), (Some(rgb(128, 128, 255)), Some(rgb(0, 0, 255))));
|
||||
assert_eq!(i.warnings, [HATCHES]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_types_become_dash_patterns() {
|
||||
// A 10 mm pattern of line type 2 is 5 mm on, 5 mm off.
|
||||
let i = plot("IN;SP1;LT2,10,1;PD0,0,4000,0;");
|
||||
let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed");
|
||||
assert!(d.pattern.iter().all(|v| (v - 5.0 * MM).abs() < 1e-9) && d.pattern.len() == 2 && !d.align_corners, "{d:?}");
|
||||
// The default length is 4% of the P1-P2 diagonal; IP sets P1 and P2.
|
||||
let i = plot("IN;IP0,0,3000,4000;SP1;LT3;PD0,0,4000,0;");
|
||||
let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed");
|
||||
let total = 200.0 * 72.0 / 1016.0;
|
||||
assert!((d.pattern[0] - 0.7 * total).abs() < 1e-9 && (d.pattern[1] - 0.3 * total).abs() < 1e-9, "{d:?}");
|
||||
// Adaptive types stretch to each line; a zero length is a dot one plotter unit long.
|
||||
let i = plot("IN;SP1;LT-4,10,1;PD0,0,4000,0;");
|
||||
let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed");
|
||||
assert!(d.align_corners && (d.pattern[2] - 72.0 / 1016.0).abs() < 1e-12, "{d:?}");
|
||||
// UL redefines a type; LT; draws solid and LT99 gives the type back.
|
||||
let i = plot("IN;SP1;UL2,1,3;LT2,10,1;PD0,0,4000,0;PU;LT;PD0,100,4000,100;PU;LT99;PD0,200,4000,200;");
|
||||
let shapes = leaves(&i.document);
|
||||
let d = stroke(&shapes[0]).dash.expect("dashed");
|
||||
assert!((d.pattern[0] - 2.5 * MM).abs() < 1e-9 && (d.pattern[1] - 7.5 * MM).abs() < 1e-9, "{d:?}");
|
||||
assert!(stroke(&shapes[1]).dash.is_none() && stroke(&shapes[2]).dash.is_some());
|
||||
// Line type 0 draws a dot at each point.
|
||||
let i = plot("IN;SP1;LT0;PD0,0,1000,0,2000,0;");
|
||||
assert_eq!(leaves(&i.document).len(), 3);
|
||||
// LA sets the caps, joins and miter limit.
|
||||
let s = stroke(&leaves(&plot("IN;SP1;LA1,4,2,4,3,9;PD0,0,100,0;").document)[0]);
|
||||
assert_eq!((s.cap, s.join, s.miter_limit), (LineCap::Round, LineJoin::Round, 9.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_maps_user_units_onto_p1_and_p2() {
|
||||
// Anisotropic: 100 user units across an inch.
|
||||
let size = |s: &str| {
|
||||
let shapes = leaves(&plot(s).document);
|
||||
bez(&shapes[0]).bounding_box().size()
|
||||
};
|
||||
let s = size("IN;IP0,0,1016,2032;SC0,100,0,100;SP1;PD0,0,100,100;");
|
||||
assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 144.0).abs() < 1e-9, "{s:?}");
|
||||
// Isotropic: the smaller of the two scales on both axes.
|
||||
let s = size("IN;IP0,0,1016,2032;SC0,100,0,100,1;SP1;PU0,0;PD100,100;");
|
||||
assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 72.0).abs() < 1e-9, "{s:?}");
|
||||
// Point factor: 10 plotter units per user unit.
|
||||
let s = size("IN;SC0,10,0,10,2;SP1;PD0,0,101.6,50.8;");
|
||||
assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 36.0).abs() < 1e-9, "{s:?}");
|
||||
// Without IP, P2 lies 11 × 8.5 in from the origin.
|
||||
let s = size("IN;SC0,11,0,8.5;SP1;PD0,0,1,1;");
|
||||
assert!((s.width - 72.0).abs() < 1e-9 && (s.height - 72.0).abs() < 1e-9, "{s:?}");
|
||||
// SC; goes back to plotter units; scaling that makes no area is ignored.
|
||||
let s = size("IN;SC0,1,0,1;SC;SC0,0,0,1;SP1;PD0,0,1016,1016;");
|
||||
assert!((s.width - 72.0).abs() < 1e-9, "{s:?}");
|
||||
// RO turns the coordinate system: x runs up.
|
||||
let shapes = leaves(&plot("IN;RO90;SP1;PW0;PA0,0;PD1016,0;").document);
|
||||
let b = bez(&shapes[0]).bounding_box();
|
||||
assert!(b.width() < 1e-9 && (b.height() - 72.0).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_encoded_data_is_read() {
|
||||
let mut f = b"IN;SP1;PW0;PE".to_vec();
|
||||
// Pen 2, a pen-up move to the absolute (1016, 0), then a relative line an inch up.
|
||||
f.push(b':');
|
||||
f.extend(pe_number(2, false));
|
||||
f.extend(b"<=");
|
||||
f.extend(pe_number(1016, false));
|
||||
f.extend(pe_number(0, false));
|
||||
f.extend(pe_number(0, false));
|
||||
f.extend(pe_number(1016, false));
|
||||
f.push(b';');
|
||||
let i = plot_bytes(&f);
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!(shapes.len(), 1);
|
||||
assert_eq!(stroke(&shapes[0]).paint, rgb(255, 0, 0));
|
||||
assert_points(&ends(&bez(&shapes[0])), &[(0.125, 72.0), (0.125, 0.0)]);
|
||||
|
||||
// 7-bit data with two fractional bits: 4064 / 4 = 1016.
|
||||
let mut f = b"IN;SP1;PW0;PE7>".to_vec();
|
||||
f.extend(pe_number(2, true));
|
||||
f.extend(pe_number(-4064, true));
|
||||
f.extend(pe_number(0, true));
|
||||
f.push(b';');
|
||||
let b = bez(&leaves(&plot_bytes(&f).document)[0]).bounding_box();
|
||||
assert!((b.width() - 72.0).abs() < 1e-9, "{b:?}");
|
||||
|
||||
// Data that can't be decoded ends the instruction at its semicolon: none of it is read as
|
||||
// instructions.
|
||||
let shapes = leaves(&plot("IN;SP1;PE#PD5000,5000;PD1016,0;").document);
|
||||
assert_eq!(shapes.len(), 1);
|
||||
assert!((bez(&shapes[0]).bounding_box().width() - 72.0).abs() < 1e-9);
|
||||
|
||||
// Rectangle mode: a pen-up move, then a filled rectangle relative to it.
|
||||
let mut f = b"IN;SP3;PE9".to_vec();
|
||||
for v in [100, 100, 1016, 508] {
|
||||
f.extend(pe_number(v, false));
|
||||
}
|
||||
f.push(b';');
|
||||
let shapes = leaves(&plot_bytes(&f).document);
|
||||
assert_eq!(fill(&shapes[0]), Some(rgb(0, 255, 0)));
|
||||
let b = bez(&shapes[0]).bounding_box();
|
||||
assert!((b.width() - 72.0).abs() < 1e-9 && (b.height() - 36.0).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printer_escapes_and_job_lines_are_skipped() {
|
||||
let mut f = b"\x1b%-12345X@PJL JOB\r\n@PJL ENTER LANGUAGE=PCL\r\n\x1bE\x1b%0BIN;SP1;PW0;PD0,0,1016,0;PU;\x1b%0A".to_vec();
|
||||
// An HP RTL raster image whose data spells out instructions.
|
||||
f.extend(b"\x1b*r1A\x1b*b11WPD9999,9999\x1b*rC");
|
||||
f.extend(b"\x1b%1BPD1016,1016;\x1b%-12345X@PJL EOJ\r\n");
|
||||
assert!(is_hpgl(&f));
|
||||
let i = plot_bytes(&f);
|
||||
let shapes = leaves(&i.document);
|
||||
// The pen went up and came down where the line ended, so the line goes on.
|
||||
assert_eq!(shapes.len(), 1, "{shapes:#?}");
|
||||
assert_eq!(ends(&bez(&shapes[0])).len(), 3);
|
||||
assert_eq!(i.warnings, [RASTER], "{:?}", i.warnings);
|
||||
let r = i.document.artboards[0].rect;
|
||||
assert!((r.width() - 72.125).abs() < 1e-9 && (r.height() - 72.125).abs() < 1e-9, "{r:?}");
|
||||
|
||||
// The device-control escapes of older plotters.
|
||||
let f = b"\x1b.(\x1b.I81;;17:\x1b.N;19:IN;SP1;PD0,0,1016,0;\x1b.)";
|
||||
assert!(is_hpgl(f));
|
||||
assert_eq!(leaves(&plot_bytes(f).document).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_comments_and_unknown_instructions_are_skipped() {
|
||||
// A label's text, up to its terminator (ETX, or the one DT sets), isn't read as instructions.
|
||||
let i = plot("IN;SP1;LBPD9999,9999\x03DT*,1;LBPU0,0;PD5000,5000*CO\"PD8000,8000\";PD1016,0;PU;ZZ1,2;QQ;");
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!(shapes.len(), 1);
|
||||
assert_eq!(i.warnings, [LABELS.to_string(), "2 instructions of kinds VectorCraft doesn't read were skipped".to_string()]);
|
||||
// Symbol mode's symbols aren't drawn, with a warning; its lines are.
|
||||
let i = plot("IN;SP1;SM*;PD100,0;SM;");
|
||||
assert_eq!((i.warnings.clone(), leaves(&i.document).len()), (vec![SYMBOLS.to_string()], 1));
|
||||
// Later pages and the input window are warned about.
|
||||
let i = plot("IN;IW0,0,10,10;SP1;PD100,0;PU;PG;PD0,100;PG;");
|
||||
assert_eq!(i.warnings, [WINDOW, PAGES]);
|
||||
// A page advance raises the pen at the origin: nothing joins the pages.
|
||||
let i = plot("IN;SP1;PD100,0;PG;PA200,0;PD300,0;");
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!(shapes.len(), 2);
|
||||
assert_eq!(ends(&bez(&shapes[1])).len(), 2, "the second page's line starts at (200, 0)");
|
||||
// A page advance at the end changes nothing.
|
||||
assert!(plot("IN;SP1;PD100,0;PG;").warnings.is_empty());
|
||||
// A file that draws nothing opens as a letter-size page.
|
||||
let i = plot("IN;SP1;PU100,100;");
|
||||
assert!(leaves(&i.document).is_empty() && i.document.artboards[0].rect.size() == kurbo::Size::new(612.0, 792.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_plot_files_are_taken_for_hpgl() {
|
||||
for f in [
|
||||
&b"IN;SP1;PU0,0;PD100,100;"[..],
|
||||
b"in;sp1;pu0,0;pd100,100;",
|
||||
b";IN;\r\nSP1;\r\nPA100,100;",
|
||||
b"\x03IN;",
|
||||
b"SP1PD0,0,10,10PU",
|
||||
b"IN",
|
||||
b"CO\"made by hand\";IN;",
|
||||
b"IN;SP1;DT*;LBhello world*PA0,0;SMx;PA1,1;SM;",
|
||||
b"IN;SP1;PE<=\xbf\xbf;PU;",
|
||||
b"\x1b%-12345X@PJL ENTER LANGUAGE=HPGL2\nIN;SP1;",
|
||||
] {
|
||||
assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f));
|
||||
}
|
||||
for f in [
|
||||
&b""[..],
|
||||
b"IN THE BEGINNING",
|
||||
b"set terminal png\nplot sin(x)\n",
|
||||
b"Paris, 10 to 12",
|
||||
b"SPDX-License-Identifier: MIT",
|
||||
b"CONTENTS",
|
||||
b"CO \"a comment\";hello world",
|
||||
b"DT*;LBtext*nope",
|
||||
b"\x89PNG\r\n\x1a\n",
|
||||
b"IN;SP1;PD0,0;hello world",
|
||||
b"\x1b%-12345X@PJL ENTER LANGUAGE=PCL\n\x1bE\x1b*r1A",
|
||||
] {
|
||||
assert!(!is_hpgl(f), "{:?}", String::from_utf8_lossy(f));
|
||||
assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f));
|
||||
}
|
||||
// A comment needs no string; instructions of an older or a device's own set count when a
|
||||
// semicolon ends them.
|
||||
for f in [&b"CO;IN;SP1;PD0,0,10,10;"[..], b"IN;CO;SP1;PD0,0,10,10;", b"IN;SP1;VA;VS10;PU0,0;PD1016,0;", b"IN;GC5;SP1;PD0,0,10,0;"] {
|
||||
assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f));
|
||||
}
|
||||
assert!(!is_hpgl(b"IN;GC5") && !is_hpgl(b"IN;Hello, world;"));
|
||||
// A printer prolog of any length comes before the first instruction.
|
||||
let long = [&b"\x1b%-12345X"[..], &b"@PJL COMMENT a job line of some length\r\n".repeat(400), b"\x1bE\x1b%0BIN;SP1;PD100,0;"].concat();
|
||||
assert!(long.len() > 16_000 && is_hpgl(&long));
|
||||
assert_eq!(leaves(&plot_bytes(&long).document).len(), 1);
|
||||
// Instructions of any kind may follow the first eight, garbage included.
|
||||
assert!(is_hpgl(b"IN;SP1;PU;PD;PU;PD;PU;PD;hello world"));
|
||||
}
|
||||
|
||||
/// A file read as HP-GL (picked by its extension) may start with any instruction the reader knows,
|
||||
/// or one of another set that a semicolon ends; its content alone doesn't make it HP-GL.
|
||||
#[test]
|
||||
fn a_plot_file_may_start_with_any_instruction() {
|
||||
for f in [&b"SI0.2,0.3;SP1;PA0,0;PD1016,0;"[..], b"DI1,0;SP1;PA0,0;PD1016,0;", b"GC5;SP1;PA0,0;PD1016,0;"] {
|
||||
assert!(!is_hpgl(f), "{:?}", String::from_utf8_lossy(f));
|
||||
assert_eq!(leaves(&plot_bytes(f).document).len(), 1, "{:?}", String::from_utf8_lossy(f));
|
||||
}
|
||||
for f in [&b"GC5"[..], b"SIhello world", b"set terminal png\n"] {
|
||||
assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f));
|
||||
}
|
||||
// Instructions of another set alone, however many or long, are not taken for HP-GL.
|
||||
let long = [&b"GC"[..], "1".repeat(5000).as_bytes(), b";ZZ1;"].concat();
|
||||
for f in [&b"GC5;"[..], b"GC5;ZZ1;", b"GC5;ZZ1;YY2;XX3;WW4;VV5;UU6;TT7;", &long] {
|
||||
assert_eq!(import(f).err().as_deref(), Some("not an HP-GL file"), "{:?}", String::from_utf8_lossy(f));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_that_carry_values_or_data_are_skipped_whole() {
|
||||
// A reset leaves HP-GL until a language switch; symbol-set escapes carry a value and a letter.
|
||||
for f in [
|
||||
&b"\x1bE\x1b(8U\x1b%1BIN;SP1;PD0,0,1016,0;"[..],
|
||||
b"\x1bE\x1b(10U\x1b)0N\x1b%1BIN;SP1;PD0,0,1016,0;",
|
||||
b"\x1bEHello\x1b%1BIN;SP1;PD0,0,1016,0;",
|
||||
] {
|
||||
assert!(is_hpgl(f), "{:?}", String::from_utf8_lossy(f));
|
||||
assert_eq!(leaves(&plot_bytes(f).document).len(), 1, "{:?}", String::from_utf8_lossy(f));
|
||||
}
|
||||
// A letter left over from an escape doesn't join the next mnemonic.
|
||||
let i = plot_bytes(b"IN;SP1;\x1b(8UPD0,0,1016,0;");
|
||||
assert!(i.warnings.is_empty() && leaves(&i.document).len() == 1, "{:?}", i.warnings);
|
||||
// Raster data by plane and transparent print data are skipped by their byte counts, however
|
||||
// they read.
|
||||
let line = |data_escape: &[u8]| {
|
||||
let data = b"\x1b%1BPD0,5000;";
|
||||
let mut f = b"IN;SP1;PW0;PD0,0,0,1016;\x1b%0A".to_vec();
|
||||
f.extend(data_escape);
|
||||
f.extend(data.len().to_string().as_bytes());
|
||||
f.extend(if data_escape.ends_with(b"b") { &b"V"[..] } else { b"X" });
|
||||
f.extend(data);
|
||||
f.extend(b"\x1b*rC\x1b%1BPD0,0;");
|
||||
bez(&leaves(&plot_bytes(&f).document)[0]).bounding_box()
|
||||
};
|
||||
for escape in [&b"\x1b*r1A\x1b*b"[..], b"\x1b&p"] {
|
||||
let b = line(escape);
|
||||
assert!((b.height() - 72.0).abs() < 1e-9, "{:?}: {b:?}", String::from_utf8_lossy(escape));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygon_mode_follows_its_own_rules() {
|
||||
// The point after a circle starts a new subpolygon: the triangle after it doesn't reach back
|
||||
// to the circle's center, filled or edged.
|
||||
let i = plot("IN;SP1;PW0;PA1000,1000;PM0;CI200;PD2000,1000,2000,2000,1000,2000;PM2;FP;EP;");
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!(shapes.len(), 2);
|
||||
for s in &shapes {
|
||||
let p = bez(s);
|
||||
let first = p.elements().iter().position(|e| matches!(e, PathEl::ClosePath)).unwrap_or(p.elements().len());
|
||||
let circle = BezPath::from_vec(p.elements()[..first].to_vec()).bounding_box();
|
||||
let center = circle.center();
|
||||
assert!(ends(&p).iter().all(|e| !near(*e, center)), "{p:?}");
|
||||
}
|
||||
// PM1 closes with the pen up: EP draws two sides of the triangle, not the third; PM2 closes
|
||||
// with the pen as it is.
|
||||
let sides = |s: &str| {
|
||||
let p = bez(&leaves(&plot(s).document)[0]);
|
||||
p.elements().iter().filter(|e| matches!(e, PathEl::LineTo(..))).count()
|
||||
};
|
||||
assert_eq!(sides("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM1;PM2;EP;"), 2);
|
||||
assert_eq!(sides("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM2;EP;"), 3);
|
||||
// The closing point moves the pen: a relative move after PM1 starts from the start.
|
||||
let i = plot("IN;SP1;PA0,0;PM0;PD1000,0,1000,1000;PM1;PU;PR0,2000;PD1000,0,0,1000;PM2;FP;");
|
||||
let b = bez(&leaves(&i.document)[0]).bounding_box();
|
||||
assert!((b.height() - 3000.0 * PT_PER_PLU).abs() < 1e-9, "{b:?}");
|
||||
// A subpolygon of fewer than three points (the closing point counted) is neither filled nor
|
||||
// edged.
|
||||
assert!(leaves(&plot("IN;SP1;PA0,0;PM0;PD0,0;PM2;FP;EP;").document).is_empty());
|
||||
let i = plot("IN;SP1;PA0,0;PM0;PR0,0;PM1;PA;PD1000,0,0,1000,0,0;PM2;FP;");
|
||||
let filled = bez(&leaves(&i.document)[0]);
|
||||
assert_eq!(filled.elements().iter().filter(|e| matches!(e, PathEl::ClosePath)).count(), 1, "{filled:?}");
|
||||
// Polygon mode ignores the instructions it doesn't allow: pens, scaling and labels.
|
||||
let i = plot("IN;SP1;PA0,0;PM0;SP2;SC0,1,0,1;LBPD9999,9999\x03PD1016,0,1016,1016;PM2;FP;");
|
||||
let shapes = leaves(&i.document);
|
||||
assert_eq!((shapes.len(), fill(&shapes[0])), (1, Some(rgb(0, 0, 0))));
|
||||
assert!((bez(&shapes[0]).bounding_box().width() - 72.0).abs() < 1e-9 && i.warnings.is_empty(), "{:?}", i.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_keeps_the_pen_and_turns_the_defaults() {
|
||||
// After RO90, IP's default P2 is the rotated hard-clip corner: the user square spans 8.5 in
|
||||
// across its rotated x axis and 11 in up it, which lie up and across the page.
|
||||
let i = plot("IN;RO90;IP;SC0,100,0,100;SP1;PW0;PA0,0;PD100,0,100,100,0,100,0,0;");
|
||||
let b = bez(&leaves(&i.document)[0]).bounding_box();
|
||||
assert!((b.width() - 792.0).abs() < 1e-9 && (b.height() - 612.0).abs() < 1e-9, "{b:?}");
|
||||
// IR's percentages are of the rotated limits too.
|
||||
let i = plot("IN;RO90;IR0,0,100,100;SC0,1,0,1;SP1;PW0;PA0,0;PD1,1;");
|
||||
let b = bez(&leaves(&i.document)[0]).bounding_box();
|
||||
assert!((b.width() - 792.0).abs() < 1e-9 && (b.height() - 612.0).abs() < 1e-9, "{b:?}");
|
||||
// The pen stays where it was on the page: a line after RO starts where the one before ended.
|
||||
let i = plot("IN;SP1;PW0;PD0,0,1016,0;PU;RO90;PD;PR1016,0;");
|
||||
let all = leaves(&i.document).iter().map(|n| bez(n).bounding_box()).reduce(|a, b| a.union(b)).unwrap();
|
||||
assert!((all.width() - 72.0).abs() < 1e-9 && (all.height() - 72.0).abs() < 1e-9, "{all:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_type_zero_and_odd_patterns_follow_the_reference() {
|
||||
// Line type 0: one dot for an arc (at its end) and one for a circle (at its center).
|
||||
for s in ["IN;SP1;LT0;PA0,0;PD;AA1016,0,180;", "IN;SP1;LT0;PA0,0;CI500;"] {
|
||||
let shapes = leaves(&plot(s).document);
|
||||
assert_eq!(shapes.len(), 1, "{s}");
|
||||
assert!(bez(&shapes[0]).bounding_box().width() < 0.1, "{s}");
|
||||
}
|
||||
// An odd number of gaps repeats with the last pen-down stretch running into the first.
|
||||
let i = plot("IN;SP1;UL3,50,25,25;LT3,10,1;PD0,0,4000,0;");
|
||||
let d = stroke(&leaves(&i.document)[0]).dash.expect("dashed");
|
||||
assert!((d.pattern[0] - 7.5 * MM).abs() < 1e-9 && (d.pattern[1] - 2.5 * MM).abs() < 1e-9 && d.pattern.len() == 2, "{d:?}");
|
||||
assert!((d.offset - 2.5 * MM).abs() < 1e-9, "{d:?}");
|
||||
// One pen-down stretch is a solid line; a dot keeps the pattern's length.
|
||||
let d = stroke(&leaves(&plot("IN;SP1;UL3,100;LT3,10,1;PD0,0,4000,0;").document)[0]).dash.expect("dashed");
|
||||
assert_eq!(d.pattern[1], 0.0);
|
||||
let d = stroke(&leaves(&plot("IN;SP1;LT4,10,1;PD0,0,4000,0;").document)[0]).dash.expect("dashed");
|
||||
assert!((d.pattern.iter().sum::<f64>() - 10.0 * MM).abs() < 1e-9, "{d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_colors_ignore_two_or_three_parameters() {
|
||||
let i = plot("IN;PC3,0,0,255;PC3,255;PC3,255,0;SP3;PD0,0,10,0;");
|
||||
assert_eq!(stroke(&leaves(&i.document)[0]).paint, rgb(0, 0, 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_files_import_without_panics() {
|
||||
let mut cases: Vec<Vec<u8>> = vec![
|
||||
// Coordinates past any plotter's range, and numbers with too many digits.
|
||||
b"IN;SP1;PD99999999999999999999999999999,0;PU0,0;PD1016,1016;".to_vec(),
|
||||
b"IN;SP1;PD0.000000000000000000000000001,1e999,--,++,..;".to_vec(),
|
||||
// Unterminated labels, comments and encoded polylines.
|
||||
b"IN;SP1;LBno end".to_vec(),
|
||||
b"IN;CO\"no end".to_vec(),
|
||||
b"IN;SP1;PE<=\xff\xff\xff\xfe\xfe".to_vec(),
|
||||
b"IN;SP1;PE>\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\x7e\xfe\x40\xc0;".to_vec(),
|
||||
// Arcs and wedges of every degenerate kind, with huge sweeps.
|
||||
b"IN;SP1;PD;AA0,0,32767;AR0,0,90;AT0,0,0,0;RT1,1,2,2;CI0;CI-5;WG0,0,0;EW5,99999,-99999;WG-5,0,360;CI99999999999999;".to_vec(),
|
||||
// Scaling onto one point, rotations the format doesn't define, huge palettes and pens.
|
||||
b"IN;IP5,5,5,5;SC0,1,0,1,1;SP1;PD1,1;SC0,0,0,0,2;RO45;NP99999999;SP99999999999;PC99999999,1,2,3;PW99999,99999;PD2,2;".to_vec(),
|
||||
// Polygon mode left open, PM1 and PM2 without PM0, FP and EP with nothing buffered.
|
||||
b"IN;SP1;FP;EP;PM1;PM2;PM0;PD10,10;PM1;PM1;PM1;CI5;PE<\xc0\xc0;".to_vec(),
|
||||
// Escapes cut short, and data lengths past the end.
|
||||
b"\x1b%-12345X@PJL".to_vec(),
|
||||
b"IN;SP1;PD0,0;\x1b*b99999999999W".to_vec(),
|
||||
b"IN;\x1b".to_vec(),
|
||||
b"IN;\x1b.".to_vec(),
|
||||
];
|
||||
// A job line with no end.
|
||||
cases.push([&b"\x1b%-12345X@PJL "[..], &b"x".repeat(1 << 20)].concat());
|
||||
// Bytes of every value after a valid start.
|
||||
cases.push([&b"IN;SP1;PD"[..], &(0..=255u8).collect::<Vec<_>>()].concat());
|
||||
for f in &cases {
|
||||
let r = std::panic::catch_unwind(|| import(f));
|
||||
assert!(r.is_ok(), "{:?}", String::from_utf8_lossy(f));
|
||||
}
|
||||
let far = import(&cases[0]).unwrap();
|
||||
assert!(far.warnings.iter().any(|w| w == FAR_AWAY), "{:?}", far.warnings);
|
||||
assert_eq!(leaves(&far.document).len(), 1, "the line in range is kept");
|
||||
|
||||
// Past the budget for path points, the rest is left out with a warning; pen-up moves, which
|
||||
// make no path, don't count.
|
||||
let many = format!("IN;SP1;PR;PD{}", "1,0,".repeat(300_000));
|
||||
let i = plot(&many);
|
||||
assert!(i.warnings.iter().any(|w| w == TOO_MANY_POINTS), "{:?}", i.warnings);
|
||||
// Scaling points past any range give relative pen widths no wider than the widest pen.
|
||||
let i = plot("IN;IP-99999999999999999999,0,99999999999999999999,0;WU1;PW32767;SP1;PD100,0;");
|
||||
let r = i.document.artboards[0].rect;
|
||||
assert!(r.width().is_finite() && r.height() <= 16384.0 * 72.0 / 1016.0 + 1e-6, "{r:?}");
|
||||
assert!((stroke(&leaves(&i.document)[0]).width - 16384.0 * 72.0 / 1016.0).abs() < 1e-9);
|
||||
|
||||
// Each fill of the polygon buffer is a copy of it, and counts.
|
||||
let big = format!("IN;SP1;PM0;PR;PD{}PM2;{}", "1,1,".repeat(1000), "FP;EP;".repeat(1000));
|
||||
let i = plot(&big);
|
||||
assert!(i.warnings.iter().any(|w| w == TOO_MANY_POINTS) && leaves(&i.document).len() < 400, "{:?}", i.warnings);
|
||||
for pen in ["SP1;PU", "SP0;PD"] {
|
||||
let moves = format!("IN;{pen}{};SP1;PU0,0;PD1016,0;", "1,0,".repeat(300_000));
|
||||
let i = plot(&moves);
|
||||
assert!(i.warnings.is_empty() && leaves(&i.document).len() == 1, "{pen}: {:?}", i.warnings);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader.
|
||||
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader, and an
|
||||
//! HP-GL/2 plot file reader.
|
||||
//!
|
||||
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
|
||||
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
|
||||
@@ -11,13 +12,16 @@
|
||||
//! clipping) is approximated or left out, and each such loss comes back as a warning.
|
||||
//!
|
||||
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
|
||||
//! [`hpgl::import`] reads an HP-GL plotter file: see [`hpgl`].
|
||||
//!
|
||||
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
|
||||
//! - `writer`: group codes, handles and the file's sections, tables and objects.
|
||||
//! - `scene`: the document walk that turns objects into entities.
|
||||
//! - `import`: the reader, entities to art, blocks to symbols.
|
||||
//! - `hpgl`: the HP-GL reader, the pen's moves to paths.
|
||||
|
||||
mod aci;
|
||||
pub mod hpgl;
|
||||
pub mod import;
|
||||
mod scene;
|
||||
mod writer;
|
||||
|
||||
@@ -26,11 +26,16 @@ impl WidthProfile {
|
||||
pub fn at(&self, t: f64) -> (f64, f64) {
|
||||
Self::at_points(&self.points, t)
|
||||
}
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's). Each side runs through
|
||||
/// its points on a monotone cubic curve (Fritsch–Carlson): it flows through a width point
|
||||
/// without a kink, turns flat at a point wider or narrower than both neighbours, and never
|
||||
/// goes past either point of the span it is in (#5714). Points on one straight line stay on
|
||||
/// it. A step (two points at one position, [`Self::around`]) ends the curve on each side of it.
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
|
||||
///
|
||||
/// Each side runs through its points on a cubic Hermite curve, its slope at each one the
|
||||
/// weighted harmonic mean of the secants either side (Fritsch–Carlson). That is C1 continuous
|
||||
/// and monotone between points, so it never overshoots them, a point eases into its neighbours
|
||||
/// instead of making a corner, and moving one reshapes the curve as far as its neighbours'
|
||||
/// neighbours (their slopes depend on it). The ends of a run use the one-sided PCHIP slope
|
||||
/// from its first two secants, or the secant when it has only two points, so a two-point
|
||||
/// profile stays a straight taper and the lens is a parabola. Two points at one `t` are still
|
||||
/// a step: each run either side ends independently at it.
|
||||
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
|
||||
if p.is_empty() {
|
||||
return (1.0, 1.0);
|
||||
@@ -71,9 +76,10 @@ impl WidthProfile {
|
||||
// Two swells between narrow necks.
|
||||
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
|
||||
];
|
||||
/// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. Each
|
||||
/// side stays between the widths of the two points of each span ([`Self::at_points`]), but the
|
||||
/// two sides needn't peak together, so a span counts its wider left and its wider right.
|
||||
/// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points.
|
||||
/// Each side's smooth curve stays between the widths of the two points of each span
|
||||
/// ([`Self::at_points`]), but the two sides needn't peak together, so a span counts its wider
|
||||
/// left and its wider right.
|
||||
pub fn widest(&self) -> f64 {
|
||||
let p = &self.points;
|
||||
if p.is_empty() {
|
||||
@@ -249,11 +255,27 @@ impl AngleProfile {
|
||||
/// How close two width points' positions are to make a step ([`WidthProfile::around`]).
|
||||
const STEP: f64 = 1e-7;
|
||||
|
||||
/// The one-sided three-point PCHIP slope at the start of a run, from the first two secants.
|
||||
/// A sign change caps the slope, and a slope that turns back on the first secant is flattened.
|
||||
/// Runs of only two points use their secant instead (see [`tangent`]).
|
||||
fn end_slope(h0: f64, h1: f64, d0: f64, d1: f64) -> f64 {
|
||||
let m = ((2.0 * h0 + h1) * d0 - h0 * d1) / (h0 + h1);
|
||||
if !m.is_finite() || m * d0 <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if d0 * d1 < 0.0 {
|
||||
return if d0 > 0.0 { m.min(3.0 * d0) } else { m.max(3.0 * d0) };
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The slope of one side (`v` reads it from a point) of width profile `p` at its point `j`, in
|
||||
/// width per unit of position: Fritsch–Carlson's choice for a monotone cubic curve. It is 0 where
|
||||
/// the point is wider or narrower than both neighbours, the weighted harmonic mean of the slopes
|
||||
/// of the spans on either side elsewhere (never more than three times either, so no span
|
||||
/// overshoots its points), and the span's own slope at an end of the profile or beside a step.
|
||||
/// the point is wider or narrower than both neighbours, and the weighted harmonic mean of the
|
||||
/// slopes of the spans on either side elsewhere (never more than three times either, so no span
|
||||
/// overshoots its points). The ends of a run take the one-sided three-point PCHIP slope, or their
|
||||
/// secant when the run has only two points; a step ends the run on each side, so the points either
|
||||
/// side of it do not see across it.
|
||||
fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f64 {
|
||||
let Some(q) = p.get(j) else { return 0.0 };
|
||||
let before = j.checked_sub(1).and_then(|k| p.get(k)).filter(|a| q.0 - a.0 > STEP);
|
||||
@@ -268,8 +290,18 @@ fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f
|
||||
let (h0, h1) = (q.0 - a.0, b.0 - q.0);
|
||||
3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1)
|
||||
}
|
||||
(Some(a), None) => slope(a, q),
|
||||
(None, Some(b)) => slope(q, b),
|
||||
// The end of a run: its previous two points set a one-sided three-point slope, or its
|
||||
// secant when the run has only two points (the point before `a` is on the other side of a
|
||||
// step, or does not exist).
|
||||
(Some(a), None) => {
|
||||
let Some(prev) = j.checked_sub(2).and_then(|k| p.get(k)).filter(|z| a.0 - z.0 > STEP) else { return slope(a, q) };
|
||||
end_slope(q.0 - a.0, a.0 - prev.0, slope(a, q), slope(prev, a))
|
||||
}
|
||||
// The start of a run, symmetrically.
|
||||
(None, Some(b)) => {
|
||||
let Some(next) = p.get(j + 2).filter(|z| z.0 - b.0 > STEP) else { return slope(q, b) };
|
||||
end_slope(b.0 - q.0, next.0 - b.0, slope(q, b), slope(b, next))
|
||||
}
|
||||
(None, None) => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -424,12 +456,12 @@ mod tests {
|
||||
#[test]
|
||||
fn widths_run_smoothly_through_width_points() {
|
||||
// A swell in the middle: flat at its widest, and a quarter of the way along above the
|
||||
// straight ramp from 1 to 3 (2).
|
||||
// straight ramp from 1 to 3 (2) because the end eases out of the first point.
|
||||
let swell = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 2.0), (1.0, 1.0, 1.0)] };
|
||||
let (before, after) = slopes(&swell, 0.5);
|
||||
assert!(before.abs() < 1e-4 && after.abs() < 1e-4, "{before} {after}");
|
||||
let (l, r) = swell.at(0.25);
|
||||
assert!((l - 2.25).abs() < 1e-12 && (r - 1.625).abs() < 1e-12, "{l} {r}");
|
||||
assert!((l - 2.5).abs() < 1e-12 && (r - 1.75).abs() < 1e-12, "{l} {r}");
|
||||
// Rising on both sides of a point: the slope is the same either side of it.
|
||||
let rise = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.3, 1.0, 1.0), (1.0, 4.0, 4.0)] };
|
||||
let (before, after) = slopes(&rise, 0.3);
|
||||
|
||||
@@ -197,12 +197,85 @@ fn strokes_without_pressure_round_trip_unchanged() {
|
||||
fn profile_interp() {
|
||||
let p = WidthProfile::lens();
|
||||
assert_eq!(p.at(0.5), (1.0, 1.0));
|
||||
// Half way up the lens the width curves above the straight ramp (0.5), flattening into its
|
||||
// widest point (#5714).
|
||||
assert_eq!(p.at(0.25), (0.625, 0.625));
|
||||
// The lens is a parabola between its ends, so a quarter of the way along is 0.75: above the
|
||||
// 0.5 a straight taper would give.
|
||||
assert_eq!(p.at(0.25), (0.75, 0.75));
|
||||
assert_eq!(p.at(2.0), (0.0, 0.0));
|
||||
}
|
||||
|
||||
/// The width runs smoothly between points: it passes through them, has one slope either side
|
||||
/// of one, and never overshoots the widths its points set.
|
||||
#[test]
|
||||
fn profile_is_smooth_between_points() {
|
||||
let p = [(0.0, 1.0, 2.0), (0.5, 0.2, 2.0), (1.0, 1.0, 2.0)];
|
||||
assert!((WidthProfile::at_points(&p, 0.5).0 - 0.2).abs() < 1e-12);
|
||||
let slope = |t: f64| (WidthProfile::at_points(&p, t + 1e-6).0 - WidthProfile::at_points(&p, t - 1e-6).0) / 2e-6;
|
||||
// A local minimum at the middle point is flat there, so the two sides agree.
|
||||
assert!(slope(0.5).abs() < 1e-3, "{}", slope(0.5));
|
||||
for k in 0..=100 {
|
||||
let w = WidthProfile::at_points(&p, k as f64 / 100.0).0;
|
||||
assert!((0.2..=1.0).contains(&w), "{w} at {k}");
|
||||
}
|
||||
// The side with no points of its own stays flat.
|
||||
for k in 0..=100 {
|
||||
assert!((WidthProfile::at_points(&p, k as f64 / 100.0).1 - 2.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
/// A run with only two points takes its secant as both end slopes, so it stays a straight
|
||||
/// taper on each side instead of becoming a smoothstep.
|
||||
#[test]
|
||||
fn profile_two_point_runs_are_linear() {
|
||||
let p = [(0.0, 0.0, 1.0), (1.0, 1.0, 0.0)];
|
||||
for k in 0..=100 {
|
||||
let t = k as f64 / 100.0;
|
||||
let (l, r) = WidthProfile::at_points(&p, t);
|
||||
assert!((l - t).abs() < 1e-12, "left {l} at {t}");
|
||||
assert!((r - (1.0 - t)).abs() < 1e-12, "right {r} at {t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The lens's one-sided end slopes make it a parabola: at a quarter of the way it is 0.75,
|
||||
/// above the 0.5 a straight line between its points would give.
|
||||
#[test]
|
||||
fn profile_lens_is_convex() {
|
||||
let p = WidthProfile::lens();
|
||||
assert!((p.at(0.25).0 - 0.75).abs() < 1e-12);
|
||||
assert!((p.at(0.75).0 - 0.75).abs() < 1e-12);
|
||||
for k in 1..25 {
|
||||
let t = k as f64 / 100.0;
|
||||
assert!(p.at(t).0 > 2.0 * t, "{} at {t}", p.at(t).0);
|
||||
assert!(p.at(1.0 - t).0 > 2.0 * t, "{} at {}", p.at(1.0 - t).0, 1.0 - t);
|
||||
}
|
||||
}
|
||||
|
||||
/// A step still steps, and the smooth runs either side meet it flat.
|
||||
#[test]
|
||||
fn profile_steps_stay_steps() {
|
||||
let p = [(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 2.0, 2.0)];
|
||||
assert_eq!(WidthProfile::at_points(&p, 0.5), (1.0, 1.0));
|
||||
assert_eq!(WidthProfile::at_points(&p, 0.5 + 1e-6), (2.0, 2.0));
|
||||
assert_eq!(WidthProfile::at_points(&p, 0.5 - 1e-6), (1.0, 1.0));
|
||||
assert_eq!(WidthProfile::at_points(&p, 0.25), (1.0, 1.0));
|
||||
assert_eq!(WidthProfile::at_points(&p, 0.75), (2.0, 2.0));
|
||||
}
|
||||
|
||||
/// Moving a point only reshapes the segments beside it (local influence), not the whole path.
|
||||
#[test]
|
||||
fn profile_points_only_affect_the_curve_around_them() {
|
||||
// A monotone run, so no neighbouring slope is held flat by a local extremum.
|
||||
let a = [(0.0, 0.0, 0.0), (0.25, 0.3, 0.3), (0.5, 0.5, 0.5), (0.75, 0.7, 0.7), (1.0, 1.0, 1.0)];
|
||||
let mut b = a;
|
||||
b[3] = (0.75, 0.9, 0.9);
|
||||
// Moving p[3] changes the slopes at p[2] and p[3], so p[1] and p[4] also shape the
|
||||
// curve: nothing before p[1] may move.
|
||||
for k in 0..=24 {
|
||||
let t = k as f64 / 100.0;
|
||||
assert_eq!(WidthProfile::at_points(&a, t), WidthProfile::at_points(&b, t), "at {t}");
|
||||
}
|
||||
assert_ne!(WidthProfile::at_points(&a, 0.3), WidthProfile::at_points(&b, 0.3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_presets_round_trip_their_ids() {
|
||||
for p in WidthProfile::PRESETS {
|
||||
|
||||
+19
-10
@@ -80,10 +80,8 @@ fn head_ends(st: &StrokeLayer, bp: &BezPath) -> Vec<(Point, Vec2, bool)> {
|
||||
/// at `t`): the profile at that fraction of the subpath's length, or wider where an end of the
|
||||
/// segment is a corner. The stroke draws the sides there straight to the corner's join and inner
|
||||
/// meeting point, which can lie outside the exact width all along the segment.
|
||||
fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, t: f64) -> (f64, f64) {
|
||||
fn profile_at(profile: &WidthProfile, segs: &[PathSeg], lens: &[f64], total: f64, closed: bool, i: usize, t: f64) -> (f64, f64) {
|
||||
let n = segs.len();
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
|
||||
let total: f64 = lens.iter().sum::<f64>().max(1e-12);
|
||||
let before = lens[..i].iter().sum::<f64>();
|
||||
let mut w = profile.at((before + segs[i].subsegment(0.0..t).arclen(ARCLEN_ACCURACY)) / total);
|
||||
let corner = |a: usize, b: usize| (closed || a < b) && kink(&segs[a], &segs[b]);
|
||||
@@ -306,7 +304,8 @@ impl StrokeLayer {
|
||||
|
||||
/// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and
|
||||
/// outside strokes of closed paths count on their own side only (and within `tol` of the
|
||||
/// path); a width profile counts at its width where `p` is nearest.
|
||||
/// path); a width profile counts at the width of every segment that can reach `p`, as the
|
||||
/// wider side of a farther one can paint past the nearest.
|
||||
pub fn hits(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
|
||||
let reach = self.reach() + tol;
|
||||
if self.width.is_nan() || self.width <= 0.0 || !bp.bounding_box().inflate(reach, reach).contains(p) {
|
||||
@@ -330,17 +329,27 @@ impl StrokeLayer {
|
||||
let segs = drawn(&els[r]);
|
||||
let (half, region) = self.body(closed);
|
||||
let full = half * self.profile_max() * dash_caps.unwrap_or(1.0);
|
||||
// The nearest point on this subpath.
|
||||
let near = segs.iter().enumerate().map(|(i, s)| (i, s.nearest(p, 1e-9))).min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq));
|
||||
if let Some((i, nr)) = near
|
||||
&& nr.distance_sq.sqrt() <= full + tol
|
||||
{
|
||||
// Every segment near enough to reach `p`: with a width profile the wider side of a
|
||||
// farther segment can paint past the nearest one, so the nearest alone is not enough.
|
||||
let near = segs.iter().enumerate().filter_map(|(i, s)| {
|
||||
let nr = s.nearest(p, 1e-9);
|
||||
(nr.distance_sq.sqrt() <= full + tol).then_some((i, nr))
|
||||
});
|
||||
// The lengths are only needed when a profiled candidate reaches `p`; compute them
|
||||
// once for the whole subpath rather than once per candidate.
|
||||
let mut lengths: Option<(Vec<f64>, f64)> = None;
|
||||
for (i, nr) in near {
|
||||
let d = nr.distance_sq.sqrt();
|
||||
let factor = if dash_caps.is_some() {
|
||||
full / half.max(1e-12)
|
||||
} else {
|
||||
self.profile.as_ref().map_or(1.0, |pr| {
|
||||
let (l, rt) = profile_at(pr, &segs, closed, i, nr.t);
|
||||
let (lens, total) = lengths.get_or_insert_with(|| {
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
|
||||
let total = lens.iter().sum::<f64>().max(1e-12);
|
||||
(lens, total)
|
||||
});
|
||||
let (l, rt) = profile_at(pr, &segs, lens, *total, closed, i, nr.t);
|
||||
// Nearest to a segment's end, `p` is beside a join or past a cap, which
|
||||
// spans the widths of both sides.
|
||||
let at_end = nr.t <= 1e-9 || nr.t >= 1.0 - 1e-9;
|
||||
|
||||
@@ -173,6 +173,18 @@ fn bar(hw: f64) -> f64 {
|
||||
/// Where the notch of the (open) arrow sits, as a fraction of the length.
|
||||
const NOTCH: f64 = 0.7;
|
||||
|
||||
/// How far [`super::stroke_pieces`] trims the line under each end's head: the head's inset from
|
||||
/// the tip, along the path. Both are 0 without a head at that end, and in [`ArrowAlign::Extend`],
|
||||
/// where the stroke keeps its full length.
|
||||
pub(super) fn trims(st: &StrokeLayer) -> (f64, f64) {
|
||||
if st.arrow_align != ArrowAlign::Tip {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let cap = cap_extent(st.cap, st.width);
|
||||
let inset_of = |kind: Option<Arrowhead>, end: bool| kind.map_or(0.0, |k| inset(k, st.arrow_weight(end), cap));
|
||||
(inset_of(st.start_arrow, false), inset_of(st.end_arrow, true))
|
||||
}
|
||||
|
||||
/// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end.
|
||||
fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
|
||||
let l = len(hw);
|
||||
|
||||
@@ -18,11 +18,11 @@ use std::collections::VecDeque;
|
||||
use kurbo::{BezPath, Point, Shape, Vec2};
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
|
||||
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP};
|
||||
use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer};
|
||||
use vectorcraft_doc::{GradientMesh, LineCap, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer};
|
||||
use vectorcraft_geom::FillRule;
|
||||
|
||||
use super::width::{flatten, left, length, split_at};
|
||||
use super::{aligned_width, cap_extent, is_closed, segments, subpaths, unit};
|
||||
use super::{aligned_width, arrow, cap_extent, is_closed, segments, subpaths, unit};
|
||||
|
||||
/// Largest stretch of a rib on a turn's bisector (1 / cos of half the turn, about 74°) before the
|
||||
/// corner gets a fan of ribs round its outside.
|
||||
@@ -97,7 +97,10 @@ impl Lay<'_> {
|
||||
|
||||
/// How far a rib at fraction `t` reaches either side of the path to cover the stroke there.
|
||||
fn cover(&self, t: f64) -> f64 {
|
||||
self.half(t) + self.margin
|
||||
// A square cap's corners stand √2 of the half-width out from where the dash ends, and a
|
||||
// dash can end just past a corner: reach that far, or they paint outside every slice.
|
||||
let diag = if self.st.dash.is_some() && self.st.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
|
||||
self.half(t) * diag + self.margin
|
||||
}
|
||||
|
||||
/// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`:
|
||||
@@ -162,6 +165,9 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
return vec![];
|
||||
}
|
||||
let heads = (st.head_reach(false), st.head_reach(true));
|
||||
// With a tip-aligned arrowhead the stroke's line, and with it the width profile, stops at the
|
||||
// head's inset from the tip (`stroke_pieces`).
|
||||
let trims = arrow::trims(st);
|
||||
let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) };
|
||||
// Caps reach as far past the ends as the stroke is wide there.
|
||||
let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t));
|
||||
@@ -170,27 +176,34 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
let count = subs.len();
|
||||
let mut out = vec![];
|
||||
for (k, (range, closed)) in subs.into_iter().enumerate() {
|
||||
let (pts, corner) = flatten(&segments(&bp.elements()[range]), closed, tol.max(1e-4));
|
||||
let side =
|
||||
(st.align != StrokeAlign::Center && closed_path && closed).then(|| band_on_left(bp, rule, &pts, st.align, (4.0 * tol).min(hw / 2.0)));
|
||||
let segs = segments(&bp.elements()[range]);
|
||||
let flat = flatten(&segs, closed, tol.max(1e-4));
|
||||
let side = (st.align != StrokeAlign::Center && closed_path && closed)
|
||||
.then(|| band_on_left(bp, rule, &flat.pts, st.align, (4.0 * tol).min(hw / 2.0)));
|
||||
// Arrowheads sit on the start of the first subpath and the end of the last one.
|
||||
let heads = (if k == 0 { heads.0 } else { 0.0 }, if k + 1 == count { heads.1 } else { 0.0 });
|
||||
let ribs = match pts.len() {
|
||||
let trims = (if k == 0 { trims.0 } else { 0.0 }, if k + 1 == count { trims.1 } else { 0.0 });
|
||||
let ribs = match flat.pts.len() {
|
||||
0 => continue,
|
||||
// A subpath of no length: a dot (round and projecting caps paint it).
|
||||
1 => {
|
||||
let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1));
|
||||
let r = lay.cover(0.0);
|
||||
vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)]
|
||||
vec![Rib::square(flat.pts[0] - d * e, d, r, 0.0, b), Rib::square(flat.pts[0] + d * e, d, r, 0.0, b)]
|
||||
}
|
||||
_ => {
|
||||
let total = length(&pts, closed);
|
||||
let total = length(&flat.pts, closed);
|
||||
if total <= 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let steps = if max_dt < 1.0 { (1.0 / max_dt).ceil() as usize } else { 1 };
|
||||
let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0));
|
||||
let heads = if closed { (0.0, 0.0) } else { heads };
|
||||
// A tip-aligned arrowhead shortens the line it sits on, and the stroke's outline
|
||||
// reads the width profile along the shortened line (`stroke_pieces`); the ribs
|
||||
// must read it there too, or on a profile that changes fast they cover a narrower
|
||||
// band than the stroke paints.
|
||||
let span = if closed { (0.0, total) } else { (trims.0.min(total), (total - trims.1).max(trims.0)) };
|
||||
// Where an arrowhead's reach ends gets a rib of its own, so the ribs that reach
|
||||
// across a head run all the way there instead of narrowing towards the next corner
|
||||
// from the head's tip, which leaves the corners of its base outside (#5964).
|
||||
@@ -200,11 +213,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
.copied()
|
||||
.chain(profile)
|
||||
.chain((1..steps).map(|i| i as f64 / steps as f64))
|
||||
.map(|t| t * total)
|
||||
.map(|t| span.0 + (span.1 - span.0) * t)
|
||||
.chain(head_ends)
|
||||
.collect();
|
||||
let (pts, _) = split_at(&pts, &corner, closed, &at);
|
||||
strip(&lay, &pts, closed, total, side, heads)
|
||||
let (pts, _, _) = split_at(&flat, closed, &at, &segs);
|
||||
strip(&lay, &pts, closed, total, side, heads, span)
|
||||
}
|
||||
};
|
||||
out.push(ribs);
|
||||
@@ -226,8 +239,9 @@ fn band_on_left(bp: &BezPath, rule: FillRule, pts: &[Point], align: StrokeAlign,
|
||||
}
|
||||
|
||||
/// The ribs along one flattened subpath `pts` of length `total` (`heads`: how far arrowheads
|
||||
/// reach from its start and end).
|
||||
fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>, heads: (f64, f64)) -> Vec<Rib> {
|
||||
/// reach from its start and end). `span` is the stretch of the subpath the width profile is laid
|
||||
/// along: the whole of it, or what is left between tip-aligned arrowheads.
|
||||
fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>, heads: (f64, f64), span: (f64, f64)) -> Vec<Rib> {
|
||||
let n = pts.len();
|
||||
let seg_count = if closed { n } else { n - 1 };
|
||||
let dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
|
||||
@@ -249,7 +263,9 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
|
||||
// stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part
|
||||
// reaching that far would paint over a wide part beside it (#5960). The start of a closed
|
||||
// subpath is also its end. Ribs near an arrowhead reach across it.
|
||||
let cover: Vec<f64> = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect();
|
||||
// The fraction of the profile at how far `s` is along the subpath: the profile spans `span`.
|
||||
let t_at = |s: f64| if span.1 > span.0 { ((s - span.0) / (span.1 - span.0)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let cover: Vec<f64> = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(t_at(along[i])) }).collect();
|
||||
let window = match lay.st.dash {
|
||||
Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()),
|
||||
_ => 0.0,
|
||||
@@ -279,7 +295,7 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
|
||||
let (first, last) = if closed { (0, n) } else { (1, n - 1) };
|
||||
for i in first..last {
|
||||
let v = i % n;
|
||||
let t = along[v] / total;
|
||||
let t = t_at(along[v]);
|
||||
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side));
|
||||
// A closed subpath starts after the fan of its first corner and ends with it.
|
||||
let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 };
|
||||
|
||||
@@ -232,16 +232,14 @@ fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
|
||||
let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3);
|
||||
assert!(near(plain.area().abs(), 1000.0, 1e-6));
|
||||
let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3);
|
||||
// The lens swells on a cubic, flat at the middle: 7/12 of the full-width band (#5714).
|
||||
assert!(near(lens.area().abs(), 1000.0 * 7.0 / 12.0, 0.05));
|
||||
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0). Half of the lens
|
||||
// rises as w(u) = u + u² − u³ (u from 0 to 1 across that half): the dash over 0..25 covers
|
||||
// its first half, ∫₀^½ w = 29/192, the one over 50..75 the wider other half, 83/192.
|
||||
// The lens is a parabola between its ends: 2/3 of the full-width band.
|
||||
assert!(near(lens.area().abs(), 2000.0 / 3.0, 0.1));
|
||||
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0).
|
||||
let st = stroke(10.0, |s| {
|
||||
s.profile = Some(WidthProfile::lens());
|
||||
s.dash = Some(dashed(&[25.0, 25.0], 0.0));
|
||||
});
|
||||
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0 * (29.0 + 83.0) / 192.0, 0.05));
|
||||
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 1000.0 / 3.0, 0.1));
|
||||
assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -155,9 +155,10 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() {
|
||||
s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false });
|
||||
});
|
||||
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
|
||||
// The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip.
|
||||
// The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip,
|
||||
// where the smooth profile is thinner than a straight ramp (a parabola's first tenth).
|
||||
assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide");
|
||||
assert!(!o.contains(Point::new(5.0, 2.0)) && o.contains(Point::new(5.0, 0.3)), "first dash is thin");
|
||||
assert!(o.contains(Point::new(5.0, 0.8)) && !o.contains(Point::new(5.0, 1.2)), "first dash is thin");
|
||||
assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty");
|
||||
// Dots too: a round dot near the middle is bigger than one near an end.
|
||||
let st = stroke(10.0, |s| {
|
||||
@@ -166,7 +167,7 @@ fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() {
|
||||
s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false });
|
||||
});
|
||||
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
|
||||
assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.5)));
|
||||
assert!(o.contains(Point::new(50.0, 4.5)) && o.contains(Point::new(10.0, 1.4)) && !o.contains(Point::new(10.0, 2.0)));
|
||||
}
|
||||
|
||||
/// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0).
|
||||
|
||||
@@ -138,9 +138,8 @@ fn across_runs_from_the_left_edge_to_the_right() {
|
||||
let mut lens = st.clone();
|
||||
lens.profile = Some(WidthProfile::lens());
|
||||
let sl = slices(&line, &lens);
|
||||
// A quarter of the way along, the lens is 0.625 as wide (its curve, #5714): its top edge is
|
||||
// 3.125 up.
|
||||
assert!(param(&sl, Point::new(25.0, -3.0)).unwrap() < 0.05);
|
||||
// A quarter of the way along, the lens is three quarters as wide: its top edge is 3.75 up.
|
||||
assert!(param(&sl, Point::new(25.0, -3.6)).unwrap() < 0.05);
|
||||
assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02);
|
||||
}
|
||||
|
||||
|
||||
+789
-187
File diff suppressed because it is too large
Load Diff
@@ -749,6 +749,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap<
|
||||
/// The most presets one presets file imports: each is looked up among the saved ones.
|
||||
pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000;
|
||||
|
||||
pub(crate) use addons::DecodedImages;
|
||||
pub use docmenu::guide_indexes;
|
||||
pub(crate) use edit::{settle_interaction, typing_in_progress};
|
||||
pub(crate) use object::bounds_rewrite;
|
||||
|
||||
@@ -6,12 +6,15 @@ mod cleanup;
|
||||
mod color;
|
||||
mod contour;
|
||||
mod document;
|
||||
mod image;
|
||||
mod pathtools;
|
||||
mod pen;
|
||||
mod select;
|
||||
mod shapes;
|
||||
mod typetools;
|
||||
|
||||
pub(crate) use image::DecodedImages;
|
||||
|
||||
use super::CommandSpec;
|
||||
|
||||
/// The commands of every built-in add-on.
|
||||
@@ -21,6 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
v.extend(color::specs());
|
||||
v.extend(contour::specs());
|
||||
v.extend(document::specs());
|
||||
v.extend(image::specs());
|
||||
v.extend(pathtools::specs());
|
||||
v.extend(pen::specs());
|
||||
v.extend(select::specs());
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
//! Image Tools (`addon.image.*`), a built-in add-on. Remove Background makes the background of the
|
||||
//! selected images transparent in their own pixels, as one undo step. The background is the most
|
||||
//! common color along an image's edges, the average of its four corners, or colors the caller
|
||||
//! picks. It is cleared where it reaches in from an edge, or wherever it is. The work is done here,
|
||||
//! on the pixels; nothing is sent anywhere. It is a VectorCraft addition. Its submenu (Object ›
|
||||
//! Plug-ins › Image Tools) and dialog are the UI's.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use image::{DynamicImage, ImageBuffer, LumaA, Rgba};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{ImageBlob, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use super::super::fileio::{MAX_RASTER_ALLOC, ppi};
|
||||
use super::super::menucmds::has_image;
|
||||
use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param};
|
||||
|
||||
/// Remove Background's command.
|
||||
pub const REMOVE_BACKGROUND: &str = "addon.image.removeBackground";
|
||||
const C: &str = REMOVE_BACKGROUND;
|
||||
|
||||
/// The most colors `colors` lists.
|
||||
const MAX_COLORS: usize = 16;
|
||||
|
||||
/// Bytes a pixel takes while it is worked on, beside its pixels: its level, whether the edge search
|
||||
/// reached it, and its place in the search's stack (4 bytes, twice that while the stack grows).
|
||||
const WORK_BYTES: u64 = 10;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"addon.image.removeBackground",
|
||||
"Remove Background…",
|
||||
["Object", "Plug-ins", "Image Tools"],
|
||||
None,
|
||||
"{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}",
|
||||
has_image,
|
||||
remove_background
|
||||
)]
|
||||
}
|
||||
|
||||
/// Where the background's colors come from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Background {
|
||||
/// The most common color along the image's edges.
|
||||
Edges,
|
||||
/// The average of the image's four corner pixels that are not clear.
|
||||
Corners,
|
||||
/// These colors.
|
||||
Colors(Vec<[u8; 3]>),
|
||||
}
|
||||
|
||||
/// How near a color is to a background color.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Match {
|
||||
/// The RGB distance, as a percentage of the largest one (black to white).
|
||||
Distance,
|
||||
/// The largest difference on one of red, green and blue, as a percentage of a channel's range.
|
||||
Channels,
|
||||
}
|
||||
|
||||
/// What Remove Background clears: the background, whether only where it reaches in from an edge,
|
||||
/// and how near a color must be to it (a percentage, measured as `measure` says) to be cleared, and
|
||||
/// to fade out past that.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Settings {
|
||||
pub background: Background,
|
||||
pub contiguous: bool,
|
||||
pub measure: Match,
|
||||
pub tolerance: f64,
|
||||
pub softness: f64,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The settings `p` gives, each checked.
|
||||
fn read(p: &Value) -> Result<Self> {
|
||||
let percent = |key: &str| -> Result<f64> {
|
||||
let v = number_or(p, key, 10.0, C)?;
|
||||
if v.is_finite() && (0.0..=100.0).contains(&v) { Ok(v) } else { Err(bad(C, format!("`{key}` must be from 0 to 100"))) }
|
||||
};
|
||||
let (tolerance, softness) = (percent("tolerance")?, percent("softness")?);
|
||||
let colors = match p.get("colors") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(colors(v)?),
|
||||
};
|
||||
let background = match text_param(p, "background", C)? {
|
||||
None | Some("edges") => Background::Edges,
|
||||
Some("corners") => Background::Corners,
|
||||
Some("colors") => Background::Colors(colors.ok_or_else(colors_error)?),
|
||||
Some(_) => return Err(bad(C, "`background` must be edges, corners or colors")),
|
||||
};
|
||||
let measure = match text_param(p, "match", C)? {
|
||||
None | Some("distance") => Match::Distance,
|
||||
Some("channels") => Match::Channels,
|
||||
Some(_) => return Err(bad(C, "`match` must be distance or channels")),
|
||||
};
|
||||
let contiguous = switch_or(p, "contiguous", true, C)?;
|
||||
Ok(Self { background, contiguous, measure, tolerance, softness })
|
||||
}
|
||||
}
|
||||
|
||||
fn colors_error() -> crate::EngineError {
|
||||
bad(C, "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue")
|
||||
}
|
||||
|
||||
/// The colors `v` lists: an array of strings, or one string of them split by commas or spaces.
|
||||
/// Each is read as [`Color::from_hex`] reads it; one with an alpha part is refused.
|
||||
fn colors(v: &Value) -> Result<Vec<[u8; 3]>> {
|
||||
// One past the most is enough to tell a list that is too long.
|
||||
let texts: Vec<&str> = match v {
|
||||
Value::String(s) => s.split([',', ' ', '\t', '\n']).filter(|t| !t.is_empty()).take(MAX_COLORS + 1).collect(),
|
||||
Value::Array(a) => a.iter().take(MAX_COLORS + 1).map(Value::as_str).collect::<Option<_>>().ok_or_else(colors_error)?,
|
||||
_ => return Err(colors_error()),
|
||||
};
|
||||
if texts.is_empty() || texts.len() > MAX_COLORS {
|
||||
return Err(colors_error());
|
||||
}
|
||||
texts.into_iter().map(|t| rgb8(t).ok_or_else(colors_error)).collect()
|
||||
}
|
||||
|
||||
/// The 8-bit RGB of the color `t` writes.
|
||||
fn rgb8(t: &str) -> Option<[u8; 3]> {
|
||||
if hex_with_alpha(t) {
|
||||
return None;
|
||||
}
|
||||
let [r, g, b] = Color::from_hex(t)?.to_rgb_uncalibrated();
|
||||
Some([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8))
|
||||
}
|
||||
|
||||
fn remove_background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let settings = Settings::read(p)?;
|
||||
// A preview step keeps the pixels it decodes for the next step; other runs keep none.
|
||||
let mut kept = std::mem::take(&mut s.decoded_images);
|
||||
let previewing = s.previewing;
|
||||
let found = changes(s, p, &settings, previewing.then_some(&mut kept));
|
||||
s.decoded_images = kept;
|
||||
let changes = found?;
|
||||
let ids: Vec<u64> = changes.iter().map(|(id, ..)| id.0).collect();
|
||||
let made_clear: u64 = changes.iter().map(|(_, out, _)| out.cleared).sum();
|
||||
if !changes.is_empty() {
|
||||
s.edit("Remove Background", |d, _| {
|
||||
for (id, out, xf) in changes {
|
||||
let key = out.blob.content_key();
|
||||
d.images.entry(key.clone()).or_insert(out.blob);
|
||||
if let Some(NodeKind::Image(im)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
im.key = key;
|
||||
(im.width, im.height, im.xf) = (out.width, out.height, xf);
|
||||
im.link = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "changed": ids.len(), "ids": ids, "cleared": made_clear }))
|
||||
}
|
||||
|
||||
/// The images `p` names (`ids`, or the selected images) whose pixels change, each with its pixels
|
||||
/// cleared as `settings` say and the transform that keeps its place and size. Decoded pixels are
|
||||
/// taken from and kept in `kept` when it is given.
|
||||
fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result<Vec<(NodeId, Cleared, Affine)>> {
|
||||
let st = s.doc()?;
|
||||
let ids: Vec<NodeId> = if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
// Each image once, however often `ids` names it.
|
||||
let mut given = HashSet::new();
|
||||
let ids: Vec<NodeId> = checked_ids_param(s, p, "ids", C)?.into_iter().filter(|id| given.insert(*id)).collect();
|
||||
if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Image(_)))) {
|
||||
return Err(bad(C, format!("object {} is not an image", id.0)));
|
||||
}
|
||||
ids
|
||||
} else {
|
||||
st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))).collect()
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select an image"));
|
||||
}
|
||||
// Every image is checked before any is decoded.
|
||||
let mut images = vec![];
|
||||
for id in ids {
|
||||
let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue };
|
||||
let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?;
|
||||
// The preview stands in for a file that hasn't been read, or that is kept at its preview.
|
||||
if im.link.is_some() && blob.is_proxy() {
|
||||
return Err(bad(C, "the linked image shows its preview: embed or relink it first"));
|
||||
}
|
||||
if blob.is_cmyk() {
|
||||
return Err(bad(C, "a CMYK image has no transparency to set"));
|
||||
}
|
||||
images.push((id, im, blob));
|
||||
}
|
||||
// Each image's pixels once, however many objects show them.
|
||||
let mut cleared: HashMap<&str, Option<Cleared>> = HashMap::new();
|
||||
let mut changes = vec![];
|
||||
for (id, im, blob) in images {
|
||||
let out = match cleared.get(im.key.as_str()) {
|
||||
Some(out) => out.clone(),
|
||||
None => {
|
||||
let out = clear(blob, settings, kept.as_deref_mut())?;
|
||||
cleared.insert(&im.key, out.clone());
|
||||
out
|
||||
}
|
||||
};
|
||||
if let Some(out) = out {
|
||||
// A file read at another size than the object's keeps the object's place and size.
|
||||
let xf = if (out.width, out.height) == (im.width, im.height) {
|
||||
im.xf
|
||||
} else {
|
||||
im.xf * Affine::scale_non_uniform(f64::from(im.width) / f64::from(out.width), f64::from(im.height) / f64::from(out.height))
|
||||
};
|
||||
changes.push((id, out, xf));
|
||||
}
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// An image with its background cleared: its PNG, its size in pixels, and how many of its pixels
|
||||
/// were made fully clear.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Cleared {
|
||||
pub blob: ImageBlob,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub cleared: u64,
|
||||
}
|
||||
|
||||
/// An image's pixels as straight RGBA, at 8 or 16 bits a sample.
|
||||
#[derive(Clone)]
|
||||
enum Pixels {
|
||||
Eight(image::RgbaImage),
|
||||
Sixteen(ImageBuffer<Rgba<u16>, Vec<u16>>),
|
||||
}
|
||||
|
||||
/// The most bytes of decoded pixels and ICC profiles [`DecodedImages`] keeps (128 MiB, or 32 MiB in
|
||||
/// the web app).
|
||||
const KEEP_BYTES: u64 = MAX_RASTER_ALLOC / 16;
|
||||
|
||||
/// The images Remove Background decoded during an interaction's preview steps, the most recently
|
||||
/// used first, at most [`KEEP_BYTES`] in all. The next preview step runs the command again on the
|
||||
/// same bytes and takes their pixels from here instead of decoding them again. The session holds
|
||||
/// it and empties it when the interaction ends (OK or Cancel). A run that is not a preview step
|
||||
/// does not read or fill it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DecodedImages(Vec<Decoded>);
|
||||
|
||||
/// An image decoded by [`clear`]: the bytes it was decoded from (held weakly, so they are not kept
|
||||
/// alive for it, and an entry whose bytes are gone is dropped), its pixels, whether it was gray,
|
||||
/// and its ICC profile.
|
||||
struct Decoded {
|
||||
source: Weak<Vec<u8>>,
|
||||
pixels: Arc<Pixels>,
|
||||
gray: bool,
|
||||
icc: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times this thread decoded an image in [`clear`].
|
||||
pub(crate) static DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
impl DecodedImages {
|
||||
/// Drops every image kept.
|
||||
pub(crate) fn empty(&mut self) {
|
||||
self.0.clear();
|
||||
}
|
||||
|
||||
/// How many images are kept.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
/// The pixels, gray flag and ICC profile kept for `bytes`, if they are kept. Images whose bytes
|
||||
/// are gone are dropped first.
|
||||
fn get(&mut self, bytes: &Arc<Vec<u8>>) -> Option<(Arc<Pixels>, bool, Option<Vec<u8>>)> {
|
||||
self.0.retain(|d| d.source.strong_count() > 0);
|
||||
let at = self.0.iter().position(|d| std::ptr::eq(d.source.as_ptr(), Arc::as_ptr(bytes)))?;
|
||||
let d = self.0.remove(at);
|
||||
let found = (Arc::clone(&d.pixels), d.gray, d.icc.clone());
|
||||
self.0.insert(0, d);
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// Keeps `pixels` decoded from `bytes` first, in place of anything kept for the same bytes, and
|
||||
/// drops images whose bytes are gone and the least recently used past [`KEEP_BYTES`].
|
||||
fn keep(&mut self, bytes: &Arc<Vec<u8>>, pixels: Arc<Pixels>, gray: bool, icc: Option<Vec<u8>>) {
|
||||
self.0.retain(|d| d.source.strong_count() > 0 && !std::ptr::eq(d.source.as_ptr(), Arc::as_ptr(bytes)));
|
||||
self.0.insert(0, Decoded { source: Arc::downgrade(bytes), pixels, gray, icc });
|
||||
let mut total = 0u64;
|
||||
self.0.retain(|d| {
|
||||
total = total.saturating_add(d.bytes());
|
||||
total <= KEEP_BYTES
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Decoded {
|
||||
/// How many bytes the entry keeps: its pixels and its ICC profile.
|
||||
fn bytes(&self) -> u64 {
|
||||
self.pixels.bytes().saturating_add(self.icc.as_ref().map_or(0, |icc| icc.len() as u64))
|
||||
}
|
||||
}
|
||||
|
||||
impl Pixels {
|
||||
/// How many bytes the pixels take.
|
||||
fn bytes(&self) -> u64 {
|
||||
match self {
|
||||
Self::Eight(img) => img.as_raw().len() as u64,
|
||||
Self::Sixteen(img) => img.as_raw().len() as u64 * 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The width and height in pixels.
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::Eight(img) => img.dimensions(),
|
||||
Self::Sixteen(img) => img.dimensions(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel `i` (row by row from the top left) at 16 bits a sample (an 8-bit sample `v` is
|
||||
/// `v * 257`).
|
||||
fn rgba16(&self, i: usize) -> [u16; 4] {
|
||||
let mut out = [0u16; 4];
|
||||
match self {
|
||||
Self::Eight(img) => {
|
||||
for (o, v) in out.iter_mut().zip(img.as_raw().get(i * 4..i * 4 + 4).unwrap_or_default()) {
|
||||
*o = u16::from(*v) * 257;
|
||||
}
|
||||
}
|
||||
Self::Sixteen(img) => {
|
||||
for (o, v) in out.iter_mut().zip(img.as_raw().get(i * 4..i * 4 + 4).unwrap_or_default()) {
|
||||
*o = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `blob` with its background cleared as `settings` say, or `None` when no pixel changes. A CMYK
|
||||
/// image, one that can't be decoded or is too large to change, and a result with no pixel left are
|
||||
/// errors. The PNG keeps the source's ICC profile and resolution. The decoded pixels are taken
|
||||
/// from and kept in `kept` when it is given.
|
||||
pub(crate) fn clear(blob: &ImageBlob, settings: &Settings, mut kept: Option<&mut DecodedImages>) -> Result<Option<Cleared>> {
|
||||
if blob.is_cmyk() {
|
||||
return Err(bad(C, "a CMYK image has no transparency to set"));
|
||||
}
|
||||
let found = kept.as_deref_mut().and_then(|k| k.get(&blob.bytes));
|
||||
let (source, gray, icc) = match found {
|
||||
Some(found) => found,
|
||||
None => decode(blob, kept)?,
|
||||
};
|
||||
// The pixels kept for the next run stay as they are; ones not kept are worked on in place.
|
||||
let mut px = Arc::try_unwrap(source).unwrap_or_else(|kept| Pixels::clone(&kept));
|
||||
let (w, h) = px.dimensions();
|
||||
let levels = levels(w as usize, h as usize, &|i| px.rgba16(i), settings);
|
||||
let Some(cleared) = apply(&mut px, &levels) else { return Ok(None) };
|
||||
let left = match &px {
|
||||
Pixels::Eight(img) => img.pixels().any(|p| p.0[3] > 0),
|
||||
Pixels::Sixteen(img) => img.pixels().any(|p| p.0[3] > 0),
|
||||
};
|
||||
if !left {
|
||||
return Err(bad(C, "every pixel matches the background: no image would be left"));
|
||||
}
|
||||
let out = match (px, gray) {
|
||||
(Pixels::Eight(img), false) => DynamicImage::ImageRgba8(img),
|
||||
(Pixels::Sixteen(img), false) => DynamicImage::ImageRgba16(img),
|
||||
(Pixels::Eight(img), true) => {
|
||||
DynamicImage::ImageLumaA8(ImageBuffer::from_fn(w, h, |x, y| LumaA([img.get_pixel(x, y).0[0], img.get_pixel(x, y).0[3]])))
|
||||
}
|
||||
(Pixels::Sixteen(img), true) => {
|
||||
DynamicImage::ImageLumaA16(ImageBuffer::from_fn(w, h, |x, y| LumaA([img.get_pixel(x, y).0[0], img.get_pixel(x, y).0[3]])))
|
||||
}
|
||||
};
|
||||
let mut png = Vec::new();
|
||||
let mut encoder = image::codecs::png::PngEncoder::new(&mut png);
|
||||
if let Some(icc) = icc {
|
||||
use image::ImageEncoder as _;
|
||||
encoder.set_icc_profile(icc).map_err(|e| bad(C, e.to_string()))?;
|
||||
}
|
||||
out.write_with_encoder(encoder).map_err(|e| bad(C, e.to_string()))?;
|
||||
// The resolution the file declares, which sizes the image at 100% when it is replaced or relinked.
|
||||
if let Some(r) = ppi::resolution(&blob.bytes) {
|
||||
png = ppi::with_png_resolution(&png, r);
|
||||
}
|
||||
Ok(Some(Cleared { blob: ImageBlob::png(png), width: w, height: h, cleared }))
|
||||
}
|
||||
|
||||
/// `blob`'s pixels as straight RGBA, whether they were gray, and its ICC profile. An image that
|
||||
/// can't be decoded or is too large to change is an error. The pixels are kept in `kept`, when it
|
||||
/// is given, if a copy of them fits the decode allowance beside the work.
|
||||
fn decode(blob: &ImageBlob, kept: Option<&mut DecodedImages>) -> Result<(Arc<Pixels>, bool, Option<Vec<u8>>)> {
|
||||
let reader =
|
||||
|| image::ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().map_err(|_| bad(C, "the image's pixels can't be read"));
|
||||
let mut decoder = reader()?.into_decoder().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
|
||||
// The ICC profile the colors are in goes with them into the PNG.
|
||||
let (w, h, kind, icc) = {
|
||||
use image::ImageDecoder as _;
|
||||
let (w, h) = decoder.dimensions();
|
||||
(w, h, decoder.color_type(), decoder.icc_profile().ok().flatten())
|
||||
};
|
||||
drop(decoder);
|
||||
// The file's pixels, those worked on (as many again for the PNG made of them) and the work,
|
||||
// checked from the header before anything is decoded.
|
||||
let deep = kind.bytes_per_pixel() > kind.channel_count();
|
||||
let per_pixel = u64::from(kind.bytes_per_pixel()) + 2 * if deep { 8 } else { 4 } + WORK_BYTES;
|
||||
if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(per_pixel)).is_none_or(|n| n > MAX_RASTER_ALLOC) {
|
||||
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to change")));
|
||||
}
|
||||
let mut reader = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
|
||||
#[cfg(test)]
|
||||
DECODES.with(|c| c.set(c.get() + 1));
|
||||
let gray = matches!(img.color(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16);
|
||||
let px = Arc::new(if deep { Pixels::Sixteen(img.into_rgba16()) } else { Pixels::Eight(img.into_rgba8()) });
|
||||
// A kept copy is worked on as a copy: one more set of the pixels worked on.
|
||||
let with_copy = per_pixel + if deep { 8 } else { 4 };
|
||||
if let Some(kept) = kept
|
||||
&& u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(with_copy)).is_some_and(|n| n <= MAX_RASTER_ALLOC)
|
||||
&& px.bytes().saturating_add(icc.as_ref().map_or(0, |icc| icc.len() as u64)) <= KEEP_BYTES
|
||||
{
|
||||
kept.keep(&blob.bytes, Arc::clone(&px), gray, icc.clone());
|
||||
}
|
||||
Ok((px, gray, icc))
|
||||
}
|
||||
|
||||
/// Each pixel's alpha scaled by its level (255 keeps it). Returns how many pixels were made fully
|
||||
/// clear, or `None` when no pixel changed.
|
||||
fn apply(px: &mut Pixels, levels: &[u8]) -> Option<u64> {
|
||||
let (mut changed, mut cleared) = (false, 0u64);
|
||||
match px {
|
||||
Pixels::Eight(img) => {
|
||||
for (p, &l) in img.pixels_mut().zip(levels) {
|
||||
let a = ((u32::from(p.0[3]) * u32::from(l) + 127) / 255) as u8;
|
||||
changed |= a != p.0[3];
|
||||
cleared += u64::from(a == 0 && p.0[3] != 0);
|
||||
p.0[3] = a;
|
||||
}
|
||||
}
|
||||
Pixels::Sixteen(img) => {
|
||||
for (p, &l) in img.pixels_mut().zip(levels) {
|
||||
let a = ((u32::from(p.0[3]) * u32::from(l) + 127) / 255) as u16;
|
||||
changed |= a != p.0[3];
|
||||
cleared += u64::from(a == 0 && p.0[3] != 0);
|
||||
p.0[3] = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed.then_some(cleared)
|
||||
}
|
||||
|
||||
/// How much of each of the `w` × `h` pixels `px` gives (row by row, straight RGBA at 16 bits a
|
||||
/// sample) stays, from 0 (cleared) to 255 (kept), as `settings` say. Each pixel's distance to the
|
||||
/// background is worked out once. A pixel past the first `u32::MAX` is left kept by the edge search.
|
||||
pub(crate) fn levels(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4], settings: &Settings) -> Vec<u8> {
|
||||
let n = w.saturating_mul(h);
|
||||
let refs: Vec<[u16; 3]> = match &settings.background {
|
||||
Background::Colors(refs) => refs.iter().map(|c| c.map(|v| u16::from(v) * 257)).collect(),
|
||||
Background::Edges => match edge_color(w, h, px) {
|
||||
Some(edge) => vec![edge],
|
||||
None => return vec![255; n],
|
||||
},
|
||||
Background::Corners => match corner_color(w, h, px) {
|
||||
Some(corner) => vec![corner],
|
||||
None => return vec![255; n],
|
||||
},
|
||||
};
|
||||
let mut out: Vec<u8> = (0..n)
|
||||
.map(|i| {
|
||||
let [r, g, b, _] = px(i);
|
||||
level([r, g, b], &refs, settings)
|
||||
})
|
||||
.collect();
|
||||
if settings.contiguous {
|
||||
// Out from the edges, through clear pixels and pixels that lose some alpha; the pixels it
|
||||
// doesn't reach are kept.
|
||||
let reaches = |out: &[u8], i: usize| out.get(i).is_some_and(|l| *l < 255) || px(i)[3] == 0;
|
||||
let mut seen = vec![false; n];
|
||||
let mut stack: Vec<u32> = vec![];
|
||||
for i in border(w, h).filter(|&i| reaches(&out, i)) {
|
||||
if let (Some(s), Ok(at)) = (seen.get_mut(i), u32::try_from(i)) {
|
||||
*s = true;
|
||||
stack.push(at);
|
||||
}
|
||||
}
|
||||
while let Some(at) = stack.pop() {
|
||||
let i = at as usize;
|
||||
let (x, y) = (i % w, i / w);
|
||||
let next = [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)];
|
||||
for j in next.into_iter().flatten() {
|
||||
if let Some(s) = seen.get_mut(j)
|
||||
&& !*s
|
||||
&& reaches(&out, j)
|
||||
&& let Ok(at) = u32::try_from(j)
|
||||
{
|
||||
*s = true;
|
||||
stack.push(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (l, s) in out.iter_mut().zip(&seen) {
|
||||
if !*s {
|
||||
*l = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The level of a pixel of color `c`: 0 within the tolerance of the nearest of `refs`, 255 from
|
||||
/// the tolerance plus the softness on, and in proportion between them.
|
||||
fn level(c: [u16; 3], refs: &[[u16; 3]], settings: &Settings) -> u8 {
|
||||
let Settings { tolerance, softness, measure, .. } = *settings;
|
||||
let d = refs.iter().map(|r| apart(c, *r, measure)).fold(f64::INFINITY, f64::min);
|
||||
if d <= tolerance {
|
||||
0
|
||||
} else if d >= tolerance + softness {
|
||||
255
|
||||
} else {
|
||||
((d - tolerance) / softness * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// How far apart `a` and `b` are, as a percentage, measured as `measure` says: the RGB distance of
|
||||
/// the largest one (black to white), or the largest difference on one channel of the channel's range.
|
||||
/// A difference of `k` 8-bit steps on one channel is exactly `k / 255 × 100` with channels.
|
||||
fn apart(a: [u16; 3], b: [u16; 3], measure: Match) -> f64 {
|
||||
match measure {
|
||||
Match::Distance => {
|
||||
let sq: f64 = a.iter().zip(b).map(|(x, y)| (f64::from(*x) - f64::from(y)).powi(2)).sum();
|
||||
sq.sqrt() / (65_535.0 * 3f64.sqrt()) * 100.0
|
||||
}
|
||||
Match::Channels => f64::from(a.iter().zip(b).map(|(x, y)| x.abs_diff(y)).max().unwrap_or(0)) / 65_535.0 * 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels on the edges of a `w` × `h` image, each once.
|
||||
fn border(w: usize, h: usize) -> impl Iterator<Item = usize> {
|
||||
let top = (0..w).filter(move |_| h > 0);
|
||||
let bottom = (0..w).filter(move |_| h > 1).map(move |x| (h - 1) * w + x);
|
||||
let sides = (1..h.saturating_sub(1)).flat_map(move |y| [Some(y * w), (w > 1).then(|| y * w + w - 1)].into_iter().flatten());
|
||||
top.chain(bottom).chain(sides)
|
||||
}
|
||||
|
||||
/// The most common color along an image's edges: its edge pixels grouped by 16 levels a channel,
|
||||
/// with the clear ones as a group of their own, as the average of the largest group. `None` when
|
||||
/// the clear edge pixels are at least as many as that group: the background is already cleared.
|
||||
fn edge_color(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4]) -> Option<[u16; 3]> {
|
||||
let mut groups: HashMap<[u16; 3], (u64, [u64; 3])> = HashMap::new();
|
||||
let mut clear = 0u64;
|
||||
for i in border(w, h) {
|
||||
let [r, g, b, a] = px(i);
|
||||
if a == 0 {
|
||||
clear += 1;
|
||||
continue;
|
||||
}
|
||||
let e = groups.entry([r >> 12, g >> 12, b >> 12]).or_default();
|
||||
e.0 += 1;
|
||||
for (s, v) in e.1.iter_mut().zip([r, g, b]) {
|
||||
*s += u64::from(v);
|
||||
}
|
||||
}
|
||||
// Equal groups go by color, so the result doesn't depend on the map's order.
|
||||
let (_, (n, sums)) = groups.into_iter().max_by(|a, b| a.1.0.cmp(&b.1.0).then(b.0.cmp(&a.0)))?;
|
||||
if clear >= n {
|
||||
return None;
|
||||
}
|
||||
Some(sums.map(|s| ((s + n / 2) / n.max(1)) as u16))
|
||||
}
|
||||
|
||||
/// The average of the four corner pixels of a `w` × `h` image that are not clear (a corner of a
|
||||
/// one-pixel-wide image counts twice). `None` when all four are clear: the background is already
|
||||
/// cleared.
|
||||
fn corner_color(w: usize, h: usize, px: &dyn Fn(usize) -> [u16; 4]) -> Option<[u16; 3]> {
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let corners = [0, w - 1, (h - 1) * w, (h - 1) * w + w - 1];
|
||||
let (mut n, mut sums) = (0u64, [0u64; 3]);
|
||||
for [r, g, b, a] in corners.map(px) {
|
||||
if a > 0 {
|
||||
n += 1;
|
||||
for (s, v) in sums.iter_mut().zip([r, g, b]) {
|
||||
*s += u64::from(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
(n > 0).then(|| sums.map(|s| ((s + n / 2) / n) as u16))
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::{DynamicImage, ImageBuffer, Luma, Rgb};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use super::{Background, Match, REMOVE_BACKGROUND as C, Settings, level, levels};
|
||||
use crate::Session;
|
||||
use crate::cmd::fileio::ppi;
|
||||
|
||||
const WHITE: [u8; 4] = [255, 255, 255, 255];
|
||||
const RED: [u8; 4] = [200, 0, 0, 255];
|
||||
|
||||
/// The command's defaults with `background`.
|
||||
fn settings(background: Background) -> Settings {
|
||||
Settings { background, contiguous: true, measure: Match::Distance, tolerance: 10.0, softness: 10.0 }
|
||||
}
|
||||
|
||||
fn png(img: DynamicImage) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// 12 × 10 pixels: white, with a red block from (3, 3) to (9, 7) that holds one white pixel at
|
||||
/// (6, 5), which no white edge pixel reaches.
|
||||
fn framed() -> image::RgbaImage {
|
||||
image::RgbaImage::from_fn(12, 10, |x, y| {
|
||||
let block = (3..9).contains(&x) && (3..7).contains(&y);
|
||||
image::Rgba(if block && (x, y) != (6, 5) { RED } else { WHITE })
|
||||
})
|
||||
}
|
||||
|
||||
/// A session with `bytes` opened as a document, its image selected.
|
||||
fn open(bytes: &[u8]) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn image_of(s: &Session, id: NodeId) -> ImageObject {
|
||||
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("an image") };
|
||||
(**im).clone()
|
||||
}
|
||||
|
||||
fn bytes_of(s: &Session, id: NodeId) -> Arc<Vec<u8>> {
|
||||
s.doc().unwrap().doc.images[&image_of(s, id).key].bytes.clone()
|
||||
}
|
||||
|
||||
fn pixels(s: &Session, id: NodeId) -> DynamicImage {
|
||||
image::load_from_memory(&bytes_of(s, id)).unwrap()
|
||||
}
|
||||
|
||||
fn alpha(img: &DynamicImage, x: u32, y: u32) -> u8 {
|
||||
img.to_rgba8().get_pixel(x, y).0[3]
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_edge_colors_are_cleared_where_they_reach_in_from_an_edge() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let before = (image_of(&s, id), undo_len(&s));
|
||||
let r = s.execute(C, &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 1, "ids": [id.0], "cleared": 12 * 10 - 6 * 4}));
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!(out.color(), image::ColorType::Rgba8);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 11, 9), alpha(&out, 2, 4)), (0, 0, 0), "the white around the block");
|
||||
assert_eq!(out.to_rgba8().get_pixel(4, 4).0, RED, "the block");
|
||||
assert_eq!(alpha(&out, 6, 5), 255, "white inside the block, which no edge reaches");
|
||||
let after = image_of(&s, id);
|
||||
assert_eq!((after.width, after.height, after.xf), (12, 10, before.0.xf), "the same place and size");
|
||||
assert_eq!(undo_len(&s), before.1 + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(image_of(&s, id), before.0);
|
||||
}
|
||||
|
||||
/// Picked colors, like the other backgrounds, are cleared where they reach in from an edge, and with
|
||||
/// `contiguous: false` wherever they are.
|
||||
#[test]
|
||||
fn picked_colors_are_cleared_from_the_edges_or_wherever_they_are() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
s.execute(C, &json!({"background": "colors", "colors": ["#fff"], "tolerance": 0, "softness": 0})).unwrap();
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 255, 255), "not inside the block");
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
s.execute(C, &json!({"background": "colors", "colors": ["#fff"], "contiguous": false, "tolerance": 0, "softness": 0})).unwrap();
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255), "inside the block too; the red stays");
|
||||
}
|
||||
|
||||
/// With `contiguous: false` the edge color is cleared inside the subject too.
|
||||
#[test]
|
||||
fn the_edge_color_can_be_cleared_wherever_it_is() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let r = s.execute(C, &json!({"contiguous": false})).unwrap();
|
||||
assert_eq!(r["cleared"], 12 * 10 - 6 * 4 + 1);
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255));
|
||||
}
|
||||
|
||||
/// `colors` as one string split by commas and spaces, written as other color params take them.
|
||||
#[test]
|
||||
fn picked_colors_can_be_one_string_of_colors() {
|
||||
for colors in ["#00ff00, ffffff", "green white", "#0f0,#FFF"] {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
s.execute(C, &json!({"background": "colors", "colors": colors, "contiguous": false, "tolerance": 0, "softness": 0})).unwrap();
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 6, 5), alpha(&out, 4, 4)), (0, 0, 255), "{colors}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A subject along a whole edge stays: only the most common edge color is the background.
|
||||
#[test]
|
||||
fn a_subject_along_one_edge_is_not_the_background() {
|
||||
let img = image::RgbaImage::from_fn(10, 10, |_, y| image::Rgba(if y >= 7 { RED } else { WHITE }));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img)));
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 5, 0), alpha(&out, 5, 9), alpha(&out, 0, 8)), (0, 255, 255));
|
||||
}
|
||||
|
||||
/// The preview steps of one interaction, as the dialog's are, decode an image once and give what a
|
||||
/// run on freshly decoded pixels gives. The pixels are kept only until the interaction ends (OK or
|
||||
/// Cancel), and a run that is not a preview step keeps none.
|
||||
#[test]
|
||||
fn preview_steps_on_the_same_pixels_decode_them_once() {
|
||||
let decodes = || super::DECODES.with(std::cell::Cell::get);
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let start = decodes();
|
||||
let p = json!({"background": "colors", "colors": "#c80000", "contiguous": false, "tolerance": 0, "softness": 0});
|
||||
s.begin_interaction("Remove Background").unwrap();
|
||||
s.preview(C, &json!({"tolerance": 0, "softness": 0})).unwrap();
|
||||
s.preview(C, &json!({"tolerance": 20, "softness": 0})).unwrap();
|
||||
s.preview(C, &p).unwrap();
|
||||
assert_eq!((decodes() - start, s.decoded_images.len()), (1, 1), "decoded once for three preview steps");
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(s.decoded_images.len(), 0, "OK lets the pixels go");
|
||||
let (mut fresh, fresh_id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
fresh.execute(C, &p).unwrap();
|
||||
assert_eq!((decodes() - start, fresh.decoded_images.len()), (2, 0), "a run that is not a preview step keeps nothing");
|
||||
assert_eq!(bytes_of(&s, id), bytes_of(&fresh, fresh_id), "the same result");
|
||||
assert_eq!((alpha(&pixels(&s, id), 4, 4), alpha(&pixels(&s, id), 0, 0)), (0, 255), "the red cleared, the white kept");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.begin_interaction("Remove Background").unwrap();
|
||||
s.preview(C, &json!({})).unwrap();
|
||||
assert_eq!(s.decoded_images.len(), 1);
|
||||
s.cancel_interaction().unwrap();
|
||||
assert_eq!(s.decoded_images.len(), 0, "Cancel lets the pixels go");
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
assert_eq!(decodes() - start, 5, "each run that is not a preview step decodes");
|
||||
}
|
||||
|
||||
/// Pixels a preview step takes from the step before keep what they were decoded with: a 16-bit gray
|
||||
/// image with an ICC profile comes out as a run on freshly decoded pixels gives it.
|
||||
#[test]
|
||||
fn reused_pixels_keep_their_depth_gray_and_profile() {
|
||||
use image::{ImageDecoder as _, ImageEncoder as _};
|
||||
let icc = b"a profile's bytes".to_vec();
|
||||
let gray16 = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 7_000u16 } else { 61_000 }]));
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = image::codecs::png::PngEncoder::new(&mut bytes);
|
||||
encoder.set_icc_profile(icc.clone()).unwrap();
|
||||
DynamicImage::ImageLuma16(gray16).write_with_encoder(encoder).unwrap();
|
||||
let decodes = || super::DECODES.with(std::cell::Cell::get);
|
||||
let (mut s, id) = open(&bytes);
|
||||
let start = decodes();
|
||||
s.begin_interaction("Remove Background").unwrap();
|
||||
s.preview(C, &json!({"tolerance": 0, "softness": 0})).unwrap();
|
||||
s.preview(C, &json!({})).unwrap();
|
||||
assert_eq!(decodes() - start, 1, "the second step took the pixels of the first");
|
||||
let (mut fresh, fresh_id) = open(&bytes);
|
||||
fresh.execute(C, &json!({})).unwrap();
|
||||
assert_eq!(bytes_of(&s, id), bytes_of(&fresh, fresh_id), "the same result");
|
||||
let DynamicImage::ImageLumaA16(out) = pixels(&s, id) else { panic!("16-bit gray with alpha") };
|
||||
assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535]));
|
||||
let result = bytes_of(&s, id);
|
||||
let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(result.as_slice())).unwrap();
|
||||
assert_eq!(decoder.icc_profile().unwrap(), Some(icc));
|
||||
}
|
||||
|
||||
/// Once the background is cleared, most edge pixels are clear and a second run clears nothing: the
|
||||
/// subject's color along an edge is not taken for the background.
|
||||
#[test]
|
||||
fn a_second_run_leaves_the_subject() {
|
||||
let img = image::RgbaImage::from_fn(10, 10, |x, y| {
|
||||
image::Rgba(if y >= 7 {
|
||||
RED
|
||||
} else if (4..6).contains(&x) && (3..5).contains(&y) {
|
||||
[0, 0, 200, 255]
|
||||
} else {
|
||||
WHITE
|
||||
})
|
||||
});
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img)));
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let (first, steps) = (image_of(&s, id), undo_len(&s));
|
||||
let r = s.execute(C, &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 0, "ids": [], "cleared": 0}));
|
||||
assert_eq!((image_of(&s, id), undo_len(&s)), (first, steps), "no change and no undo step");
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 5, 0), alpha(&out, 5, 9), alpha(&out, 4, 3)), (0, 255, 255), "the red and the blue stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerance_and_softness_set_how_near_a_color_is_cleared() {
|
||||
// White; 1.96 % from white; 21.6 % from white.
|
||||
let px = [WHITE, [250, 250, 250, 255], [200, 200, 200, 255]];
|
||||
let at = |i: usize| px[i].map(|v| u16::from(v) * 257);
|
||||
let run = |tolerance, softness| levels(3, 1, &at, &Settings { tolerance, softness, ..settings(Background::Colors(vec![[255; 3]])) });
|
||||
assert_eq!(run(0.0, 0.0), [0, 255, 255], "only white itself at 0");
|
||||
assert_eq!(run(2.0, 0.0), [0, 0, 255]);
|
||||
assert_eq!(run(0.0, 10.0), [0, 50, 255], "1.96 % into a 10 % fade");
|
||||
assert_eq!(run(100.0, 0.0), [0, 0, 0], "everything at 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_with_no_pixel_left_is_refused_and_changes_nothing() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let before = (image_of(&s, id), undo_len(&s));
|
||||
let e = s.execute(C, &json!({"background": "colors", "colors": "#ffffff", "tolerance": 100})).unwrap_err();
|
||||
assert!(e.to_string().contains("every pixel matches the background"), "{e}");
|
||||
assert_eq!((image_of(&s, id), undo_len(&s)), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_whose_pixels_do_not_change_is_left_with_no_undo_step() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let before = (image_of(&s, id), undo_len(&s));
|
||||
let r = s.execute(C, &json!({"background": "colors", "colors": "#00ff00", "tolerance": 0, "softness": 0})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 0, "ids": [], "cleared": 0}));
|
||||
assert_eq!((image_of(&s, id), undo_len(&s)), before);
|
||||
// An image whose edges and corners are already clear has no edge or corner color to clear,
|
||||
// whatever color the clear pixels hold.
|
||||
let ring =
|
||||
image::RgbaImage::from_fn(6, 6, |x, y| image::Rgba(if (1..5).contains(&x) && (1..5).contains(&y) { WHITE } else { [255, 255, 255, 0] }));
|
||||
for p in [json!({}), json!({"background": "corners", "contiguous": false})] {
|
||||
let (mut s, _) = open(&png(DynamicImage::ImageRgba8(ring.clone())));
|
||||
assert_eq!(s.execute(C, &p).unwrap()["changed"], 0, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The corners' color: 40 × 30 pixels of light gray around a red block, cleared within 5 steps on
|
||||
/// each channel (`match: channels`, a tolerance of `5 / 255 × 100`, no softness), from the edges
|
||||
/// or wherever it is.
|
||||
#[test]
|
||||
fn the_corners_color_is_cleared() {
|
||||
let img = image::RgbaImage::from_fn(40, 30, |x, y| {
|
||||
image::Rgba(if (10..30).contains(&x) && (8..22).contains(&y) { [200, 40, 40, 255] } else { [240, 240, 240, 255] })
|
||||
});
|
||||
for contiguous in [false, true] {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img.clone())));
|
||||
let p = json!({"background": "corners", "contiguous": contiguous, "match": "channels", "tolerance": 5.0 / 255.0 * 100.0, "softness": 0});
|
||||
let r = s.execute(C, &p).unwrap();
|
||||
assert_eq!(r["cleared"], 40 * 30 - 14 * 20, "{p}");
|
||||
let out = pixels(&s, id).to_rgba8();
|
||||
assert_eq!((out.get_pixel(0, 0).0[3], out.get_pixel(15, 15).0), (0, [200, 40, 40, 255]), "{p}");
|
||||
}
|
||||
// A clear corner is left out of the average: the other three are white, and white is cleared.
|
||||
let mut img = image::RgbaImage::from_pixel(8, 8, image::Rgba(WHITE));
|
||||
img.put_pixel(0, 0, image::Rgba([0; 4]));
|
||||
img.put_pixel(4, 4, image::Rgba(RED));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img)));
|
||||
let r = s.execute(C, &json!({"background": "corners", "tolerance": 0, "softness": 0})).unwrap();
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 7, 7), alpha(&out, 3, 3), alpha(&out, 4, 4)), (0, 0, 255));
|
||||
assert_eq!(r["cleared"], 8 * 8 - 2, "the pixels made clear, not the corner that was clear already");
|
||||
}
|
||||
|
||||
/// One picked color matched by channel at a tolerance of 0: cleared wherever it is, and a pixel a
|
||||
/// long way from it kept.
|
||||
#[test]
|
||||
fn one_picked_color_is_cleared_by_channel() {
|
||||
let mut img = image::RgbaImage::from_pixel(4, 4, image::Rgba([10, 20, 30, 255]));
|
||||
img.put_pixel(2, 2, image::Rgba([250, 250, 250, 255]));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(img)));
|
||||
let p = json!({"background": "colors", "colors": "#0a141e", "contiguous": false, "match": "channels", "tolerance": 0, "softness": 0});
|
||||
assert_eq!(s.execute(C, &p).unwrap()["cleared"], 15);
|
||||
let out = pixels(&s, id);
|
||||
assert_eq!((alpha(&out, 0, 0), alpha(&out, 2, 2)), (0, 255));
|
||||
}
|
||||
|
||||
/// With `match: channels`, a tolerance of `t / 255 × 100` clears a color `k` 8-bit steps from the
|
||||
/// background on one channel exactly when `k ≤ t`, for every `t` and `k` from 0 to 255; with
|
||||
/// `distance` the same color is nearer when the others match.
|
||||
#[test]
|
||||
fn channels_match_the_largest_difference_on_one_channel() {
|
||||
for t in 0..=255u16 {
|
||||
let s = Settings { measure: Match::Channels, tolerance: f64::from(t) / 255.0 * 100.0, softness: 0.0, ..settings(Background::Edges) };
|
||||
for k in 0..=255u16 {
|
||||
for c in [[k * 257, 0, 0], [0, k * 257, 0], [0, 0, k * 257]] {
|
||||
assert_eq!(level(c, &[[0; 3]], &s) == 0, k <= t, "t {t}, k {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let at = |measure| {
|
||||
level([0, 40 * 257, 0], &[[0; 3]], &Settings { measure, tolerance: 32.0 / 255.0 * 100.0, softness: 0.0, ..settings(Background::Edges) })
|
||||
};
|
||||
assert_eq!((at(Match::Channels), at(Match::Distance)), (255, 0), "40 steps on green: past 32 on one channel; 9 % of the RGB distance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() {
|
||||
let deep = ImageBuffer::from_fn(8, 8, |x, y| Rgb(if (2..6).contains(&x) && (2..6).contains(&y) { [51_400, 0, 1] } else { [65_535; 3] }));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgb16(deep)));
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let DynamicImage::ImageRgba16(out) = pixels(&s, id) else { panic!("16-bit RGBA") };
|
||||
assert_eq!(out.get_pixel(0, 0).0[3], 0);
|
||||
assert_eq!(out.get_pixel(3, 3).0, [51_400, 0, 1, 65_535], "16-bit samples kept as they were");
|
||||
// Samples one 8-bit step apart are told apart: only the background is cleared at 0.
|
||||
let near = ImageBuffer::from_fn(4, 4, |x, y| Rgb(if (1..3).contains(&x) && (1..3).contains(&y) { [65_280; 3] } else { [65_535; 3] }));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgb16(near)));
|
||||
s.execute(C, &json!({"tolerance": 0, "softness": 0})).unwrap();
|
||||
let DynamicImage::ImageRgba16(out) = pixels(&s, id) else { panic!("16-bit RGBA") };
|
||||
assert_eq!((out.get_pixel(0, 0).0[3], out.get_pixel(1, 1).0[3]), (0, 65_535));
|
||||
let gray = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }]));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageLuma8(gray)));
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") };
|
||||
assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([240, 0], [30, 255]));
|
||||
let gray16 = ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 7_000u16 } else { 61_000 }]));
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageLuma16(gray16)));
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let DynamicImage::ImageLumaA16(out) = pixels(&s, id) else { panic!("16-bit gray with alpha") };
|
||||
assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535]));
|
||||
}
|
||||
|
||||
/// The ICC profile the colors are in stays with them.
|
||||
#[test]
|
||||
fn the_color_profile_is_kept() {
|
||||
use image::{ImageDecoder as _, ImageEncoder as _};
|
||||
let icc = b"a profile's bytes".to_vec();
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = image::codecs::png::PngEncoder::new(&mut bytes);
|
||||
encoder.set_icc_profile(icc.clone()).unwrap();
|
||||
DynamicImage::ImageRgba8(framed()).write_with_encoder(encoder).unwrap();
|
||||
let blob = ImageBlob::png(bytes);
|
||||
let out = super::clear(&blob, &settings(Background::Edges), None).unwrap().unwrap();
|
||||
let mut decoder = image::codecs::png::PngDecoder::new(Cursor::new(out.blob.bytes.as_slice())).unwrap();
|
||||
assert_eq!(decoder.icc_profile().unwrap(), Some(icc));
|
||||
}
|
||||
|
||||
/// The resolution the file declares stays with the pixels, so a later Replace or relink sizes the
|
||||
/// image as before.
|
||||
#[test]
|
||||
fn the_resolution_is_kept() {
|
||||
let bytes = ppi::with_png_resolution(&png(DynamicImage::ImageRgba8(framed())), (300.0, 150.0));
|
||||
let (mut s, id) = open(&bytes);
|
||||
s.execute(C, &json!({})).unwrap();
|
||||
let (x, y) = ppi::resolution(&bytes_of(&s, id)).expect("a resolution");
|
||||
assert_eq!((x.round(), y.round()), (300.0, 150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_image_is_refused() {
|
||||
let inks = vectorcraft_doc::cmyk::Inks::new(4, 2, [0, 0, 0, 0].repeat(8)).unwrap();
|
||||
let tiff = ImageBlob::cmyk_tiff(&inks).unwrap();
|
||||
let (mut s, id) = open(&tiff.bytes);
|
||||
let before = (image_of(&s, id), undo_len(&s));
|
||||
let e = s.execute(C, &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("a CMYK image has no transparency to set"), "{e}");
|
||||
assert_eq!((image_of(&s, id), undo_len(&s)), before);
|
||||
}
|
||||
|
||||
/// A linked image of `blob`, `width` × `height` pixels as the object records it, beside the open
|
||||
/// document's image.
|
||||
fn place_linked(s: &mut Session, key: &str, blob: ImageBlob, (width, height): (u32, u32)) -> NodeId {
|
||||
s.edit("Place", |d, sel| {
|
||||
d.images.insert(key.into(), blob);
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject {
|
||||
key: key.into(),
|
||||
width,
|
||||
height,
|
||||
xf: Default::default(),
|
||||
link: Some(LinkInfo::new("/nowhere/art.png")),
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_image_is_embedded_and_one_that_shows_its_preview_is_refused() {
|
||||
let bytes = png(DynamicImage::ImageRgba8(framed()));
|
||||
let (mut s, _) = open(&bytes);
|
||||
let linked = place_linked(&mut s, "linked", ImageBlob::png(bytes.clone()), (12, 10));
|
||||
let clip = vectorcraft_doc::links::PlacementOptions { clip: true, ..Default::default() };
|
||||
s.edit("Placement", |d, _| {
|
||||
if let Some(NodeKind::Image(im)) = d.node_mut(linked).map(|n| &mut n.kind) {
|
||||
im.placement = clip;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let r = s.execute(C, &json!({"ids": [linked.0, linked.0]})).unwrap();
|
||||
assert_eq!(r, json!({"changed": 1, "ids": [linked.0], "cleared": 12 * 10 - 6 * 4}), "an image named twice changes once");
|
||||
let im = image_of(&s, linked);
|
||||
assert_eq!((im.link, alpha(&pixels(&s, linked), 0, 0)), (None, 0), "embedded, with its background cleared");
|
||||
assert_eq!(im.placement, clip, "its Placement Options kept, as Embed keeps them");
|
||||
// Pixels the file wasn't read for: the preview, as an image whose file is missing shows it.
|
||||
let preview = Arc::new(bytes);
|
||||
let missing = place_linked(&mut s, "missing", ImageBlob { mime: "image/png".into(), bytes: preview.clone(), proxy: Some(preview) }, (12, 10));
|
||||
let before = (image_of(&s, missing), undo_len(&s));
|
||||
let e = s.execute(C, &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("the linked image shows its preview: embed or relink it first"), "{e}");
|
||||
assert_eq!((image_of(&s, missing), undo_len(&s)), before);
|
||||
}
|
||||
|
||||
/// An object whose pixels were read at another size than it records keeps its place and size.
|
||||
#[test]
|
||||
fn an_image_read_at_another_size_keeps_its_place_and_size() {
|
||||
let bytes = png(DynamicImage::ImageRgba8(framed()));
|
||||
let (mut s, _) = open(&bytes);
|
||||
let id = place_linked(&mut s, "linked", ImageBlob::png(bytes), (24, 20));
|
||||
s.execute(C, &json!({"ids": [id.0]})).unwrap();
|
||||
let im = image_of(&s, id);
|
||||
assert_eq!((im.width, im.height, im.xf), (12, 10, Affine::scale_non_uniform(2.0, 2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_refused_and_change_nothing() {
|
||||
let (mut s, id) = open(&png(DynamicImage::ImageRgba8(framed())));
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let before = (image_of(&s, id), undo_len(&s));
|
||||
// With `ids`, the command is available when one of them is an image.
|
||||
let not_image = format!("object {rect} is not an image");
|
||||
let cases: [(Value, &str); 18] = [
|
||||
(json!({"background": "sky"}), "`background` must be edges, corners or colors"),
|
||||
(json!({"background": 5}), "`background` must be a string"),
|
||||
(json!({"match": "rgb"}), "`match` must be distance or channels"),
|
||||
(json!({"match": true}), "`match` must be a string"),
|
||||
(json!({"contiguous": "yes"}), "`contiguous` must be true or false"),
|
||||
(json!({"contiguous": 1}), "`contiguous` must be true or false"),
|
||||
(json!({"tolerance": 101}), "`tolerance` must be from 0 to 100"),
|
||||
(json!({"softness": -1}), "`softness` must be from 0 to 100"),
|
||||
(json!({"tolerance": "5"}), "`tolerance` must be a number"),
|
||||
(json!({"background": "colors"}), "`colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue"),
|
||||
(json!({"background": "colors", "colors": []}), "`colors` must list"),
|
||||
(json!({"background": "colors", "colors": "#12"}), "`colors` must list"),
|
||||
(json!({"background": "colors", "colors": "#ffffff80"}), "`colors` must list"),
|
||||
(json!({"colors": ["#ffffff", 7]}), "`colors` must list"),
|
||||
(json!({"background": "colors", "colors": vec!["#000"; 17]}), "`colors` must list"),
|
||||
(json!({"background": "colors", "colors": "#000 ".repeat(100_000)}), "`colors` must list"),
|
||||
(json!({"ids": [id.0, rect]}), ¬_image),
|
||||
(json!({"ids": [rect]}), "select an image"),
|
||||
];
|
||||
for (p, msg) in cases {
|
||||
let e = s.execute(C, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{p}: {e}");
|
||||
}
|
||||
assert_eq!((image_of(&s, id), undo_len(&s)), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_too_large_to_change_is_refused_before_it_is_decoded() {
|
||||
let run = |w: u32, h: u32| {
|
||||
let (mut s, _) = open(&crate::tests_linkspanel::png_header(w, h));
|
||||
s.execute(C, &json!({})).unwrap_err().to_string()
|
||||
};
|
||||
// As gray samples, RGBA worked on and kept for the PNG, and the work: 19 bytes a pixel.
|
||||
let over = run(10_632, 10_631);
|
||||
assert!(over.contains("an image of 10632 × 10631 pixels is too large to change"), "{over}");
|
||||
// Within the allowance it is decoded, and its missing rows can't be read.
|
||||
let within = run(10_631, 10_631);
|
||||
assert!(within.contains("the image's pixels can't be read"), "{within}");
|
||||
}
|
||||
@@ -19,12 +19,12 @@
|
||||
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
|
||||
//! image as its Contents row says; new ones go right below that row.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::appearance::SharedStack;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::edit::{outermost, roots_of, selected_roots};
|
||||
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a 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) → {ids}",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a 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) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -1198,7 +1198,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Sample enabled attributes, refusing object transparency that a text range cannot store.
|
||||
/// Sample enabled attributes with transform-aware character dimensions and atomic validation.
|
||||
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.copyFrom";
|
||||
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
@@ -1270,9 +1270,6 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?,
|
||||
};
|
||||
let picked = Picked::of(&node);
|
||||
if !reverse && !defaults_only {
|
||||
s.eyedropper_sample = Some(node);
|
||||
}
|
||||
// The highlighted characters' type objects and ranges: after Select All in threaded text, all
|
||||
// the text of every frame of the story, except a frame the attributes come from, in place of
|
||||
// the object selection.
|
||||
@@ -1285,6 +1282,15 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some((id, start, end)) if targets.contains(&id) => vec![(id, Some((start, end)))],
|
||||
_ => vec![],
|
||||
};
|
||||
let target_ids = || targets.iter().copied().filter(|_| ranges.is_empty()).chain(ranges.iter().map(|(id, _)| *id));
|
||||
// Prepare every dimensional copy before changing defaults or the remembered sample. A bad
|
||||
// source or target transform must refuse the entire pick, including a multi-frame range.
|
||||
let characters = if attrs.character && !append {
|
||||
let d = &s.doc()?.doc;
|
||||
copied_character_styles(&node, target_ids().filter_map(|id| d.node(id)))?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
// Character styles cannot represent object transparency. Refuse before even changing
|
||||
// paint defaults; applying it to the object would also alter the unselected characters.
|
||||
if attrs.appearance.transparency {
|
||||
@@ -1298,11 +1304,33 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reverse {
|
||||
picked.to_defaults(s, &attrs.appearance);
|
||||
}
|
||||
// The session's bounds check follows the selection. Check text targets reached through
|
||||
// explicit IDs, a reverse pick, or a highlighted story as well. Paragraph changes can
|
||||
// move point text even when character copying is disabled.
|
||||
let text_ids: Vec<_> = if attrs.paragraph && !append && picked.text.is_some() {
|
||||
let d = &s.doc()?.doc;
|
||||
target_ids().filter(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))).collect()
|
||||
} else {
|
||||
characters.keys().copied().collect()
|
||||
};
|
||||
let check_text_bounds = |d: &Document| {
|
||||
if !text_ids.is_empty() && !crate::sanity::objects_in_range(d, &text_ids) {
|
||||
return Err(bad(C, "sampled text would exceed the canvas (coordinates out of range)"));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
// Remember the pick only after the document edit has passed its own validation.
|
||||
let finish = |s: &mut Session, ids: &[NodeId]| {
|
||||
if !reverse {
|
||||
if !defaults_only {
|
||||
s.eyedropper_sample = Some(node);
|
||||
}
|
||||
picked.to_defaults(s, &attrs.appearance);
|
||||
}
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
};
|
||||
if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
return Ok(finish(s, &[]));
|
||||
}
|
||||
if !ranges.is_empty() {
|
||||
let ids: Vec<NodeId> = ranges.iter().map(|(f, _)| *f).collect();
|
||||
@@ -1312,24 +1340,26 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let (start, end) = range.unwrap_or((0, vectorcraft_text::edit::runs_len(&t.runs)));
|
||||
let placed = picked.placed(Some(t.local_bounds()));
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs));
|
||||
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| Picked::run(st, &placed, &attrs, characters.get(&id)));
|
||||
if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) {
|
||||
t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone());
|
||||
}
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
check_text_bounds(d)?;
|
||||
Ok(count_changed(d, &before, &ids))
|
||||
})?;
|
||||
return Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
return Ok(finish(s, &ids));
|
||||
}
|
||||
// When every target comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
super::overprint::edit_counted(s, "Eyedropper", |d| {
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
picked.apply(n, &attrs, append);
|
||||
picked.apply(n, &attrs, append, characters.get(id));
|
||||
}
|
||||
}
|
||||
check_text_bounds(d)?;
|
||||
// The paints the pick wrote, with a weight going with the paint of its stroke, as in `set_item`. `append` adds
|
||||
// only the colors of the visible rows the source has.
|
||||
let (fill, stroke) = (attrs.appearance.fill, attrs.appearance.stroke);
|
||||
@@ -1369,7 +1399,59 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let object = super::graph::capture_series_object_opacity(d, &objects);
|
||||
Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object))
|
||||
})?;
|
||||
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
Ok(finish(s, &targets))
|
||||
}
|
||||
|
||||
/// Read source typography in document space, then compensate for each target's transform.
|
||||
/// Paint stays in its existing sampling path; character scale never changes object placement.
|
||||
fn copied_character_styles<'a>(source: &Node, targets: impl Iterator<Item = &'a Node>) -> Result<HashMap<NodeId, CharStyle>> {
|
||||
const C: &str = "appearance.copyFrom";
|
||||
let mut styles = HashMap::new();
|
||||
let NodeKind::Text(source) = &source.kind else { return Ok(styles) };
|
||||
let Some(run) = source.runs.first() else { return Ok(styles) };
|
||||
let mut targets = targets
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some((n.id, t)),
|
||||
_ => None,
|
||||
})
|
||||
.peekable();
|
||||
if targets.peek().is_none() {
|
||||
return Ok(styles);
|
||||
}
|
||||
let effective = source.effective_char_style(&run.style).ok_or_else(|| bad(C, "source text transform is collapsed or unrepresentable"))?;
|
||||
if !copied_dimensions_fit(&run.style, &effective) {
|
||||
return Err(bad(C, "character dimensions cannot be represented with the source text transform"));
|
||||
}
|
||||
for (id, text) in targets {
|
||||
let scale = text.style_scale().ok_or_else(|| bad(C, "target text transform is collapsed or unrepresentable"))?;
|
||||
let mut local = effective.clone();
|
||||
local.size /= scale.points;
|
||||
local.leading = local.leading.map(|v| v / scale.points);
|
||||
local.baseline_shift /= scale.points;
|
||||
local.tcy_up_down /= scale.points;
|
||||
local.tcy_left_right /= scale.points;
|
||||
local.h_scale /= scale.horizontal;
|
||||
// As with dimensional type commands, reject overflow and underflow before layout.
|
||||
if !copied_dimensions_fit(&effective, &local) {
|
||||
return Err(bad(C, "character dimensions cannot be represented with the target text transform"));
|
||||
}
|
||||
styles.insert(id, local);
|
||||
}
|
||||
Ok(styles)
|
||||
}
|
||||
|
||||
/// Bound each converted type dimension and refuse loss to zero before layout reads it.
|
||||
fn copied_dimensions_fit(from: &CharStyle, to: &CharStyle) -> bool {
|
||||
[
|
||||
(from.size, to.size),
|
||||
(from.leading.unwrap_or(0.0), to.leading.unwrap_or(0.0)),
|
||||
(from.baseline_shift, to.baseline_shift),
|
||||
(from.tcy_up_down, to.tcy_up_down),
|
||||
(from.tcy_left_right, to.tcy_left_right),
|
||||
(from.h_scale, to.h_scale),
|
||||
]
|
||||
.iter()
|
||||
.all(|(from, to)| to.is_finite() && to.abs() <= crate::MAX_COORD && (*from == 0.0 || *to != 0.0))
|
||||
}
|
||||
|
||||
/// What the Eyedropper picks up from an object.
|
||||
@@ -1422,8 +1504,8 @@ impl Picked {
|
||||
|
||||
/// Copy character attributes and the enabled paint attributes through the same path for
|
||||
/// whole text objects and highlighted character ranges.
|
||||
fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) {
|
||||
if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) {
|
||||
fn run(st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs, character: Option<&CharStyle>) {
|
||||
if let Some(cs) = character {
|
||||
*st = CharStyle {
|
||||
fill: std::mem::take(&mut st.fill),
|
||||
stroke: std::mem::take(&mut st.stroke),
|
||||
@@ -1439,7 +1521,7 @@ impl Picked {
|
||||
/// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its
|
||||
/// stack instead. Type takes the paints on its characters, and character and paragraph
|
||||
/// attributes from type.
|
||||
fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool) {
|
||||
fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool, character: Option<&CharStyle>) {
|
||||
let ap = &a.appearance;
|
||||
if ap.transparency {
|
||||
n.opacity = self.opacity;
|
||||
@@ -1456,7 +1538,7 @@ impl Picked {
|
||||
let placed = self.placed(Some(t.local_bounds()));
|
||||
let text = self.text.as_ref();
|
||||
for r in &mut t.runs {
|
||||
self.run(&mut r.style, &placed, a);
|
||||
Self::run(&mut r.style, &placed, a, character);
|
||||
}
|
||||
if let Some((_, para)) = text.filter(|_| a.paragraph) {
|
||||
// The source's first paragraph's attributes, on every paragraph.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! save, export and serialize by path or bytes (base64), the format table, `command.batch` and
|
||||
//! `action.batch` (the Actions panel menu's Batch…).
|
||||
//!
|
||||
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images).
|
||||
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, HP-GL, raster images).
|
||||
//! - `action_batch`: play an action on every file in a folder or every data set.
|
||||
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
|
||||
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
|
||||
@@ -205,6 +205,8 @@ mod tests_eps;
|
||||
#[cfg(test)]
|
||||
mod tests_dxfimport;
|
||||
#[cfg(test)]
|
||||
mod tests_hpgl;
|
||||
#[cfg(test)]
|
||||
mod tests_metafile;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -479,6 +479,8 @@ pub const FORMATS: &[Format] = &[
|
||||
},
|
||||
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
// HP-GL/2 plot files, read by `vectorcraft_cad::hpgl` into paths; exports don't write them.
|
||||
reader("hpgl", "HP-GL", &["plt", "hpgl", "hgl", "hpg"], "application/vnd.hp-hpgl", false),
|
||||
Format {
|
||||
id: "tga",
|
||||
label: "Targa",
|
||||
@@ -552,6 +554,10 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"dxf",
|
||||
"emf",
|
||||
"wmf",
|
||||
"plt",
|
||||
"hpgl",
|
||||
"hgl",
|
||||
"hpg",
|
||||
"eps",
|
||||
"epsf",
|
||||
"ps",
|
||||
@@ -565,9 +571,10 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of
|
||||
/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS
|
||||
/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it.
|
||||
/// The packaging declares no type for HP-GL plot files either.
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &[
|
||||
"psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc",
|
||||
"heic", "heif", "jxl",
|
||||
"heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg",
|
||||
];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
@@ -641,6 +648,10 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"dxf",
|
||||
"emf",
|
||||
"wmf",
|
||||
"plt",
|
||||
"hpgl",
|
||||
"hgl",
|
||||
"hpg",
|
||||
"eps",
|
||||
"epsf",
|
||||
"ps",
|
||||
|
||||
@@ -134,6 +134,17 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) {
|
||||
return format("svg");
|
||||
}
|
||||
// HP-GL has no signature: a file that starts with plotter instructions, named as one or with an
|
||||
// extension no other format (text, the formats VectorCraft names as unsupported, and the files
|
||||
// File › Open takes in other ways included) reads.
|
||||
let ext = super::extension(name);
|
||||
let other = by_name.is_some_and(|f| f.id != "hpgl")
|
||||
|| super::TEXT_EXTS.contains(&ext.as_str())
|
||||
|| super::unsupported(&ext).is_some()
|
||||
|| super::opens_as_other(&ext);
|
||||
if !other && vectorcraft_cad::hpgl::is_hpgl(bytes) {
|
||||
return format("hpgl");
|
||||
}
|
||||
by_name
|
||||
}
|
||||
|
||||
@@ -487,6 +498,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let (doc, warnings) = super::metafile::import(bytes)?;
|
||||
(doc, warnings, false)
|
||||
}
|
||||
"hpgl" => {
|
||||
let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?;
|
||||
(i.document, i.warnings, false)
|
||||
}
|
||||
"affinity" => {
|
||||
let i = super::affinity::import(&title, bytes)?;
|
||||
preview_only = i.preview_only;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,104 @@
|
||||
//! HP-GL plotter files through the commands: `document.open` by extension or by content, File ›
|
||||
//! Place as one group of paths, the dialogs' filters and the format list, and files that aren't
|
||||
//! HP-GL refused with a message.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
vectorcraft_format::base64_encode(bytes)
|
||||
}
|
||||
|
||||
/// An inch-wide red square and a black diagonal, with the thinnest pen.
|
||||
const PLOT: &[u8] = b"IN;SP2;PW0;PA0,0;PD1016,0,1016,1016,0,1016,0,0;PU;SP1;PD1016,1016;PU;";
|
||||
|
||||
#[test]
|
||||
fn document_open_reads_a_plot_file() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(PLOT)})).unwrap();
|
||||
assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("hpgl"), Some("part.plt")), "{r}");
|
||||
let st = s.doc().unwrap();
|
||||
let board = st.doc.artboards[0].rect;
|
||||
assert!((board.width() - 72.25).abs() < 1e-9 && (board.height() - 72.25).abs() < 1e-9, "{board:?}");
|
||||
let mut paths = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if let NodeKind::Path { .. } = n.kind {
|
||||
paths.push(n.clone());
|
||||
}
|
||||
});
|
||||
assert_eq!(paths.len(), 2);
|
||||
// Opened as artwork: Save asks for a name.
|
||||
assert!(st.path.is_none());
|
||||
|
||||
// A plot file opens by its content in any of its names, and under a name no format reads.
|
||||
for name in ["drawing.prn", "PART.HPGL", "part.hgl", "part.hpg"] {
|
||||
let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(PLOT)})).unwrap();
|
||||
assert_eq!(r["format"].as_str(), Some("hpgl"), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_place_places_a_plot_file_as_one_group() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
let p = json!({"name": "part.plt", "dataBase64": b64(PLOT), "thumbnail": 16});
|
||||
let info = s.execute("file.place.info", &p).unwrap();
|
||||
assert_eq!(info["format"], "hpgl", "{info}");
|
||||
assert!((info["width"].as_f64().unwrap() - 72.25).abs() < 1e-9, "{info}");
|
||||
let r = s.execute("file.place", &p).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("hpgl"), Some(1)), "{r}");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
|
||||
let NodeKind::Group { children, clip, .. } = &group.kind else { panic!("a group, got {:?}", group.kind) };
|
||||
assert_eq!((children.len(), *clip), (2, false), "the art's bounds, not clipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_are_not_hpgl_are_refused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
|
||||
let before = s.doc().unwrap().doc.artboards[0].rect.size();
|
||||
let script = b"set terminal png\nplot sin(x)\n";
|
||||
let e = s.execute("document.open", &json!({"name": "graph.plt", "dataBase64": b64(script)})).expect_err("refused");
|
||||
assert!(e.to_string().contains("not an HP-GL file"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), before);
|
||||
assert!(s.execute("file.place", &json!({"name": "graph.plt", "dataBase64": b64(script)})).is_err());
|
||||
// Text that isn't a plot opens by its name, not as HP-GL.
|
||||
assert!(detect("notes.txt", b"IN THE BEGINNING").is_none_or(|f| f.id != "hpgl"));
|
||||
// Only a plot file's own names, and names no other format reads, are read by their content:
|
||||
// text, other formats' files and the formats named as unsupported (a DWG drawing starts with
|
||||
// `AC` and digits) aren't, even when they start like a plot.
|
||||
for name in ["notes.txt", "notes.svg", "picture.tga", "drawing.dxf", "site.dwg", "old.pct"] {
|
||||
for bytes in [&b"IN"[..], b"in 5;", b"AC1032\0\0", PLOT] {
|
||||
assert!(detect(name, bytes).is_none_or(|f| f.id != "hpgl"), "{name}: {:?}", String::from_utf8_lossy(bytes));
|
||||
}
|
||||
}
|
||||
let text = s.execute("file.place", &json!({"name": "notes.txt", "dataBase64": b64(b"IN 5; PA 10;")})).unwrap();
|
||||
assert_eq!(text["format"], "txt", "{text}");
|
||||
}
|
||||
|
||||
/// A plot file named as one opens when its first instruction is one a plot file seldom starts with;
|
||||
/// under another name it isn't taken for HP-GL.
|
||||
#[test]
|
||||
fn a_plot_file_by_its_name_may_start_with_any_instruction() {
|
||||
let plot = b"SI0.2,0.3;SP1;PA0,0;PD1016,0;";
|
||||
let r = Session::new().execute("document.open", &json!({"name": "part.plt", "dataBase64": b64(plot)})).unwrap();
|
||||
assert_eq!(r["format"].as_str(), Some("hpgl"), "{r}");
|
||||
assert!(detect("drawing.prn", plot).is_none_or(|f| f.id != "hpgl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dialogs_and_the_format_list_name_hpgl() {
|
||||
let exts = ["plt", "hpgl", "hgl", "hpg"];
|
||||
for e in exts {
|
||||
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
|
||||
assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("hpgl"), "{e}");
|
||||
}
|
||||
let filter = ("HP-GL", &exts[..]);
|
||||
assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter));
|
||||
let r = Session::new().execute("document.formats", &json!({})).unwrap();
|
||||
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "hpgl").unwrap().clone();
|
||||
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false)));
|
||||
}
|
||||
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Check Links",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}",
|
||||
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
@@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Links",
|
||||
[],
|
||||
None,
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}",
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Link Info",
|
||||
[],
|
||||
None,
|
||||
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size",
|
||||
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size; under automation read roots, a link whose file is outside the roots has no link, path or location, and fileSize and modified come only from the file",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -882,17 +882,35 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
|
||||
}
|
||||
|
||||
/// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in
|
||||
/// the layers), with `refused`: why the path isn't followed, when it isn't.
|
||||
/// the layers), with `refused`: why the path isn't followed, when it isn't. `path` is left out
|
||||
/// where [`path_shown`] says so.
|
||||
fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<Value> {
|
||||
gs.filter(|g| !g.ids.is_empty())
|
||||
.map(|g| {
|
||||
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) });
|
||||
let mut row = json!({ "name": g.link.name(), "ids": ids_json(&g.ids) });
|
||||
note_path(&mut row, "path", &g.link.path, doc_path);
|
||||
note_refused(&mut row, &g.link, doc_path);
|
||||
row
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// May a reply to the caller asking now carry the link path `path` (of the document saved at
|
||||
/// `doc_path`)? Always without read roots in force; with them, only when the file it names (a
|
||||
/// relative path taken from the document's folder) lies inside them.
|
||||
fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
|
||||
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
|
||||
}
|
||||
|
||||
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
|
||||
fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
|
||||
if path_shown(path, doc_path) {
|
||||
row[key] = json!(path);
|
||||
} else if let Some(o) = row.as_object_mut() {
|
||||
o.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]).
|
||||
fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) {
|
||||
if let Some(r) = refused(link, doc_path) {
|
||||
@@ -1188,10 +1206,12 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (status, found) = probe(g, LinkBase::of(st));
|
||||
missing += usize::from(status == Status::Missing);
|
||||
modified += usize::from(status == Status::Modified);
|
||||
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
|
||||
note_refused(&mut row, &g.link, LinkBase::of(st).doc);
|
||||
let doc_path = LinkBase::of(st).doc;
|
||||
let mut row = json!({ "name": g.link.name(), "status": status.as_str(), "ids": ids_json(&g.ids) });
|
||||
note_path(&mut row, "path", &g.link.path, doc_path);
|
||||
note_refused(&mut row, &g.link, doc_path);
|
||||
if let Some(f) = found.filter(|f| f.path != g.link.path) {
|
||||
row["found"] = json!(f.path);
|
||||
note_path(&mut row, "found", &f.path, doc_path);
|
||||
}
|
||||
if stale(&st.doc, g) {
|
||||
row["preview"] = json!(true);
|
||||
@@ -1663,14 +1683,15 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.insert("linked".into(), json!(link.is_some()));
|
||||
o.insert("status".into(), json!(status));
|
||||
}
|
||||
let doc_path = LinkBase::of(st).doc;
|
||||
if let Some(l) = link {
|
||||
row["path"] = json!(l.path);
|
||||
note_path(&mut row, "path", &l.path, doc_path);
|
||||
if let Some(page) = l.page {
|
||||
row["page"] = json!(page);
|
||||
}
|
||||
}
|
||||
if let Some(f) = found {
|
||||
row["found"] = json!(f);
|
||||
note_path(&mut row, "found", f, doc_path);
|
||||
}
|
||||
if preview {
|
||||
row["preview"] = json!(true);
|
||||
@@ -1811,13 +1832,17 @@ fn placement_json(o: PlacementOptions) -> Value {
|
||||
|
||||
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
|
||||
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
|
||||
// Whether the path is shown is judged for the caller asking, before the opener's roots apply.
|
||||
let shown = path_shown(&l.path, LinkBase::of(st).doc);
|
||||
// A confined caller reads within its own roots; anyone else within what the caller that
|
||||
// opened the document may read (never wider than the caller asking now).
|
||||
let roots = crate::file_access::current().or_else(|| st.opened_by.clone());
|
||||
crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, out));
|
||||
crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, shown, out));
|
||||
}
|
||||
|
||||
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
|
||||
/// [`file_fields`]; without `shown`, the link's path (`path`, `link`) and folder (`location`)
|
||||
/// and the size and date the document keeps of its file are left out.
|
||||
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, shown: bool, out: &mut Value) {
|
||||
let base = LinkBase::of(st);
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0);
|
||||
let doc_path = base.doc;
|
||||
@@ -1825,13 +1850,20 @@ fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Va
|
||||
let stamp = path.as_deref().and_then(file_stamp);
|
||||
out["status"] = json!(status.as_str());
|
||||
note_refused(out, l, doc_path);
|
||||
out["path"] = json!(l.path);
|
||||
if shown {
|
||||
out["path"] = json!(l.path);
|
||||
out["location"] = json!(l.folder());
|
||||
} else if let Some(o) = out.as_object_mut() {
|
||||
for k in ["path", "link", "location"] {
|
||||
o.remove(k);
|
||||
}
|
||||
}
|
||||
out["fileName"] = json!(l.name());
|
||||
out["location"] = json!(l.folder());
|
||||
let kept = |v: Option<u64>| v.filter(|_| shown);
|
||||
for (k, v) in [
|
||||
("page", l.page.map(u64::from)),
|
||||
("fileSize", stamp.map(|s| s.0).or(l.size)),
|
||||
("modified", stamp.and_then(|s| s.1).or(l.modified)),
|
||||
("fileSize", stamp.map(|s| s.0).or(kept(l.size))),
|
||||
("modified", stamp.and_then(|s| s.1).or(kept(l.modified))),
|
||||
("created", path.as_deref().and_then(file_created)),
|
||||
] {
|
||||
if let Some(v) = v {
|
||||
|
||||
@@ -871,7 +871,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn has_image(s: &Session) -> std::result::Result<(), String> {
|
||||
pub(crate) fn has_image(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with
|
||||
//! `link` ([`super::links`]).
|
||||
//! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options
|
||||
//! (`dxf`, [`fileio::dxfimport`]).
|
||||
//! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`).
|
||||
//! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by
|
||||
//! its art (`crop: "bounding"`).
|
||||
//! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by
|
||||
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -264,7 +264,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
|
||||
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
|
||||
let layers = std::mem::take(&mut doc.layers);
|
||||
let symbols = doc.symbol_boxes();
|
||||
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
|
||||
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf" | "hpgl") {
|
||||
// An SVG with `crop: "crop"` asked for is framed and clipped by its viewBox (its artboard),
|
||||
// as a PDF by its page: art drawn on a shared canvas keeps its place and scale (#788). By
|
||||
// default, as Illustrator places one, it is its art's bounds.
|
||||
|
||||
@@ -741,17 +741,18 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Installed plug-ins stay installed after a restart (#2541). The module is written first and its
|
||||
// grant only once it is there; when the grant can't be written, the store gets the module it
|
||||
// held back. A failed write of that module is only logged, and the grant then doesn't cover
|
||||
// the new module. A stored module that can't be read back isn't replaced by one that needs a
|
||||
// grant.
|
||||
// held back. A failed write of that module is only logged: the store then holds the new module,
|
||||
// which the grant doesn't cover. A write that reports a failure may still have put the old
|
||||
// module back, so the store is read again to tell which module it holds. A stored module that
|
||||
// can't be read back isn't replaced by one that needs a grant.
|
||||
let previous = s.stored_plugin_bytes(plugin.id());
|
||||
let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes);
|
||||
let holds_new = |s: &Session| s.stored_plugin_bytes(plugin.id()).is_ok_and(|held| held.as_deref() == Some(&bytes[..]));
|
||||
if m.asks_permissions() {
|
||||
let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted };
|
||||
registry::grant(g.clone());
|
||||
if stored && !s.store_grant(&m.id, Some(g)) {
|
||||
s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref());
|
||||
stored = false;
|
||||
stored = !s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()) && holds_new(s);
|
||||
}
|
||||
} else {
|
||||
// This module takes the id over, so nothing the user approved for the id applies any more.
|
||||
@@ -764,7 +765,7 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// stays; one that can't be opened or read may approve the old bytes once it reads again.
|
||||
if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) {
|
||||
let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref()));
|
||||
stored = !back;
|
||||
stored = !back && holds_new(s);
|
||||
if !back || matches!(previous, Ok(None)) {
|
||||
s.ignore_stored_grant(&m.id);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"addon.color.strokeToFill",
|
||||
"addon.color.swatchLegend",
|
||||
"addon.contour.set",
|
||||
"addon.image.removeBackground",
|
||||
"addon.pathtools.addPoints",
|
||||
"addon.pathtools.close",
|
||||
"addon.pathtools.corner",
|
||||
|
||||
@@ -282,6 +282,9 @@ pub struct Session {
|
||||
pub(crate) in_tool_actions: bool,
|
||||
/// While [`Session::preview`] runs its command: its edits are the interaction's own.
|
||||
previewing: bool,
|
||||
/// The images Remove Background decoded during the interaction's preview steps, emptied when
|
||||
/// it ends ([`cmd::DecodedImages`]).
|
||||
pub(crate) decoded_images: cmd::DecodedImages,
|
||||
/// Envelope Options with no envelope selected: the options and fidelity new envelopes get
|
||||
/// (`None`: the reference app's defaults, fidelity 50); not saved.
|
||||
pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>,
|
||||
|
||||
@@ -189,11 +189,15 @@ fn home_folder() -> String {
|
||||
}
|
||||
|
||||
/// The folders `Folder.temp`, `Folder.desktop` and the others name, `null` where there is none.
|
||||
/// On the web every one is `null`, and no platform folder is looked up.
|
||||
fn locations(home: &str) -> Data {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Data::Object(PLACES.iter().map(|k| (k.to_string(), Data::Null)).collect());
|
||||
}
|
||||
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let join = |a: &str, b: &str| Path::new(a).join(b).to_string_lossy().into_owned();
|
||||
let exe = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_string_lossy().into_owned()));
|
||||
let temp = std::env::temp_dir().to_string_lossy().into_owned();
|
||||
let temp = temp_folder().to_string_lossy().into_owned();
|
||||
let (user_data, app_data, common, system, trash) = if WINDOWS {
|
||||
(env("APPDATA"), env("PROGRAMDATA"), env("CommonProgramFiles"), env("SystemRoot"), None)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
@@ -209,7 +213,6 @@ fn locations(home: &str) -> Data {
|
||||
(Some(data.clone()), Some("/usr/share".to_string()), Some("/usr/share".to_string()), Some("/".to_string()), Some(join(&data, "Trash")))
|
||||
};
|
||||
let s = |v: Option<String>| v.map_or(Data::Null, Data::String);
|
||||
let web = cfg!(target_arch = "wasm32");
|
||||
let places = vec![
|
||||
("appData", app_data),
|
||||
("appPackage", exe.clone()),
|
||||
@@ -222,7 +225,15 @@ fn locations(home: &str) -> Data {
|
||||
("trash", trash),
|
||||
("userData", user_data),
|
||||
];
|
||||
Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), if web { Data::Null } else { s(v) })).collect())
|
||||
Data::Object(places.into_iter().map(|(k, v)| (k.to_string(), s(v))).collect())
|
||||
}
|
||||
|
||||
/// The names [`locations`] gives.
|
||||
const PLACES: [&str; 10] = ["appData", "appPackage", "commonFiles", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"];
|
||||
|
||||
/// The folder for temporary files: the platform's, or on the web the root, where nothing is read.
|
||||
fn temp_folder() -> std::path::PathBuf {
|
||||
if cfg!(target_arch = "wasm32") { std::path::PathBuf::from("/") } else { std::env::temp_dir() }
|
||||
}
|
||||
|
||||
/// The start of an absolute path.
|
||||
@@ -349,12 +360,12 @@ impl Pathname {
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` with every character but letters, digits and `-_.!~*'()` written as `%xx` escapes of its
|
||||
/// `s` with every character but letters, digits and `/-_.!~*'()` written as `%xx` escapes of its
|
||||
/// UTF-8 bytes (RFC 2396).
|
||||
fn encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&b) {
|
||||
if b.is_ascii_alphanumeric() || b"/-_.!~*'()".contains(&b) {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{b:02X}"));
|
||||
@@ -842,7 +853,23 @@ fn temp_name(host: &Host, _: &[Data]) -> Result<Data, HostError> {
|
||||
let mut disk = host.disk.borrow_mut();
|
||||
disk.temps += 1;
|
||||
let name = format!("vc{:05}.tmp", disk.temps);
|
||||
Ok(Data::String(std::env::temp_dir().join(name).to_string_lossy().into_owned()))
|
||||
Ok(Data::String(temp_folder().join(name).to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
fn locations_of(host: &Host, _: &[Data]) -> Result<Data, HostError> {
|
||||
Ok(locations(&host.disk.borrow().home))
|
||||
}
|
||||
|
||||
fn encode_text(_: &Host, a: &[Data]) -> Result<Data, HostError> {
|
||||
Ok(Data::String(encode(&arg_text(a, 0))))
|
||||
}
|
||||
|
||||
fn decode_text(_: &Host, a: &[Data]) -> Result<Data, HostError> {
|
||||
Ok(Data::String(decode(&arg_text(a, 0))))
|
||||
}
|
||||
|
||||
fn encoding_available(_: &Host, a: &[Data]) -> Result<Data, HostError> {
|
||||
Ok(Data::Bool(Encoding::parse(&arg_text(a, 0)).is_some()))
|
||||
}
|
||||
|
||||
/// The folder `Folder.current` starts at in the run `host` serves: the script's own folder.
|
||||
@@ -851,10 +878,6 @@ fn start_folder(host: &Host, _: &[Data]) -> Result<Data, HostError> {
|
||||
Ok(Data::String(disk.pathname(&disk.start, &disk.start, false).fs()))
|
||||
}
|
||||
|
||||
fn locations_of(host: &Host, _: &[Data]) -> Result<Data, HostError> {
|
||||
Ok(locations(&host.disk.borrow().home))
|
||||
}
|
||||
|
||||
/// Adds `File` and `Folder` to the realm, their host functions acting for the run `link` holds.
|
||||
pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunError> {
|
||||
let unchecked = |f: fn(&Host, &[Data]) -> Result<Data, HostError>| {
|
||||
@@ -871,7 +894,6 @@ pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunEr
|
||||
("noPicker".into(), value(NO_PICKER)),
|
||||
("badMode".into(), value(BAD_MODE)),
|
||||
]);
|
||||
let pure = |f: fn(&str) -> Data| HostItem::Function(Rc::new(move |a| Ok(f(&arg_text(&a, 0)))));
|
||||
let system = if WINDOWS {
|
||||
"Windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
@@ -884,10 +906,10 @@ pub(super) fn install(realm: &mut Realm, link: &super::Link) -> Result<(), RunEr
|
||||
("system".into(), value(system)),
|
||||
("lineFeed".into(), value(if WINDOWS { "Windows" } else { "Unix" })),
|
||||
("current".into(), unchecked(start_folder)),
|
||||
("locations".into(), unchecked(locations_of)),
|
||||
("encode".into(), pure(|s| Data::String(encode(s)))),
|
||||
("decode".into(), pure(|s| Data::String(decode(s)))),
|
||||
("encodingAvailable".into(), pure(|s| Data::Bool(Encoding::parse(s).is_some()))),
|
||||
("locations".into(), call(link, locations_of)),
|
||||
("encode".into(), call(link, encode_text)),
|
||||
("decode".into(), call(link, decode_text)),
|
||||
("encodingAvailable".into(), call(link, encoding_available)),
|
||||
("parse".into(), call(link, parse)),
|
||||
("relative".into(), call(link, relative)),
|
||||
("tempName".into(), call(link, temp_name)),
|
||||
@@ -955,7 +977,7 @@ mod tests {
|
||||
#[test]
|
||||
fn uri_escapes_are_utf8_both_ways() {
|
||||
assert_eq!(encode("caf\u{e9} 1"), "caf%C3%A9%201");
|
||||
assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()%2Fb");
|
||||
assert_eq!(encode("a-_.!~*'()/b"), "a-_.!~*'()/b");
|
||||
assert_eq!(decode("caf%C3%A9%20x"), "caf\u{e9} x");
|
||||
assert_eq!(decode("100%"), "100%");
|
||||
assert_eq!(decode("%E9"), "%E9", "a lone Latin-1 escape is not UTF-8");
|
||||
|
||||
@@ -115,6 +115,7 @@ impl Session {
|
||||
recovery: Default::default(),
|
||||
in_tool_actions: false,
|
||||
previewing: false,
|
||||
decoded_images: Default::default(),
|
||||
envelope_defaults: None,
|
||||
liquify_stroke: None,
|
||||
plane_widget_press: false,
|
||||
|
||||
@@ -335,6 +335,7 @@ impl Session {
|
||||
|
||||
pub fn commit_interaction(&mut self) -> Result<()> {
|
||||
self.clear_deferred_overlay_cache();
|
||||
self.decoded_images.empty();
|
||||
if let Some(paint) = self.active().and_then(|st| st.interaction.as_ref()).and_then(|it| it.preview_paint.clone()) {
|
||||
self.paint = paint;
|
||||
}
|
||||
@@ -414,6 +415,7 @@ impl Session {
|
||||
|
||||
pub fn cancel_interaction(&mut self) -> Result<()> {
|
||||
self.clear_deferred_overlay_cache();
|
||||
self.decoded_images.empty();
|
||||
self.pending_paint = None;
|
||||
if let Some(paint) = self
|
||||
.active()
|
||||
|
||||
@@ -190,6 +190,9 @@ fn lock_disabled(_: &Path) -> Option<std::fs::File> {
|
||||
thread_local! {
|
||||
/// Tests: [`Session::put_back_plugin`] fails on this thread, as on a full volume.
|
||||
pub(crate) static PUT_BACK_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Tests: [`Session::put_back_plugin`] writes the old module back on this thread and then
|
||||
/// reports a failure, as when the last flush of a copy fails.
|
||||
pub(crate) static PUT_BACK_FAILS_AFTER_WRITE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Tests: moving a plug-in's settings and cache back from `<folder>.removed` ([`move_back`])
|
||||
/// fails on this thread, as when another program holds a file in the folder.
|
||||
pub(crate) static MOVE_BACK_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
@@ -504,16 +507,21 @@ impl Session {
|
||||
|
||||
/// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a
|
||||
/// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. → whether the
|
||||
/// store now holds `previous`.
|
||||
/// write reported success; a write that reports a failure may still have put `previous` back.
|
||||
pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) -> bool {
|
||||
#[cfg(test)]
|
||||
if PUT_BACK_FAILS.get() {
|
||||
return false;
|
||||
}
|
||||
match previous {
|
||||
let back = match previous {
|
||||
Some(bytes) => self.store_plugin(id, bytes),
|
||||
None => self.unstore_plugin(id),
|
||||
};
|
||||
#[cfg(test)]
|
||||
if PUT_BACK_FAILS_AFTER_WRITE.get() {
|
||||
return false;
|
||||
}
|
||||
back
|
||||
}
|
||||
|
||||
/// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it
|
||||
|
||||
@@ -115,6 +115,9 @@ fn constrain_angle_draws_boxes_along_the_turned_axes() {
|
||||
let mut s = new_doc();
|
||||
set_pref(&mut s, "constrainAngle", json!(90));
|
||||
for tool in ["rectangle", "ellipse"] {
|
||||
// The shape drawn before stays selected, and a press on its box's corner handle would
|
||||
// scale it instead of drawing.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
// 60 right and 20 down on the page: along axes turned 90°, 20 wide and 60 high.
|
||||
gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 130.0, 110.0), (Drag, 160.0, 120.0), (Up, 160.0, 120.0)]);
|
||||
|
||||
@@ -215,6 +215,291 @@ fn type_picks_up_font_and_size() {
|
||||
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, preserved_alignment, "#00ff00".into()));
|
||||
}
|
||||
|
||||
/// Sample document-space typography between text objects with different local transforms.
|
||||
#[test]
|
||||
fn scaled_type_pick_matches_effective_character_dimensions() {
|
||||
for source_area in [false, true] {
|
||||
for target_area in [false, true] {
|
||||
let mut s = session();
|
||||
let create = |s: &mut Session, area: bool, x: f64| {
|
||||
let mut p = json!({"x": x, "y": 100, "text": "Sample", "size": 12});
|
||||
if area {
|
||||
p["area"] = json!({"width": 160, "height": 80});
|
||||
}
|
||||
id_of(s.execute("text.create", &p).unwrap())
|
||||
};
|
||||
let src = create(&mut s, source_area, 100.0);
|
||||
s.execute("object.transform", &json!({"matrix": [3, 0, 0, 2, 0, 0]})).unwrap();
|
||||
let dst = create(&mut s, target_area, 300.0);
|
||||
s.execute("object.transform", &json!({"matrix": [0.5, 0, 0, 1.5, 0, 0]})).unwrap();
|
||||
let before = node(&s, dst);
|
||||
let source = node(&s, src);
|
||||
let history = undo_len(&s);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let after = node(&s, dst);
|
||||
let NodeKind::Text(t) = &after.kind else { panic!("not text") };
|
||||
let NodeKind::Text(old) = &before.kind else { panic!("not text") };
|
||||
let effective = t.effective_char_style(&t.first_style()).unwrap();
|
||||
assert!((effective.size - 24.0).abs() < 1e-8, "sampled font size: {}", effective.size);
|
||||
assert!((effective.h_scale - 150.0).abs() < 1e-8, "sampled horizontal scale: {}", effective.h_scale);
|
||||
assert_eq!(t.xf, old.xf, "sampling keeps the target's transform");
|
||||
assert_eq!(node(&s, src), source);
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, dst), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, dst), after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text object snapshot, including the local attributes and retained affine transform.
|
||||
fn sampled_type(s: &Session, id: NodeId) -> vectorcraft_doc::TextObject {
|
||||
let NodeKind::Text(t) = node(s, id).kind else { panic!("not text") };
|
||||
*t
|
||||
}
|
||||
|
||||
/// Compare the dimensional character attributes that text controls display in document units.
|
||||
fn assert_sampled_dimensions(actual: &vectorcraft_doc::CharStyle, expected: &vectorcraft_doc::CharStyle) {
|
||||
assert_eq!(actual.leading.is_some(), expected.leading.is_some());
|
||||
for (a, e) in [
|
||||
(actual.size, expected.size),
|
||||
(actual.h_scale, expected.h_scale),
|
||||
(actual.v_scale, expected.v_scale),
|
||||
(actual.leading.unwrap_or(0.0), expected.leading.unwrap_or(0.0)),
|
||||
(actual.baseline_shift, expected.baseline_shift),
|
||||
(actual.tcy_up_down, expected.tcy_up_down),
|
||||
(actual.tcy_left_right, expected.tcy_left_right),
|
||||
] {
|
||||
assert!((a - e).abs() < 1e-8, "sampled dimension: {a} != {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotation, shear, reflection and grouping stay on the objects while typography is copied.
|
||||
#[test]
|
||||
fn scaled_type_pick_preserves_affines_and_document_dimensions() {
|
||||
for grouped in [false, true] {
|
||||
let mut s = session();
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap());
|
||||
s.execute("text.setStyle", &json!({"leading": 18})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"hScale": 80, "vScale": 125, "baselineShift": 3, "tcyUpDown": 2, "tcyLeftRight": -1})).unwrap();
|
||||
if grouped {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("object.transform", &json!({"matrix": [0, -3, -2, 1, 0, 0]})).unwrap();
|
||||
let src_text = sampled_type(&s, src);
|
||||
let expected = src_text.effective_char_style(&src_text.first_style()).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Target", "size": 20})).unwrap());
|
||||
if grouped {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("object.transform", &json!({"matrix": [-1, 0.5, 1, 2, 0, 0]})).unwrap();
|
||||
let before = sampled_type(&s, dst);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let after = sampled_type(&s, dst);
|
||||
assert_sampled_dimensions(&after.effective_char_style(&after.first_style()).unwrap(), &expected);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
assert_eq!(sampled_type(&s, src), src_text);
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
let reopened = vectorcraft_format::load(&bytes).unwrap();
|
||||
let NodeKind::Text(t) = &reopened.node(dst).unwrap().kind else { panic!("not text") };
|
||||
assert_sampled_dimensions(&t.effective_char_style(&t.first_style()).unwrap(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// A transformed target compensates only the highlighted characters, retaining the others.
|
||||
#[test]
|
||||
fn scaled_type_pick_changes_only_the_highlighted_range() {
|
||||
let mut s = session();
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap());
|
||||
s.execute("object.transform", &json!({"matrix": [3, 0, 0, 2, 0, 0]})).unwrap();
|
||||
let source = sampled_type(&s, src);
|
||||
let expected = source.effective_char_style(&source.first_style()).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Sample", "size": 10})).unwrap());
|
||||
s.execute("object.transform", &json!({"matrix": [0.5, 0, 0, 3, 0, 0]})).unwrap();
|
||||
let before = sampled_type(&s, dst);
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 1, "end": 4}));
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
let history = undo_len(&s);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
let after = sampled_type(&s, dst);
|
||||
assert_eq!(after.runs.len(), 3);
|
||||
assert_eq!((&*after.runs[0].text, &*after.runs[1].text, &*after.runs[2].text), ("S", "amp", "le"));
|
||||
assert_eq!(after.runs[0].style, before.first_style());
|
||||
assert_eq!(after.runs[2].style, before.first_style());
|
||||
assert_sampled_dimensions(&after.effective_char_style(&after.runs[1].style).unwrap(), &expected);
|
||||
assert_eq!(after.xf, before.xf);
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(sampled_type(&s, dst), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(sampled_type(&s, dst), after);
|
||||
}
|
||||
|
||||
/// Reject unusable source or target transforms before any part of a multi-object pick changes.
|
||||
#[test]
|
||||
fn scaled_type_pick_rejects_unrepresentable_dimensions_atomically() {
|
||||
for (bad_source, factor) in [(true, 0.0), (false, 0.0), (false, 1e-306), (true, f64::from_bits(1))] {
|
||||
let mut s = session();
|
||||
let size = if bad_source && factor > 0.0 { 0.1 } else { 24.0 };
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": size, "color": "#00ff00"})).unwrap());
|
||||
if bad_source {
|
||||
s.execute("object.transform", &json!({"matrix": [factor, 0, 0, factor, 0, 0]})).unwrap();
|
||||
}
|
||||
let good = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Good", "size": 10})).unwrap());
|
||||
let other = id_of(s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Other", "size": 12})).unwrap());
|
||||
if !bad_source {
|
||||
s.execute("object.transform", &json!({"matrix": [factor, 0, 0, factor, 0, 0]})).unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [good.0, other.0]})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let paint = s.paint.clone();
|
||||
let sample = s.eyedropper_sample.clone();
|
||||
let history = undo_len(&s);
|
||||
let error = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err();
|
||||
assert!(error.to_string().contains("transform"), "{error}");
|
||||
assert_eq!(s.doc().unwrap().doc, doc);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert_eq!(s.paint, paint);
|
||||
assert_eq!(s.eyedropper_sample, sample);
|
||||
assert_eq!(undo_len(&s), history);
|
||||
// A paint-only pick does not need a usable text transform.
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"character": false, "paragraph": false}})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed dimensional pick retains highlighted characters and the previously remembered sample.
|
||||
#[test]
|
||||
fn scaled_type_pick_rejection_preserves_the_highlighted_range_and_sample() {
|
||||
let mut s = session();
|
||||
let saved = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Saved", "size": 18})).unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": saved.0})).unwrap();
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Source", "size": 24, "color": "#00ff00"})).unwrap());
|
||||
s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Sample", "size": 10})).unwrap());
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 1, "end": 4}));
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let range = s.tool_options();
|
||||
let paint = s.paint.clone();
|
||||
let sample = s.eyedropper_sample.clone();
|
||||
let history = undo_len(&s);
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, doc);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert_eq!(s.tool_options(), range);
|
||||
assert_eq!(s.paint, paint);
|
||||
assert_eq!(s.eyedropper_sample, sample);
|
||||
assert_eq!(undo_len(&s), history);
|
||||
// Alt-click can still apply the last successful sample after the rejected pick.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap();
|
||||
assert_eq!(sampled_type(&s, dst).first_style().size, 18.0);
|
||||
}
|
||||
|
||||
/// A pick that would extend text beyond the canvas keeps the previous sample and defaults.
|
||||
#[test]
|
||||
fn scaled_type_pick_canvas_rejection_preserves_sample_and_defaults() {
|
||||
for mode in ["selection", "ids", "reverse", "range"] {
|
||||
let mut s = session();
|
||||
let saved = id_of(s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Saved", "size": 18, "color": "#0000ff"})).unwrap());
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 96, "color": "#ff0000"})).unwrap());
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": MAX_COORD - 100.0, "y": 200, "text": "WWWW", "size": 10})).unwrap());
|
||||
s.execute("appearance.copyFrom", &json!({"source": saved.0, "ids": []})).unwrap();
|
||||
let pick = match mode {
|
||||
"ids" | "reverse" => {
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
if mode == "ids" { json!({"source": src.0, "ids": [dst.0]}) } else { json!({"source": dst.0, "ids": [src.0], "reverse": true}) }
|
||||
}
|
||||
"range" => {
|
||||
s.select_tool("type", ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("textSelection", &json!({"id": dst.0, "start": 0, "end": 3}));
|
||||
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||
json!({"source": src.0})
|
||||
}
|
||||
_ => json!({"source": src.0}),
|
||||
};
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let range = s.tool_options();
|
||||
let paint = s.paint.clone();
|
||||
let sample = s.eyedropper_sample.clone();
|
||||
let history = undo_len(&s);
|
||||
|
||||
let error = s.execute("appearance.copyFrom", &pick).unwrap_err();
|
||||
assert!(error.to_string().contains("coordinates out of range"), "{mode}: {error}");
|
||||
assert_eq!(s.doc().unwrap().doc, doc, "{mode}");
|
||||
assert_eq!(s.doc().unwrap().selection, selection, "{mode}");
|
||||
assert_eq!(s.tool_options(), range, "{mode}");
|
||||
assert_eq!(s.paint, paint, "{mode}");
|
||||
assert_eq!(s.eyedropper_sample, sample, "{mode}");
|
||||
assert_eq!(undo_len(&s), history, "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Changing justification can move explicitly targeted point text outside the canvas even
|
||||
/// when character copying is disabled.
|
||||
#[test]
|
||||
fn paragraph_only_pick_rejects_out_of_bounds_explicit_target() {
|
||||
let mut s = session();
|
||||
let saved = id_of(s.execute("text.create", &json!({"x": 40, "y": 40, "text": "Saved", "color": "#0000ff"})).unwrap());
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "color": "#ff0000"})).unwrap());
|
||||
s.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 100, "y": 200, "text": "WWWW", "size": 16})).unwrap());
|
||||
s.execute("text.setStyle", &json!({"justify": "right"})).unwrap();
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, MAX_COORD - 101.0, 0]})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": saved.0, "ids": []})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let paint = s.paint.clone();
|
||||
let sample = s.eyedropper_sample.clone();
|
||||
let history = undo_len(&s);
|
||||
|
||||
let pick = json!({"source": src.0, "ids": [dst.0], "apply": {"character": false}});
|
||||
let error = s.execute("appearance.copyFrom", &pick).unwrap_err();
|
||||
assert!(error.to_string().contains("coordinates out of range"), "{error}");
|
||||
assert_eq!(s.doc().unwrap().doc, doc);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert_eq!(s.paint, paint);
|
||||
assert_eq!(s.eyedropper_sample, sample);
|
||||
assert_eq!(undo_len(&s), history);
|
||||
}
|
||||
|
||||
/// Disabled character copying and append preserve typography; reverse and cached picks convert it.
|
||||
#[test]
|
||||
fn scaled_type_pick_respects_options_and_reverse_sampling() {
|
||||
let mut s = session();
|
||||
let src = id_of(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Source", "size": 12})).unwrap());
|
||||
s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap();
|
||||
let source = sampled_type(&s, src);
|
||||
let expected = source.effective_char_style(&source.first_style()).unwrap();
|
||||
let dst = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Target", "size": 20})).unwrap());
|
||||
s.execute("object.scale", &json!({"sx": 50, "sy": 150})).unwrap();
|
||||
let before = sampled_type(&s, dst);
|
||||
for options in [json!({"pickUp": {"character": false}}), json!({"apply": {"character": false}}), json!({"append": true})] {
|
||||
let mut p = json!({"source": src.0, "ids": [dst.0]});
|
||||
p.as_object_mut().unwrap().extend(options.as_object().unwrap().clone());
|
||||
s.execute("appearance.copyFrom", &p).unwrap();
|
||||
assert_sampled_dimensions(&sampled_type(&s, dst).first_style(), &before.first_style());
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap();
|
||||
let after = sampled_type(&s, dst);
|
||||
assert_sampled_dimensions(&after.effective_char_style(&after.first_style()).unwrap(), &expected);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap();
|
||||
s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).unwrap();
|
||||
let cached = sampled_type(&s, dst);
|
||||
assert_sampled_dimensions(&cached.effective_char_style(&cached.first_style()).unwrap(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_applies_the_selection_to_the_clicked_object() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -296,6 +296,59 @@ fn link_info_stays_within_the_asking_callers_roots() {
|
||||
assert_ne!(i["status"], "ok", "{i}");
|
||||
}
|
||||
|
||||
/// A confined caller's `links.list`, `links.check` and `links.info` replies, and its
|
||||
/// `document.open` reply, leave out the path and folder of a linked file outside its read roots
|
||||
/// and keep the row with the file's name. A file found by name inside the roots shows its path.
|
||||
#[test]
|
||||
fn link_paths_outside_the_read_roots_are_left_out() {
|
||||
let away = Folder::new("lrr-away");
|
||||
let home = Folder::new("lrr-home");
|
||||
let pic = away.file("photo.png");
|
||||
let bytes = png(60, 30, RED);
|
||||
write(&pic, &bytes);
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
|
||||
let ask = |s: &mut Session, cmd: &str, p: Value| crate::file_access::confine(Some(&roots), || run(s, cmd, p));
|
||||
let hidden = |r: &Value| assert!(!r.to_string().contains("lrr-away"), "{r}");
|
||||
let list = ask(&mut s, "links.list", json!({}));
|
||||
hidden(&list);
|
||||
let row = &list["links"][0];
|
||||
assert_eq!((row["id"].as_u64(), row["name"].as_str(), row["status"].as_str()), (Some(id.0), Some("photo.png"), Some("missing")), "{list}");
|
||||
let check = ask(&mut s, "links.check", json!({}));
|
||||
hidden(&check);
|
||||
assert_eq!((check["links"][0]["name"].as_str(), check["missing"].as_u64()), (Some("photo.png"), Some(1)), "{check}");
|
||||
let info = ask(&mut s, "links.info", json!({"id": id.0}));
|
||||
hidden(&info);
|
||||
assert_eq!(
|
||||
(info["fileName"].as_str(), info["status"].as_str(), info["linked"].as_bool()),
|
||||
(Some("photo.png"), Some("missing"), Some(true)),
|
||||
"{info}"
|
||||
);
|
||||
for k in ["path", "link", "location", "fileSize", "modified", "created"] {
|
||||
assert!(info.get(k).is_none(), "{k}: {info}");
|
||||
}
|
||||
// Unconfined, the same replies carry the paths.
|
||||
assert_eq!(run(&mut s, "links.list", json!({}))["links"][0]["path"], json!(pic));
|
||||
let i = run(&mut s, "links.info", json!({"id": id.0}));
|
||||
assert_eq!((&i["path"], &i["link"], i["fileSize"].as_u64()), (&json!(pic), &json!(pic), Some(bytes.len() as u64)), "{i}");
|
||||
// Saved inside the roots next to a copy of the file: the copy is found by name and shown.
|
||||
save(&mut s, &home.file("poster.vectorcraft"));
|
||||
let copy = home.file("photo.png");
|
||||
write(©, &bytes);
|
||||
let check = ask(&mut s, "links.check", json!({}));
|
||||
hidden(&check);
|
||||
assert_eq!((check["links"][0]["status"].as_str(), &check["links"][0]["found"]), (Some("ok"), &json!(copy)), "{check}");
|
||||
let list = ask(&mut s, "links.list", json!({}));
|
||||
hidden(&list);
|
||||
assert_eq!(list["links"][0]["found"], json!(copy), "{list}");
|
||||
// Opened again without the copy: the missing link's row has no path.
|
||||
std::fs::remove_file(©).unwrap();
|
||||
let opened = ask(&mut Session::new(), "document.open", json!({"path": home.file("poster.vectorcraft")}));
|
||||
hidden(&opened);
|
||||
assert_eq!(opened["missingLinks"][0]["name"], "photo.png", "{opened}");
|
||||
}
|
||||
|
||||
/// A 600×300 pt image at the centre of the artboard, its placement options set to `options`,
|
||||
/// relinked to a 300×100 px file → the image's id and session.
|
||||
fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) {
|
||||
|
||||
@@ -73,14 +73,13 @@ fn arrowheads_are_part_of_the_outline() {
|
||||
assert_eq!(path_of(&n).subpaths.len(), 1, "line and head are one shape");
|
||||
}
|
||||
|
||||
/// Outline Stroke of a Lens-profile line covers 7/12 of the full-width rectangle.
|
||||
/// Outline Stroke of a Lens-profile line covers 2/3 of the full-width rectangle.
|
||||
#[test]
|
||||
fn a_lens_profile_outlines_to_seven_twelfths_of_the_rectangle() {
|
||||
fn a_lens_profile_outlines_to_two_thirds_of_the_rectangle() {
|
||||
let mut s = session();
|
||||
line(&mut s, json!({"weight": 10, "profile": "lens"}));
|
||||
let n = outline(&mut s);
|
||||
// Each half of the lens swells on a cubic, flat at the middle (#5714).
|
||||
assert!(near(area(&n), 7.0 / 12.0 * 10.0 * 100.0, 2.0), "{}", area(&n));
|
||||
assert!(near(area(&n), 2.0 / 3.0 * 10.0 * 100.0, 2.0), "{}", area(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1060,6 +1060,30 @@ fn link_info_gives_the_artboards_size_not_pixels() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A confined caller's Link Info of a placed document whose file is outside its read roots has no
|
||||
/// `link`, `path` or `location`, and keeps the file's name.
|
||||
#[test]
|
||||
fn link_info_leaves_out_a_placed_documents_path_outside_the_read_roots() {
|
||||
let away = Folder::new("placed-lrr-away");
|
||||
let home = Folder::new("placed-lrr-home");
|
||||
let src = away.file("logo.vectorcraft");
|
||||
source(&src, 100.0, 50.0, RED, 0);
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &src);
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&home.0], &[]).unwrap().unwrap();
|
||||
let i = crate::file_access::confine(Some(&roots), || s.execute("links.info", &json!({"id": id.0}))).unwrap();
|
||||
assert!(!i.to_string().contains("placed-lrr-away"), "{i}");
|
||||
assert_eq!(
|
||||
(i["fileName"].as_str(), i["document"].as_bool(), i["status"].as_str()),
|
||||
(Some("logo.vectorcraft"), Some(true), Some("missing")),
|
||||
"{i}"
|
||||
);
|
||||
let l = crate::file_access::confine(Some(&roots), || s.execute("links.list", &json!({}))).unwrap();
|
||||
assert!(!l.to_string().contains("placed-lrr-away") && l["links"][0]["name"] == "logo.vectorcraft", "{l}");
|
||||
let i = s.execute("links.info", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!((&i["link"], &i["path"]), (&json!(src), &json!(src)), "unconfined: {i}");
|
||||
}
|
||||
|
||||
/// Save As reads its defaults from the catalog; using them must retain current editable links.
|
||||
#[test]
|
||||
fn native_save_catalog_defaults_keep_placed_documents_editable() {
|
||||
|
||||
@@ -2297,6 +2297,194 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() {
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
}
|
||||
|
||||
/// Runs the test `name` of this module again in a process of its own → whether this is that
|
||||
/// process. The list of disabled plug-ins is kept for the whole process, and the other tests here
|
||||
/// read it again each time they attach a plug-in store.
|
||||
fn in_own_process(name: &str) -> bool {
|
||||
const CHILD: &str = "VECTORCRAFT_TEST_PLUGIN_DISABLED_LIST";
|
||||
if std::env::var_os(CHILD).is_some() {
|
||||
return true;
|
||||
}
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", &format!("tests::plugins::{name}"), "--nocapture"])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
|
||||
"{name} failed in its own process:\n{stdout}\n{}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
/// An update whose grant can't be written, and whose old module can't be put back either, stays in
|
||||
/// the store: a disabled plug-in is turned on, as for any update the store holds. The old grant
|
||||
/// doesn't cover the new module, so the next start skips it until it is approved.
|
||||
#[test]
|
||||
fn an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on() {
|
||||
if !in_own_process("an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on") {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_plugins::{permissions::sha256_hex, registry, safe_mode};
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-update");
|
||||
let id = "test.engine.permissions.putbackupdate";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||||
run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false}));
|
||||
let grants = folder.0.join("grants.json");
|
||||
let recorded = std::fs::read(&grants).unwrap();
|
||||
std::fs::remove_file(&grants).unwrap();
|
||||
std::fs::create_dir_all(grants.join("block")).unwrap();
|
||||
|
||||
crate::store::PUT_BACK_FAILS.set(true);
|
||||
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
|
||||
let installed = s.execute("plugin.install", &update);
|
||||
crate::store::PUT_BACK_FAILS.set(false);
|
||||
assert_eq!(installed.unwrap()["hash"], sha256_hex(&changed), "the new module runs in this session");
|
||||
assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&changed)));
|
||||
assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), changed, "the store holds the new module");
|
||||
assert!(safe_mode::disabled().is_empty(), "the module the store holds is turned on");
|
||||
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
|
||||
assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved");
|
||||
|
||||
std::fs::remove_dir_all(&grants).unwrap();
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
registry::remove(id);
|
||||
registry::revoke(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
assert!(registry::get(id).is_none(), "the old grant doesn't cover the new module");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// An update whose grant can't be written, and whose put-back writes the old module and then
|
||||
/// reports a failure, leaves the old module in the store: a disabled plug-in stays disabled, and the
|
||||
/// next start doesn't load it.
|
||||
#[test]
|
||||
fn an_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off() {
|
||||
if !in_own_process("an_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off") {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_plugins::{registry, safe_mode};
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-put-back-fails-after-write");
|
||||
let id = "test.engine.permissions.putbacklate";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||||
run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false}));
|
||||
let disabled = safe_mode::disabled();
|
||||
assert!(!disabled.is_empty());
|
||||
let grants = folder.0.join("grants.json");
|
||||
let recorded = std::fs::read(&grants).unwrap();
|
||||
std::fs::remove_file(&grants).unwrap();
|
||||
std::fs::create_dir_all(grants.join("block")).unwrap();
|
||||
|
||||
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(true);
|
||||
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
|
||||
let installed = s.execute("plugin.install", &update);
|
||||
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(false);
|
||||
installed.unwrap();
|
||||
assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), first, "the store holds the old module");
|
||||
assert_eq!(safe_mode::disabled(), disabled, "the plug-in stays disabled");
|
||||
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
|
||||
assert!(!safe_mode::parse_disabled_file(&saved).is_empty(), "the disabled list on disk is kept");
|
||||
|
||||
std::fs::remove_dir_all(&grants).unwrap();
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
registry::remove(id);
|
||||
registry::revoke(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
assert!(registry::get(id).is_none(), "the disabled old module doesn't load");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// A module without permissions whose install can't remove the old grant, and whose put-back writes
|
||||
/// the old module and then reports a failure, leaves the old module in the store: a disabled plug-in
|
||||
/// stays disabled.
|
||||
#[test]
|
||||
fn a_plain_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off() {
|
||||
if !in_own_process("a_plain_update_put_back_that_fails_after_writing_keeps_a_disabled_plugin_off") {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_plugins::safe_mode;
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-plain-put-back-fails-after-write");
|
||||
let id = "test.engine.permissions.plainputbacklate";
|
||||
let empty = json!({"objects": []});
|
||||
let approved = asking(id, r#"["doc.read"]"#, &empty);
|
||||
let plain = asking(id, "null", &empty);
|
||||
let grants = folder.0.join("grants.json");
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]}));
|
||||
run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false}));
|
||||
let disabled = safe_mode::disabled();
|
||||
assert!(!disabled.is_empty());
|
||||
let file = s.stored_plugin_file(id).unwrap();
|
||||
// `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read.
|
||||
let entry = serde_json::from_slice::<Vec<Value>>(&std::fs::read(&grants).unwrap()).unwrap().remove(0);
|
||||
let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes();
|
||||
std::fs::write(&grants, &full).unwrap();
|
||||
|
||||
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(true);
|
||||
let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true}));
|
||||
crate::store::PUT_BACK_FAILS_AFTER_WRITE.set(false);
|
||||
installed.unwrap();
|
||||
assert_eq!(std::fs::read(&file).unwrap(), approved, "the store holds the old module");
|
||||
assert_eq!(safe_mode::disabled(), disabled, "the plug-in stays disabled");
|
||||
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
|
||||
assert!(!safe_mode::parse_disabled_file(&saved).is_empty(), "the disabled list on disk is kept");
|
||||
|
||||
std::fs::remove_file(&grants).unwrap();
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
safe_mode::set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// A first install whose grant can't be written, and whose module can't be taken out of the store
|
||||
/// again, stays in the store: the plug-in's file is turned on. No grant covers the module, so the
|
||||
/// next start skips it until it is approved.
|
||||
#[test]
|
||||
fn a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on() {
|
||||
if !in_own_process("a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on") {
|
||||
return;
|
||||
}
|
||||
use vectorcraft_plugins::{registry, safe_mode};
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-first");
|
||||
let id = "test.engine.permissions.putbackfirst";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
let module = s.stored_plugin_file(id).unwrap();
|
||||
run(&mut s, "plugin.setEnabled", json!({"file": module, "enabled": false}));
|
||||
let grants = folder.0.join("grants.json");
|
||||
std::fs::create_dir_all(grants.join("block")).unwrap();
|
||||
|
||||
crate::store::PUT_BACK_FAILS.set(true);
|
||||
let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||||
crate::store::PUT_BACK_FAILS.set(false);
|
||||
installed.unwrap();
|
||||
assert!(registry::get(id).is_some(), "the module runs in this session");
|
||||
assert_eq!(std::fs::read(&module).unwrap(), first, "the store holds the module");
|
||||
assert!(safe_mode::disabled().is_empty(), "the file the store holds is turned on");
|
||||
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
|
||||
assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved");
|
||||
|
||||
std::fs::remove_dir_all(&grants).unwrap();
|
||||
registry::remove(id);
|
||||
registry::revoke(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
assert!(registry::get(id).is_none(), "no grant covers the module");
|
||||
safe_mode::set_disabled(vec![]);
|
||||
}
|
||||
|
||||
/// A plug-in folder reports a changed module as needing approval and still loads the other files;
|
||||
/// granted on its own, the changed module loads from the folder. A session without a store uses the
|
||||
/// grants held by the process.
|
||||
|
||||
@@ -55,6 +55,42 @@ fn file_constructor_identity_and_folder_path() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `exists` is true only when the path names an entry of the object's own kind.
|
||||
#[test]
|
||||
fn script_file_exists_checks_the_kind() {
|
||||
let base = folder("kind");
|
||||
write(&base.join("main/data.txt"), b"x");
|
||||
std::fs::create_dir_all(base.join("main/sub")).unwrap();
|
||||
let log =
|
||||
logged(&base, "vc.log([new File('sub').exists, new Folder('data.txt').exists, new File('data.txt').exists, new Folder('sub').exists]);");
|
||||
assert_eq!(log, json!([[false, false, true, true]]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A property a script puts on `Object.prototype` is not read as part of what the app returns.
|
||||
#[test]
|
||||
fn script_file_ignores_inherited_result_fields() {
|
||||
let base = folder("inherited");
|
||||
write(&base.join("main/data.txt"), b"data");
|
||||
let log = logged(
|
||||
&base,
|
||||
"Object.prototype.error = 'inherited'; Object.prototype.exists = true;\n\
|
||||
var f = new File('data.txt'); var opened = f.open('r');\n\
|
||||
vc.log([f.exists, new File('nope').exists, opened, f.error, f.read()]);",
|
||||
);
|
||||
assert_eq!(log, json!([[true, false, true, "", "data"]]));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `File.encode`, `File.decode`, `File.isEncodingAvailable` and the `Folder` locations are counted as
|
||||
/// calls to the app.
|
||||
#[test]
|
||||
fn script_file_class_functions_are_counted() {
|
||||
let source = "File.encode('x'); File.decode('y'); Folder.isEncodingAvailable('UTF-8'); Folder.temp;";
|
||||
let r = Session::new().execute("script.run", &json!({"source": source})).unwrap();
|
||||
assert_eq!(r["hostCallCounts"]["fileCalls"], 4, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_are_uri_encoded_and_current_relative() {
|
||||
let base = folder("names");
|
||||
@@ -74,7 +110,7 @@ fn file_names_are_uri_encoded_and_current_relative() {
|
||||
assert_eq!(
|
||||
log,
|
||||
json!([
|
||||
["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20%2F", "\u{e9}", "sub/y.txt", true, true],
|
||||
["caf%C3%A9%20x.txt", "caf\u{e9} x.txt", "caf%C3%A9%20x.txt", true, "%C3%A9%20/", "\u{e9}", "sub/y.txt", true, true],
|
||||
["sub", "w", "w"],
|
||||
["literal%20name.txt", "literal%2520name.txt", "literal%2520name.txt", true, "p%41", "p%41"]
|
||||
])
|
||||
|
||||
@@ -221,9 +221,9 @@ fn new_width_point_adjust_adjoining_uses_sampled_widths() {
|
||||
let (mut s, id) = line_session();
|
||||
let original = vec![(0.0, 9.0, 9.0), (0.25, 1.0, 0.0), (0.75, 3.0, 0.0), (1.0, 7.0, 7.0)];
|
||||
s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": original})).unwrap();
|
||||
// At t=0.5 the old left side is where the profile's curve is there (#5714) and the right
|
||||
// side is 0pt. Indexed editing of that sampled point is the existing convention: scale the
|
||||
// nonzero side, add from zero.
|
||||
// At t=0.5 the old left side is where the profile's curve is there and the right side is
|
||||
// 0pt. Indexed editing of that sampled point is the existing convention: scale the nonzero
|
||||
// side, add from zero.
|
||||
let sampled = vectorcraft_doc::WidthProfile { points: original }.at(0.5).0;
|
||||
let index = if indexed { Some(set(&mut s, json!({"id": id.0, "t": 0.5, "left": sampled * 5.0, "right": 0}))) } else { None };
|
||||
set(&mut s, json!({"id": id.0, "index": index, "t": 0.5, "left": 20, "right": 5, "adjustAdjoining": true}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF and DXF input,
|
||||
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG, PDF, DXF and HP-GL input,
|
||||
//! mutated raster images (Photoshop documents and JPEG XL images among them) placed with File → Place, and swatch (`.vcswatches`, `.gpl`, `.ase`), graphic style (`.vcstyles`), flattener preset
|
||||
//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions),
|
||||
//! must load as an error or as a document that then renders and exports, without a panic; nor may
|
||||
@@ -2935,6 +2935,73 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- HP-GL plot files: opened and placed ----------
|
||||
|
||||
/// A plot file with every kind of instruction the reader plays, printer escapes around it.
|
||||
const HPGL_SAMPLE: &[u8] = b"\x1b%-12345X@PJL ENTER LANGUAGE=HPGL2\r\n\x1bE\x1b%0BIN;BP1,\"job\";CO\"sample\";NP16;PC9,10,20,30;CR0,100,0,100,0,100;\
|
||||
SP1;PW0.5;WU0;LA1,4,2,4,3,7;LT2,5,1;PA0,0;PD1000,0,1000,1000;PU;LT;PR;PD-500,0,0,-500;PA2000,2000;CI300,5;AA2000,2500,120;AR0,100,-90;\
|
||||
AT2500,2500,3000,2000;RT100,100,200,0;BZ0,100,100,100,100,0;BR10,10,20,10,30,0;UL3,1,2,1;LT-3,2;EA3000,3000;ER100,-100;FT10,40;RA3500,3500;\
|
||||
RR200,200;WG400,30,120;EW-400,0,360;PM0;PD4000,4000,4500,4000,4500,4500;PM1;PU4100,4100;PD4200,4100,4200,4200;PM2;FP1;EP;FT3,10,45;FP;\
|
||||
IP0,0,10160,7620;SC0,100,0,100,1,20,80;PU10,10;PD20,20;SC0,10,0,10,2;PD5,5;SC;RO90;PD100,100;RO;IR10,10,90,90;IW0,0,5000,5000;\
|
||||
DT*,1;LBlabel*SMx;PA0,0;SM;PE:\xc1<=\xbf\xc0\xc2\xc2;LB\x03PG;\x1b%0A\x1b*r1A\x1b*b4W\x01\x02\x03\x04\x1b*rC\x1b%1BPD1,1;\x1b%-12345X";
|
||||
|
||||
/// The sample the mutations start from opens as a plot with art.
|
||||
#[test]
|
||||
fn the_hpgl_sample_opens() {
|
||||
let l = vectorcraft_engine::cmd::fileio::load("x.plt", HPGL_SAMPLE).unwrap();
|
||||
assert_eq!(l.format.id, "hpgl");
|
||||
let mut paths = 0;
|
||||
l.doc.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
|
||||
assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings);
|
||||
}
|
||||
|
||||
/// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers.
|
||||
fn arb_hpgl_instruction() -> impl Strategy<Value = String> {
|
||||
let op = prop::sample::select(vec![
|
||||
"IN", "DF", "PU", "PD", "PA", "PR", "SP", "PW", "WU", "NP", "PC", "CR", "LT", "UL", "LA", "FT", "SC", "IP", "IR", "RO", "CI", "AA", "AR",
|
||||
"AT", "RT", "BZ", "BR", "EA", "ER", "RA", "RR", "EW", "WG", "PM", "FP", "EP", "LB", "DT", "SM", "CO", "PG", "IW", "PE", "ZZ",
|
||||
]);
|
||||
let number = prop_oneof![
|
||||
Just("0".to_string()),
|
||||
Just("-1".to_string()),
|
||||
(-3000i32..3000).prop_map(|v| v.to_string()),
|
||||
Just("99999999999999999999999".to_string()),
|
||||
Just("0.000001".to_string()),
|
||||
Just("32767".to_string()),
|
||||
Just("-32768".to_string()),
|
||||
Just("360".to_string()),
|
||||
];
|
||||
(op, prop::collection::vec(number, 0..8)).prop_map(|(o, n)| format!("{o}{};", n.join(",")))
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn random_hpgl_instructions_never_panic(instructions in prop::collection::vec(arb_hpgl_instruction(), 0..40)) {
|
||||
let bytes = format!("IN;SP1;{}", instructions.concat()).into_bytes();
|
||||
survive_metafile("HP-GL instructions", "x.plt", &bytes)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_hpgl_files_never_panic(cut in 0usize..1200, edits in prop::collection::vec((0usize..1200, any::<u8>()), 0..16)) {
|
||||
let mut bytes = HPGL_SAMPLE.to_vec();
|
||||
let n = bytes.len();
|
||||
for &(at, b) in &edits {
|
||||
bytes[at % n] = b;
|
||||
}
|
||||
bytes.truncate(cut.max(4));
|
||||
survive_metafile("mutated HP-GL", "x.plt", &bytes)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hpgl_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..400)) {
|
||||
let mut b = b"IN;SP1;PD".to_vec();
|
||||
b.extend(bytes);
|
||||
survive_metafile("HP-GL garbage", "x.hpgl", &b)?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- print presets files ----------
|
||||
|
||||
/// Import print presets from `data`, then lay out (and print, when short) the rich document with
|
||||
|
||||
@@ -9,14 +9,18 @@
|
||||
//! a script's own method: a getter, a function, a date, a regular expression, an error, a boxed
|
||||
//! primitive, an array with properties besides its elements, an object whose prototype is not
|
||||
//! `Object.prototype`, a property that is not enumerable or any other object that is not plain
|
||||
//! data throws a `TypeError`, and so does an object that contains itself. A value nested deeper, with more entries or longer text than the
|
||||
//! data throws a `TypeError`, and so does an object that contains itself. A literal makes an
|
||||
//! extensible object whose properties are writable and configurable and an array whose `length`
|
||||
//! is writable, so a property that is not writable or not configurable, an object or array that is
|
||||
//! not extensible (after `Object.preventExtensions`, `Object.seal` or `Object.freeze`) and an
|
||||
//! array with a read-only `length` throw a `TypeError` too. A value nested deeper, with more entries or longer text than the
|
||||
//! realm's [`crate::DataLimits`] allow throws a `RangeError`. Every value written takes fuel.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::{Builder, arg, method};
|
||||
use crate::heap::{Class, NativeFn, ObjId, Prop, key};
|
||||
use crate::heap::{CONFIGURABLE, Class, NativeFn, ObjId, Object, Prop, WRITABLE, key};
|
||||
use crate::interp::{ErrorType, Interp, JsResult};
|
||||
use crate::value::{Value, array_index, index_key, number_to_str};
|
||||
use crate::{ScriptItem, bytecode::Code};
|
||||
@@ -289,10 +293,14 @@ impl Source {
|
||||
Class::Ordinary => return Err(interp.type_error(NOT_PLAIN_OBJECT)),
|
||||
_ => None,
|
||||
};
|
||||
let fixed = shape.as_ref().and(fixed_state(o));
|
||||
let Some(shape) = shape else {
|
||||
let what = format!("toSource: a {} cannot be written as source", interp.object(id)?.class.name());
|
||||
return Err(interp.type_error(&what));
|
||||
};
|
||||
if let Some(what) = fixed {
|
||||
return Err(interp.type_error(what));
|
||||
}
|
||||
self.ancestors.push(id);
|
||||
let r = match shape {
|
||||
Err(length) => self.array(interp, id, length),
|
||||
@@ -347,6 +355,23 @@ const NOT_PLAIN_ARRAY: &str =
|
||||
"toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source";
|
||||
const NOT_PLAIN_OBJECT: &str =
|
||||
"toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source";
|
||||
const NOT_EXTENSIBLE: &str = "toSource: a non-extensible object cannot be written as source";
|
||||
const READ_ONLY_LENGTH: &str = "toSource: an array with a read-only length cannot be written as source";
|
||||
const FIXED_PROPERTY: &str = "toSource: a property that is not writable or configurable cannot be written as source";
|
||||
|
||||
/// Why the literal for the array or plain object `o` would not make it again: `o` is not
|
||||
/// extensible, its `length` is read-only, or one of its data properties is not writable or not
|
||||
/// configurable. A literal makes each of these open.
|
||||
fn fixed_state(o: &Object) -> Option<&'static str> {
|
||||
if !o.extensible {
|
||||
return Some(NOT_EXTENSIBLE);
|
||||
}
|
||||
if matches!(o.class, Class::Array { length_writable: false, .. }) {
|
||||
return Some(READ_ONLY_LENGTH);
|
||||
}
|
||||
let open = WRITABLE | CONFIGURABLE;
|
||||
o.props.iter().any(|(_, p)| matches!(p, Prop::Data { flags, .. } if flags & open != open)).then_some(FIXED_PROPERTY)
|
||||
}
|
||||
|
||||
/// The getter behind `item`; [`ScriptItem::Global`] is a value and has none.
|
||||
pub(crate) fn getter(item: ScriptItem) -> Option<(&'static str, NativeFn)> {
|
||||
|
||||
@@ -126,6 +126,45 @@ fn to_source_refuses_what_is_not_plain_data() {
|
||||
assert!(thrown(&mut r, "toSource({get g() { throw 'ran'; }})").starts_with("TypeError"), "the getter does not run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_source_refuses_flags_and_extensibility_a_literal_does_not_make() {
|
||||
let mut r = realm();
|
||||
let property = "TypeError: toSource: a property that is not writable or configurable cannot be written as source";
|
||||
let extensible = "TypeError: toSource: a non-extensible object cannot be written as source";
|
||||
let length = "TypeError: toSource: an array with a read-only length cannot be written as source";
|
||||
for (source, want) in [
|
||||
("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true, configurable: true}); toSource(o)", property),
|
||||
("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true, writable: true}); toSource(o)", property),
|
||||
("var a = [1, 2]; Object.defineProperty(a, '1', {writable: false}); toSource(a)", property),
|
||||
("var a = [1, 2]; Object.defineProperty(a, '0', {configurable: false}); a.toSource()", property),
|
||||
("var o = {}; Object.defineProperty(o, 'a', {value: 1, enumerable: true}); toSource({x: [{b: o}]})", property),
|
||||
("toSource(Object.preventExtensions({}))", extensible),
|
||||
("toSource(Object.preventExtensions([]))", extensible),
|
||||
("toSource(Object.seal({a: 1}))", extensible),
|
||||
("toSource(Object.freeze({a: 1}))", extensible),
|
||||
("toSource(Object.seal([1]))", extensible),
|
||||
("toSource(Object.freeze([1]))", extensible),
|
||||
("toSource([1, {b: Object.freeze({})}])", extensible),
|
||||
("var a = []; Object.defineProperty(a, 'length', {writable: false}); toSource(a)", length),
|
||||
("var a = [1, , 3]; Object.defineProperty(a, 'length', {writable: false}); toSource({a: a})", length),
|
||||
] {
|
||||
assert_eq!(thrown(&mut r, source), want, "{source}");
|
||||
}
|
||||
// The refused states are ones the literal would lose: evaluated, it is open again.
|
||||
let lost = "var o = Object.freeze({a: 1}); var w = eval('({\"a\":1})');
|
||||
[Object.isExtensible(o), Object.isExtensible(w), Object.getOwnPropertyDescriptor(w, 'a').writable].join()";
|
||||
assert_eq!(text(&mut r, lost), "false,true,true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_source_writes_properties_defined_with_every_flag_on() {
|
||||
let mut r = realm();
|
||||
let source = "var o = {}; Object.defineProperty(o, 'a', {value: 1, writable: true, enumerable: true, configurable: true});
|
||||
var a = [0]; Object.defineProperty(a, '0', {value: 2, writable: true, enumerable: true, configurable: true});
|
||||
toSource({o: o, a: a, e: {}, f: [], s: [1, , 3]})";
|
||||
assert_eq!(text(&mut r, source), r#"({"o":({"a":1}), "a":[2], "e":({}), "f":[], "s":[1, , 3]})"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_source_of_a_string_with_many_escapes_throws_a_range_error_before_the_heap_limit() {
|
||||
let mut r = realm();
|
||||
|
||||
@@ -70,8 +70,13 @@ fn message(i: &mut Interp, name: &str) -> JsResult<Value> {
|
||||
i.get_value(&text, &key(name))
|
||||
}
|
||||
|
||||
/// The field `name` of a record the host gave: its own data property, `undefined` when it has none,
|
||||
/// so that a property a script put on `Object.prototype` is not read as part of it.
|
||||
fn field(i: &mut Interp, v: &Value, name: &str) -> JsResult<Value> {
|
||||
i.get_value(v, &key(name))
|
||||
match v.as_object() {
|
||||
Some(o) => slot(i, o, name),
|
||||
None => Ok(Value::Undefined),
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of the own data property `name` of `o`, `undefined` when it has none.
|
||||
@@ -310,8 +315,19 @@ fn parent(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value>
|
||||
instance(i, proto, up)
|
||||
}
|
||||
|
||||
/// `exists`: whether the path names something of the object's own kind, a folder for a `Folder`
|
||||
/// and anything else for a `File`.
|
||||
fn exists(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
|
||||
Ok(Value::Bool(stat_field(i, &this, "exists", "exists", Value::Bool(false))?.truthy()))
|
||||
let folder = classes(i)?.folder_proto;
|
||||
let want_dir = match this.as_object() {
|
||||
Some(o) => i.object(o)?.proto == Some(folder),
|
||||
None => false,
|
||||
};
|
||||
let Some(r) = stat(i, &this, "exists")? else { return Ok(Value::Bool(false)) };
|
||||
i.rooted(std::slice::from_ref(&r), |i| {
|
||||
let dir = field(i, &r, "dir")?.truthy();
|
||||
Ok(Value::Bool(field(i, &r, "exists")?.truthy() && dir == want_dir))
|
||||
})
|
||||
}
|
||||
|
||||
fn created(i: &mut Interp, this: Value, _: &[Value], _: bool) -> JsResult<Value> {
|
||||
@@ -607,7 +623,7 @@ fn get_files(i: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResult<V
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let entries = field(i, &r, "entries")?;
|
||||
let length = field(i, &entries, "length")?;
|
||||
let length = i.get_value(&entries, &key("length"))?;
|
||||
let n = i.to_number(&length)?;
|
||||
i.reserve_heap(OBJECT_BYTES)?;
|
||||
let proto = i.intrinsics.array_proto;
|
||||
|
||||
@@ -1966,6 +1966,10 @@ fn drawing_gestures_snap_to_smart_guides() {
|
||||
let b = &made["bounds"];
|
||||
[&b["x"], &b["y"], &b["width"], &b["height"]].map(|v| v.as_f64().unwrap())
|
||||
};
|
||||
// The rectangle drawn first stays selected, and the press lands on its box's corner handle,
|
||||
// which would scale it instead of drawing.
|
||||
let r = call(&mut s, 30, "run_command", json!({"command": "select.none", "params": {}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let events = json!([{"kind": "move", "x": 202, "y": 203}, {"kind": "down", "x": 202, "y": 203}, {"kind": "drag", "x": 330, "y": 341}, {"kind": "up", "x": 330, "y": 341}]);
|
||||
assert_eq!(bounds(&mut s, 3, events, "rectangle"), [200.0, 200.0, 130.0, 141.0]);
|
||||
let r = call(&mut s, 5, "run_command", json!({"command": "select.none", "params": {}}));
|
||||
|
||||
@@ -17,6 +17,9 @@ const MAX_BULGE_SAMPLES: f64 = 1024.0;
|
||||
/// About how many points of a long piece its first fit uses.
|
||||
const COARSE_POINTS: usize = 16;
|
||||
|
||||
/// Most times [`fit_through_rec`] splits a run of points, which bounds its recursion.
|
||||
const MAX_SPLITS: usize = 40;
|
||||
|
||||
/// Unit vector or `None` if (nearly) zero.
|
||||
pub(crate) fn unit(v: Vec2) -> Option<Vec2> {
|
||||
let l = v.hypot();
|
||||
@@ -37,7 +40,7 @@ pub(crate) fn end_tangent(c: &CubicBez) -> Vec2 {
|
||||
///
|
||||
/// `t0` is the unit tangent leaving `pts[0]`; `t1` is the unit tangent arriving at the last point
|
||||
/// (forward direction). Consecutive output cubics are G1 continuous.
|
||||
pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<CubicBez> {
|
||||
pub fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<CubicBez> {
|
||||
let pts = dedup(pts);
|
||||
if pts.len() < 2 {
|
||||
return Vec::new();
|
||||
@@ -48,6 +51,43 @@ pub(crate) fn fit_cubics(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<Cub
|
||||
join_pieces(&pts, &pieces, t0, tol)
|
||||
}
|
||||
|
||||
/// Fit a chain of cubics through `pts`, sampled from a smooth curve, with maximum error `tol` at
|
||||
/// the points.
|
||||
///
|
||||
/// Unlike [`fit_cubics`], a piece may leave the chords between the points: the points lie on the
|
||||
/// curve being fitted, so the piece bows with them (each end keeps its tangent, and a two-point
|
||||
/// piece gets handles a third of its chord long). Use this for dense samples of a known curve, and
|
||||
/// [`fit_cubics`] for the samples of a polyline. `t0` and `t1` are as in [`fit_cubics`].
|
||||
pub fn fit_cubics_through(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> Vec<CubicBez> {
|
||||
let pts = dedup(pts);
|
||||
let mut out = Vec::new();
|
||||
if pts.len() < 2 {
|
||||
return out;
|
||||
}
|
||||
fit_through_rec(&pts, t0, t1, tol.max(1e-9), &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// Fits all of `pts` with one cubic, splitting at its worst point until each piece is within `tol`
|
||||
/// of its points (see [`fit_cubics_through`]). `depth` bounds the recursion on hostile points.
|
||||
fn fit_through_rec(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64, out: &mut Vec<CubicBez>, depth: usize) {
|
||||
let n = pts.len();
|
||||
if n == 2 {
|
||||
let d = pts[0].distance(pts[1]) / 3.0;
|
||||
out.push(CubicBez::new(pts[0], pts[0] + t0 * d, pts[1] - t1 * d, pts[1]));
|
||||
return;
|
||||
}
|
||||
let (curve, err, split) = fit_single_tol(pts, t0, t1, tol);
|
||||
if err <= tol || depth >= MAX_SPLITS {
|
||||
out.push(curve);
|
||||
return;
|
||||
}
|
||||
let split = split.clamp(1, n - 2);
|
||||
let tc = unit(pts[split + 1] - pts[split - 1]).unwrap_or(t0);
|
||||
fit_through_rec(&pts[..=split], t0, tc, tol, out, depth + 1);
|
||||
fit_through_rec(&pts[split..], tc, t1, tol, out, depth + 1);
|
||||
}
|
||||
|
||||
/// The curves of `pieces`, which follow each other along `pts` from its first point, leaving it
|
||||
/// along `t0`. Each curve takes up to [`MAX_JOINS`] of the pieces after its first one, as long as
|
||||
/// the fit of them together is within `tol`.
|
||||
@@ -152,6 +192,32 @@ pub(crate) fn fit_single(pts: &[Point], t0: Vec2, t1: Vec2) -> (CubicBez, f64, u
|
||||
fit_single_from(pts, chord_params(pts), t0, t1)
|
||||
}
|
||||
|
||||
/// [`fit_single`] that stops reparameterising once the curve is within `tol` of its points: a
|
||||
/// curve already that close needs no better fit, and each Newton round reads every point.
|
||||
fn fit_single_tol(pts: &[Point], t0: Vec2, t1: Vec2, tol: f64) -> (CubicBez, f64, usize) {
|
||||
let mut u = chord_params(pts);
|
||||
let mut best = generate(pts, &u, t0, t1);
|
||||
let (mut best_err, mut best_split) = max_error(pts, &best, &u);
|
||||
for _ in 0..MAX_REPARAM {
|
||||
if best_err <= tol {
|
||||
break;
|
||||
}
|
||||
for (i, ui) in u.iter_mut().enumerate() {
|
||||
*ui = newton(&best, pts[i], *ui);
|
||||
}
|
||||
let c = generate(pts, &u, t0, t1);
|
||||
let (e, s) = max_error(pts, &c, &u);
|
||||
if e < best_err {
|
||||
best = c;
|
||||
best_err = e;
|
||||
best_split = s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(best, best_err, best_split)
|
||||
}
|
||||
|
||||
/// [`fit_single`] starting from the parameters `u` (one per point, 0 to 1) instead of the
|
||||
/// chord-length ones.
|
||||
pub(crate) fn fit_single_from(pts: &[Point], u: Vec<f64>, t0: Vec2, t1: Vec2) -> (CubicBez, f64, usize) {
|
||||
@@ -334,6 +400,25 @@ mod tests {
|
||||
assert!(out[0].p1.distance(c.p1) < 1.0, "{:?}", out[0]);
|
||||
}
|
||||
|
||||
/// A curve's samples are followed between the points, not chopped to the chords through
|
||||
/// them: a smooth outline fitted from coarse samples must keep its bow (an outline's area
|
||||
/// depends on it), unlike a polyline's samples.
|
||||
#[test]
|
||||
fn through_fit_bows_between_coarse_points() {
|
||||
// The parabola y = 4t(1 - t) over x = 100t, sampled at 8 points.
|
||||
let node = |t: f64| Point::new(100.0 * t, 4.0 * t * (1.0 - t));
|
||||
let pts: Vec<Point> = (0..=8).map(|i| node(i as f64 / 8.0)).collect();
|
||||
let (t0, t1) = (Vec2::new(1.0, 0.04).normalize(), Vec2::new(1.0, -0.04).normalize());
|
||||
let out = fit_cubics_through(&pts, t0, t1, 0.001);
|
||||
for c in &out {
|
||||
for k in 0..=100 {
|
||||
let q = c.eval(k as f64 / 100.0);
|
||||
let want = 4.0 * (q.x / 100.0) * (1.0 - q.x / 100.0);
|
||||
assert!((q.y - want).abs() < 0.01, "{:?} at {q:?} wants {want}", c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_when_needed() {
|
||||
// A zig-zag cannot be fitted with one cubic.
|
||||
|
||||
@@ -26,6 +26,7 @@ pub use edit::{
|
||||
remove_anchor, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, smooth_options,
|
||||
split_into_grid,
|
||||
};
|
||||
pub use fit::{fit_cubics, fit_cubics_through};
|
||||
pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path};
|
||||
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions, regions_within};
|
||||
pub use planar::{
|
||||
|
||||
@@ -63,9 +63,11 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
|
||||
// Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it).
|
||||
let mut warnings = set.warnings();
|
||||
if raised.is_some() {
|
||||
warnings.push(
|
||||
"PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6".into(),
|
||||
);
|
||||
warnings.push(if opts.settings.compatibility == Compatibility::Pdf14 {
|
||||
"PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6".into()
|
||||
} else {
|
||||
"PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6".into()
|
||||
});
|
||||
}
|
||||
let native = opts.native.as_deref().filter(|_| set.preserve_editing);
|
||||
if let Some(native) = native {
|
||||
@@ -199,9 +201,9 @@ fn writes_user_units(set: &PdfSettings) -> bool {
|
||||
}
|
||||
|
||||
/// `long_pages_in_points` is on in a file without a standard. PDF/X files write long pages in
|
||||
/// points anyway, and PDF/A files from PDF 1.5 on keep the `UserUnit`, which keeps their page
|
||||
/// boundaries within 14,400 units (ISO 19005-2, 6.1.13). A PDF/A file at PDF 1.4 has no
|
||||
/// `UserUnit` and writes long pages in points either way.
|
||||
/// points anyway, and PDF/A files keep the `UserUnit`, which keeps their page boundaries within
|
||||
/// 14,400 units (ISO 19005-2, 6.1.13). A PDF/A file at PDF 1.4 is written as PDF 1.6 to have one
|
||||
/// ([`for_long_pages`]).
|
||||
fn long_pages_in_points(set: &PdfSettings) -> bool {
|
||||
set.long_pages_in_points && set.standard == Standard::None
|
||||
}
|
||||
@@ -239,10 +241,20 @@ fn for_long_pages(set: &PdfSettings, pages: &[(usize, PageBoxes)]) -> Result<Opt
|
||||
}
|
||||
let raised = (set.compatibility == Compatibility::Pdf15 && !set.standard.is_pdfx() && !long_pages_in_points(set))
|
||||
.then(|| PdfSettings { compatibility: Compatibility::Pdf16, ..set.clone() });
|
||||
// PDF/A-2 limits page boundaries to 14,400 units (ISO 19005-2, 6.1.13), and PDF 1.4 has no
|
||||
// `UserUnit`: a PDF/A file at PDF 1.4 is written as PDF 1.6, with the `UserUnit` whether or
|
||||
// not `long_pages_in_points` is on.
|
||||
let raised = raised.or_else(|| {
|
||||
(set.standard == Standard::PdfA2b && set.compatibility == Compatibility::Pdf14).then(|| PdfSettings {
|
||||
compatibility: Compatibility::Pdf16,
|
||||
long_pages_in_points: false,
|
||||
..set.clone()
|
||||
})
|
||||
});
|
||||
// The writer's test for a whole number, on the `f32` it writes.
|
||||
let real = |side: f64| side > MAX_PDF14_REAL && (side as f32) as i32 as f32 != side as f32;
|
||||
if let Some((i, b)) = pages.iter().find(|(_, b)| real(b.media.width()) || real(b.media.height())) {
|
||||
page_too_long(set, *i, b.media.width().max(b.media.height()))?;
|
||||
page_too_long(raised.as_ref().unwrap_or(set), *i, b.media.width().max(b.media.height()))?;
|
||||
}
|
||||
Ok(raised)
|
||||
}
|
||||
|
||||
@@ -256,14 +256,48 @@ fn every_preset_and_version_keeps_a_20_m_page_or_says_why_not() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// PDF/X-4 and PDF/A-2b files at PDF 1.4 have no `UserUnit` either.
|
||||
// PDF/X-4 files at PDF 1.4 have no `UserUnit` either; PDF/A-2b files at PDF 1.4 are written as
|
||||
// PDF 1.6 to have one.
|
||||
let at_14 = |standard| report(&d, PdfSettings { standard, compatibility: Compatibility::Pdf14, ..PdfSettings::default() });
|
||||
refused(at_14(Standard::PdfX4).err().unwrap(), "PDF 1.4");
|
||||
refused(at_14(Standard::PdfA2b).err().unwrap(), "PDF 1.4");
|
||||
let r = at_14(Standard::PdfA2b).unwrap();
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.6"));
|
||||
assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), [3.938]);
|
||||
}
|
||||
|
||||
/// PDF/A-2 limits page boundaries to 14,400 units (ISO 19005-2, 6.1.13), and PDF 1.4 has no
|
||||
/// `UserUnit`: a PDF/A-2b file at PDF 1.4 with a page longer than 14,400 pt is written as PDF 1.6,
|
||||
/// with a `UserUnit` that keeps the page within 14,400 units and a warning, with
|
||||
/// `long_pages_in_points` on or off. Pages past 32,767 pt that aren't whole numbers of points are
|
||||
/// written too. A file whose pages are all 14,400 pt or shorter stays PDF 1.4.
|
||||
#[test]
|
||||
fn pdfa2b_at_pdf_1_4_writes_a_long_page_as_pdf_1_6_within_14400_units() {
|
||||
for in_points in [false, true] {
|
||||
let set = PdfSettings {
|
||||
standard: Standard::PdfA2b,
|
||||
compatibility: Compatibility::Pdf14,
|
||||
long_pages_in_points: in_points,
|
||||
..PdfSettings::default()
|
||||
};
|
||||
for side in [17_000.0, 32_767.0, 32_767.5, 40_000.0, TWENTY_M] {
|
||||
let r = report(&sized(side, 300.0), set.clone()).unwrap_or_else(|e| panic!("{side} {in_points}: {e}"));
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.6"), "{side} {in_points}");
|
||||
let unit = raw_numbers(&r.bytes, "/UserUnit", 1);
|
||||
assert_eq!(unit.len(), 1, "{side} {in_points}");
|
||||
let media = raw_numbers(&r.bytes, "/MediaBox", 4);
|
||||
assert!(media[2] - media[0] <= 14_400.0 && media[3] - media[1] <= 14_400.0, "{side} {in_points}: {media:?}");
|
||||
assert!(((media[2] - media[0]) * unit[0] - side).abs() < side * 0.001, "{side} {in_points}: {media:?} {unit:?}");
|
||||
let raised = "PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6";
|
||||
assert!(r.warnings.iter().any(|w| w == raised), "{side} {in_points}: {:?}", r.warnings);
|
||||
}
|
||||
let r = report(&sized(14_400.0, 300.0), set.clone()).unwrap();
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.4"), "{in_points}");
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("has no UserUnit")), "{in_points}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// A page from 14,400 to 32,767 pt long is written in points, with the warning, in PDF 1.3 and 1.4
|
||||
/// files, PDF/X-1a, PDF/X-3, PDF/X-4 and PDF/A-2b files among them, as before the warning. Past
|
||||
/// files, PDF/X-1a, PDF/X-3 and PDF/X-4 files among them, as before the warning. Past
|
||||
/// 32,767 pt a page of whole points is written too, as the writer puts whole numbers as integers;
|
||||
/// a side that isn't a whole number, or art that needs a real number past 32,767, gets the error.
|
||||
#[test]
|
||||
@@ -276,7 +310,6 @@ fn pdf_1_4_writes_a_page_up_to_32767_pt_in_points() {
|
||||
("PDF/X-1a:2001", preset("PDF/X-1a:2001")),
|
||||
("PDF/X-3:2002", preset("PDF/X-3:2002")),
|
||||
("PDF/X-4:2010", PdfSettings { standard: Standard::PdfX4, compatibility: Compatibility::Pdf14, ..PdfSettings::default() }),
|
||||
("PDF/A-2b", PdfSettings { standard: Standard::PdfA2b, compatibility: Compatibility::Pdf14, ..PdfSettings::default() }),
|
||||
];
|
||||
for (label, set) in &sets {
|
||||
for side in [17_000.0, 32_767.0, 40_000.0] {
|
||||
@@ -287,8 +320,8 @@ fn pdf_1_4_writes_a_page_up_to_32767_pt_in_points() {
|
||||
let points = format!("{label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in");
|
||||
assert!(w.len() == 1 && w[0].ends_with(&points), "{label}: {w:?}");
|
||||
}
|
||||
// For PDF/X-4 and PDF/A-2b files the error names the PDF version.
|
||||
let label = if matches!(*label, "PDF/X-4:2010" | "PDF/A-2b") { "PDF 1.4" } else { label };
|
||||
// For PDF/X-4 files the error names the PDF version.
|
||||
let label = if *label == "PDF/X-4:2010" { "PDF 1.4" } else { label };
|
||||
let refused = |e: PdfError, side: &str| {
|
||||
let refusal = format!("{label} files hold real numbers up to 32,767, and the page of artboard 1, {side} pt long, needs larger ones");
|
||||
assert!(e.to_string().contains(&refusal), "{e}");
|
||||
|
||||
@@ -92,6 +92,11 @@ fn a_long_profile_stroke_keeps_its_widths_when_outlined_near_the_view() {
|
||||
let near = render(&mut renderer, &d, at);
|
||||
assert_eq!(renderer.strokes.values().any(|e| e.crop.is_some()), cropped, "{name}: crop choice");
|
||||
let full = whole(&d, at);
|
||||
// The cropped outline fits cubics through the same samples, but its fit starts at the
|
||||
// crop rather than at the subpath's end, so a cubic may put its control points
|
||||
// differently (the same curve). The rasteriser then flattens it into a slightly
|
||||
// different polygon, which shows as one level on an anti-aliased edge pixel. Anything
|
||||
// more would be a wrong width.
|
||||
let mismatched: Vec<_> = near
|
||||
.pixels
|
||||
.as_chunks::<4>()
|
||||
@@ -99,7 +104,7 @@ fn a_long_profile_stroke_keeps_its_widths_when_outlined_near_the_view() {
|
||||
.iter()
|
||||
.zip(full.pixels.as_chunks::<4>().0.iter())
|
||||
.enumerate()
|
||||
.filter(|(_, (a, b))| a != b)
|
||||
.filter(|(_, (a, b))| a.iter().zip(b.iter()).any(|(a, b)| a.abs_diff(*b) > 1))
|
||||
.take(8)
|
||||
.map(|(i, (a, b))| (i % SIZE as usize, i / SIZE as usize, a.to_vec(), b.to_vec()))
|
||||
.collect();
|
||||
|
||||
+130
-24
@@ -11,19 +11,20 @@
|
||||
//! The selection keeps its bounding box and its Live Shape widgets on show with the Rectangle,
|
||||
//! Rounded Rectangle, Ellipse, Polygon and Star tools, so a shape just drawn can be adjusted at
|
||||
//! once: a drag on a live rectangle's or polygon's corner widget rounds its corners and a drag on
|
||||
//! a live ellipse's pie widget sets its pie angle, as with the Selection tool; a press on the
|
||||
//! selection's centre point (Show Center) moves it, and one just outside a corner of its box turns
|
||||
//! it about that centre, as the Selection tool's rotate zone does; a press anywhere else draws a
|
||||
//! new shape (#5477).
|
||||
//! a live ellipse's pie widget sets its pie angle, as with the Selection tool; a drag on one of the
|
||||
//! box's eight handles scales the selection, a press on the selection's centre point (Show Center)
|
||||
//! moves it, and one just outside a corner of its box turns it about that centre, as the Selection
|
||||
//! tool's handles, centre point and rotate zone do; a press anywhere else draws a new shape (#5477).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::OrientedBox;
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::{move_delta, rotate_for_drag};
|
||||
use crate::bbox::{Handle, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::corners::{self, CornerDrag};
|
||||
use crate::guides::{DrawSnap, Leave, square};
|
||||
use crate::guides::{DrawSnap, Leave, PointSnap, Targets, square};
|
||||
use crate::pie::{self, PieDrag};
|
||||
use crate::select::{MoveSnap, hit_center, matrix_json, rotate_zone, shown_box};
|
||||
use crate::select::{MoveSnap, handle_cursor, hit_center, is_area_type, matrix_json, rotate_zone, shown_box};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// The largest corner radius the Rounded Rectangle tool draws, points: the range of General ›
|
||||
@@ -31,6 +32,22 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
|
||||
/// value stop there too.
|
||||
pub const MAX_CORNER_RADIUS: f64 = 1296.0;
|
||||
|
||||
/// How far (screen points, along each axis) from a bounding-box handle's centre a shape tool's
|
||||
/// press scales the selection: half the drawn handle square, which is 6 points wide at the default
|
||||
/// Selection & Anchor Display › Size 3 and a point wider or narrower per step. The Selection tool
|
||||
/// takes 5 points.
|
||||
fn handle_reach(cx: &ToolContext) -> f64 {
|
||||
(6.0 + f64::from(cx.anchor_size.clamp(1, 7)) - 3.0) / 2.0
|
||||
}
|
||||
|
||||
/// A document offset `d` in screen points: across and down the window.
|
||||
fn screen_offset(cx: &ToolContext, d: Vec2) -> (f64, f64) {
|
||||
match cx.screen {
|
||||
Some(s) if s.right.hypot2() > 0.0 && s.down.hypot2() > 0.0 => (d.dot(s.right) / s.right.hypot2(), d.dot(s.down) / s.down.hypot2()),
|
||||
_ => (d.x * cx.zoom, d.y * cx.zoom),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does tool `id` keep the selection's bounding box and Live Shape widgets on show and let its
|
||||
/// corner and pie widgets be dragged (#5477)? The shape tools that draw a box: not Line Segment.
|
||||
pub fn shows_shape_widgets(id: &str) -> bool {
|
||||
@@ -43,9 +60,15 @@ enum WidgetDrag {
|
||||
Pie(PieDrag),
|
||||
}
|
||||
|
||||
/// A drag of the selection itself, taken by a shape tool: the centre point moves it and the zone
|
||||
/// just outside a corner of its box turns it, as the Selection tool's drags do (#5477).
|
||||
/// A drag of the selection itself, taken by a shape tool: a handle of its box scales it, the centre
|
||||
/// point moves it and the zone just outside a corner of its box turns it, as the Selection tool's
|
||||
/// drags do (#5477).
|
||||
enum SelectionDrag {
|
||||
/// Scaling the selection by `handle` of its box `bx`, as it was when the press came. `areas`:
|
||||
/// area type is selected, and its frame resizes while its type keeps its size. `snap` holds
|
||||
/// what the moved handle snaps to. `last`: where the last drag was, and whether Backtick was
|
||||
/// down then.
|
||||
Scale { handle: Handle, bx: OrientedBox, areas: bool, snap: Box<PointSnap>, last: Option<(Point, bool)> },
|
||||
/// Moving the selection by the centre point `hold`: `start` is where the press was, and the
|
||||
/// move has begun once the pointer left the drag threshold.
|
||||
Move { start: Point, hold: Point, began: bool, snap: Box<MoveSnap> },
|
||||
@@ -178,6 +201,22 @@ impl ShapeTool {
|
||||
rotate_zone(cx, &self.box_of(cx)?, p)
|
||||
}
|
||||
|
||||
/// The handle of the selection's box under `p`, with the box: a press there scales the
|
||||
/// selection, as with the Selection tool. A shape tool takes only the drawn handle square
|
||||
/// ([`handle_reach`]), which stays square to the screen when the box or the view is turned, so
|
||||
/// a press just beside a handle still draws a new shape.
|
||||
fn over_handle(&self, cx: &ToolContext, p: Point) -> Option<(Handle, OrientedBox)> {
|
||||
let bx = self.box_of(cx)?;
|
||||
let reach = handle_reach(cx);
|
||||
Handle::ALL
|
||||
.into_iter()
|
||||
.find(|h| {
|
||||
let (x, y) = screen_offset(cx, p - bx.to_doc() * h.pos(bx.rect));
|
||||
x.abs() <= reach && y.abs() <= reach
|
||||
})
|
||||
.map(|h| (h, bx))
|
||||
}
|
||||
|
||||
/// Is `p` over the centre point of a selected object (Attributes › Show Center), where a press
|
||||
/// moves the selection? The point shows whatever View › Show Bounding Box says, so the press
|
||||
/// takes it too.
|
||||
@@ -185,22 +224,35 @@ impl ShapeTool {
|
||||
shows_shape_widgets(self.id) && hit_center(cx, p, cx.point_tol()).is_some()
|
||||
}
|
||||
|
||||
/// A press on the selection's centre point or just outside a corner of its box: the drag moves
|
||||
/// or turns the selection instead of drawing a new shape (#5477). A turn begins with the press,
|
||||
/// as the Selection tool's does; a move begins once the pointer leaves the drag threshold.
|
||||
/// A press on a handle of the selection's box, on its centre point or just outside a corner of
|
||||
/// its box: the drag scales, moves or turns the selection instead of drawing a new shape
|
||||
/// (#5477). The handles come first, as with the Selection tool. A scale or a turn begins with
|
||||
/// the press, as the Selection tool's do; a move begins once the pointer leaves the drag
|
||||
/// threshold.
|
||||
fn press_selection(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option<Vec<Action>> {
|
||||
if !shows_shape_widgets(self.id) {
|
||||
return None;
|
||||
}
|
||||
self.selection = match hit_center(cx, ev.pos, cx.point_tol()) {
|
||||
Some((_, hold)) => Some(SelectionDrag::Move { start: ev.pos, hold, began: false, snap: Box::new(MoveSnap::new(cx)) }),
|
||||
None => self.over_rotate(cx, ev.pos).map(|center| SelectionDrag::Rotate { center, start: ev.pos }),
|
||||
self.selection = if let Some((handle, bx)) = self.over_handle(cx, ev.pos) {
|
||||
let is_area = |id: &vectorcraft_doc::NodeId| cx.doc.node(*id).is_some_and(is_area_type);
|
||||
let areas = cx.selection.objects.iter().any(is_area);
|
||||
Some(SelectionDrag::Scale { handle, bx, areas, snap: Box::new(PointSnap::new(cx, || Targets::for_scale(cx))), last: None })
|
||||
} else {
|
||||
match hit_center(cx, ev.pos, cx.point_tol()) {
|
||||
Some((_, hold)) => Some(SelectionDrag::Move { start: ev.pos, hold, began: false, snap: Box::new(MoveSnap::new(cx)) }),
|
||||
None => self.over_rotate(cx, ev.pos).map(|center| SelectionDrag::Rotate { center, start: ev.pos }),
|
||||
}
|
||||
};
|
||||
self.selection.as_ref()?;
|
||||
self.snap.clear();
|
||||
self.guides.clear();
|
||||
self.measure = None;
|
||||
Some(match self.selection {
|
||||
// Only area type: the step resizes type areas; anything else scales.
|
||||
Some(SelectionDrag::Scale { areas, .. }) => {
|
||||
let only_areas = areas && cx.selection.objects.iter().all(|id| cx.doc.node(*id).is_some_and(is_area_type));
|
||||
vec![Action::Begin(if only_areas { "Resize Type Area" } else { "Scale" }.into())]
|
||||
}
|
||||
Some(SelectionDrag::Rotate { .. }) => vec![Action::Begin("Rotate".into())],
|
||||
_ => vec![],
|
||||
})
|
||||
@@ -235,6 +287,32 @@ impl ShapeTool {
|
||||
self.measure = cx.transform_tools_guides.then(|| (ev.pos, format!("{:.1}°", -deg)));
|
||||
vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(a), "copy": false}))]
|
||||
}
|
||||
(PointerKind::Drag, SelectionDrag::Scale { handle, bx, areas, snap, last }) => {
|
||||
// As the Selection tool scales: in the box's own frame, the moved handle snapped,
|
||||
// and with Use Preview Bounds by the visual box's size unless the drag flips it.
|
||||
*last = Some((ev.pos, ev.backquote));
|
||||
let m = ev.mods;
|
||||
let a = scale_for_drag(bx.rect, *handle, bx.to_local(ev.pos), m.shift, m.alt);
|
||||
let (a, guides) = snap.snap_scale(cx, bx, *handle, a, m.shift, m.alt);
|
||||
self.guides = guides;
|
||||
let nr = a.transform_rect_bbox(bx.rect);
|
||||
self.measure = cx.transform_tools_guides.then(|| (ev.pos, cx.size_label(nr.width(), nr.height())));
|
||||
let [sx, _, _, sy, _, _] = a.as_coeffs();
|
||||
let (cmd, mut params) = if !ev.backquote && cx.preview_bounds && sx > 0.0 && sy > 0.0 {
|
||||
let reference = if m.alt { 4 } else { handle.opposite().reference() };
|
||||
("object.setBounds", json!({ "width": nr.width(), "height": nr.height(), "reference": reference, "copy": false }))
|
||||
} else {
|
||||
("object.transform", json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }))
|
||||
};
|
||||
if *areas {
|
||||
params["typeAreas"] = json!(true);
|
||||
}
|
||||
if ev.backquote {
|
||||
params["objects"] = json!(false);
|
||||
params["patterns"] = json!(true);
|
||||
}
|
||||
vec![Action::Preview(cmd.into(), params)]
|
||||
}
|
||||
(PointerKind::Up, SelectionDrag::Move { began, .. }) => {
|
||||
let began = *began;
|
||||
self.selection = None;
|
||||
@@ -243,8 +321,22 @@ impl ShapeTool {
|
||||
self.snapped = false;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
(PointerKind::Up, SelectionDrag::Scale { last, .. }) => {
|
||||
// Backtick pressed or let go after the last drag: the scale is previewed again at
|
||||
// that drag's position with the key as it is at the release, as the Selection tool
|
||||
// does.
|
||||
let again = last.filter(|(_, backquote)| *backquote != ev.backquote).map(|(pos, _)| pos);
|
||||
let mut out =
|
||||
again.and_then(|pos| self.selection_pointer(cx, &PointerEvent { kind: PointerKind::Drag, pos, ..*ev })).unwrap_or_default();
|
||||
self.selection = None;
|
||||
self.guides.clear();
|
||||
self.measure = None;
|
||||
out.push(Action::Commit);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, SelectionDrag::Rotate { .. }) => {
|
||||
self.selection = None;
|
||||
self.guides.clear();
|
||||
self.measure = None;
|
||||
vec![Action::Commit]
|
||||
}
|
||||
@@ -422,8 +514,9 @@ impl Tool for ShapeTool {
|
||||
fn busy(&self) -> bool {
|
||||
self.start.is_some() || self.widget.is_some() || self.selection.is_some()
|
||||
}
|
||||
/// A widget or selection drag takes the pointer; otherwise a press draws, unless it lands on
|
||||
/// the selection's centre point or in the rotate zone just outside its box's corner.
|
||||
/// A widget or selection drag takes the pointer; otherwise a press draws, unless it lands on a
|
||||
/// handle of the selection's box, its centre point or the rotate zone just outside its box's
|
||||
/// corner.
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.widget_pointer(cx, ev) {
|
||||
return out;
|
||||
@@ -434,7 +527,11 @@ impl Tool for ShapeTool {
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
// Over a widget a press would drag, no Smart Guide for a new shape's start.
|
||||
if self.over_widget(cx, ev.pos) || self.over_center(cx, ev.pos) || self.over_rotate(cx, ev.pos).is_some() {
|
||||
if self.over_widget(cx, ev.pos)
|
||||
|| self.over_handle(cx, ev.pos).is_some()
|
||||
|| self.over_center(cx, ev.pos)
|
||||
|| self.over_rotate(cx, ev.pos).is_some()
|
||||
{
|
||||
self.snap.clear();
|
||||
} else {
|
||||
self.snap.hover(cx, ev.pos, &[], None);
|
||||
@@ -566,6 +663,11 @@ impl Tool for ShapeTool {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
/// A drag of a handle of the selection's box: the canvas keeps drawing the box at the changing
|
||||
/// bounds, as for the Selection tool.
|
||||
fn resizing(&self) -> bool {
|
||||
matches!(self.selection, Some(SelectionDrag::Scale { .. }))
|
||||
}
|
||||
/// Adjusting a widget (the canvas hides the bounding box meanwhile, as for the Selection tool).
|
||||
fn transforming(&self) -> bool {
|
||||
match &self.widget {
|
||||
@@ -573,7 +675,7 @@ impl Tool for ShapeTool {
|
||||
Some(WidgetDrag::Pie(d)) => d.adjusting(),
|
||||
None => match &self.selection {
|
||||
Some(SelectionDrag::Move { began, .. }) => *began,
|
||||
Some(SelectionDrag::Rotate { .. }) => true,
|
||||
Some(SelectionDrag::Rotate { .. } | SelectionDrag::Scale { .. }) => true,
|
||||
None => false,
|
||||
},
|
||||
}
|
||||
@@ -630,9 +732,9 @@ impl Tool for ShapeTool {
|
||||
}
|
||||
o
|
||||
}
|
||||
/// The Selection tool's pointer over a Live Shape widget, the selection's centre point or the
|
||||
/// rotate zone just outside its box's corner; the crosshair everywhere else, where a press
|
||||
/// draws.
|
||||
/// The Selection tool's pointer over a Live Shape widget, a handle of the selection's box, its
|
||||
/// centre point or the rotate zone just outside its box's corner; the crosshair everywhere
|
||||
/// else, where a press draws.
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
// Over a widget a press would drag (or dragging one), the Selection tool's pointer for it.
|
||||
let idle = self.start.is_none();
|
||||
@@ -645,13 +747,14 @@ impl Tool for ShapeTool {
|
||||
// move snaps, as the Selection tool's.
|
||||
match &self.selection {
|
||||
Some(SelectionDrag::Rotate { center, .. }) => return Cursor::rotate_about_in_view(*center, p, cx.view_angle()),
|
||||
Some(SelectionDrag::Scale { handle, bx, .. }) => return handle_cursor(*handle, bx.angle - cx.view_angle()),
|
||||
Some(SelectionDrag::Move { began: true, .. }) if m.alt => return Cursor::SelectionCopy,
|
||||
Some(SelectionDrag::Move { began: true, .. }) => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow },
|
||||
Some(_) => return Cursor::Arrow,
|
||||
None => {}
|
||||
}
|
||||
// A press takes the corner and pie widgets first, then the centre point, then the zone
|
||||
// just outside a corner; everywhere else it draws a new shape.
|
||||
// A press takes the corner and pie widgets first, then the box's handles, then the centre
|
||||
// point, then the zone just outside a corner; everywhere else it draws a new shape.
|
||||
if idle {
|
||||
if shows_shape_widgets(self.id) && corners::over_widget(cx, p, false) {
|
||||
return Cursor::CornerRadius;
|
||||
@@ -659,6 +762,9 @@ impl Tool for ShapeTool {
|
||||
if self.over_widget(cx, p) {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
if let Some((handle, bx)) = self.over_handle(cx, p) {
|
||||
return handle_cursor(handle, bx.angle - cx.view_angle());
|
||||
}
|
||||
if self.over_center(cx, p) {
|
||||
return Cursor::Move;
|
||||
}
|
||||
|
||||
@@ -583,6 +583,96 @@ fn a_shape_tool_moves_the_selection_by_its_centre_point_and_turns_it_from_a_corn
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}");
|
||||
}
|
||||
|
||||
/// With a shape tool, a drag on one of the eight handles of the selection's bounding box scales the
|
||||
/// selection, as with the Selection tool, instead of drawing a new shape from the handle.
|
||||
#[test]
|
||||
fn a_shape_tool_scales_the_selection_by_its_bounding_box_handles() {
|
||||
let (d, s) = unfilled_live_rect();
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let corner = Point::new(200.0, 200.0);
|
||||
for id in ["rectangle", "roundedRectangle", "polygon", "star", "ellipse"] {
|
||||
let mut t = ShapeTool::new(id);
|
||||
assert_eq!(t.cursor(&c, corner, Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{id}");
|
||||
// No Smart Guide for a new shape's start over a handle.
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Move, corner.x, corner.y));
|
||||
assert!(t.overlays(&c).is_empty(), "{id}");
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale".into())], "{id}");
|
||||
// A resize: the canvas keeps the box on show at the changing bounds.
|
||||
assert!(t.busy() && t.transforming() && t.resizing(), "{id}");
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 230.0));
|
||||
let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::BottomRight, Point::new(220.0, 230.0), false, false);
|
||||
assert_eq!(a, vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(want), "copy": false}))], "{id}");
|
||||
assert_eq!(t.cursor(&c, Point::new(220.0, 230.0), Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{id}");
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 220.0, 230.0)), vec![Action::Commit], "{id}");
|
||||
assert!(!t.busy() && !t.transforming() && !t.resizing(), "{id}");
|
||||
}
|
||||
// The side handles scale along one axis; Escape turns the scale back.
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 100.0)), vec![Action::Begin("Scale".into())]);
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 80.0));
|
||||
let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::Top, Point::new(150.0, 80.0), false, false);
|
||||
assert_eq!(a, vec![Action::Preview("object.transform".into(), json!({"matrix": matrix_json(want), "copy": false}))]);
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert!(!t.busy());
|
||||
// Backtick pressed after the last drag: the release previews the scale again for the patterns
|
||||
// only, then commits.
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y));
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 230.0));
|
||||
let a = t.pointer(&c, &PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Up, 220.0, 230.0) });
|
||||
let want = scale_for_drag(Rect::new(100.0, 100.0, 200.0, 200.0), Handle::BottomRight, Point::new(220.0, 230.0), false, false);
|
||||
let patterns = json!({"matrix": matrix_json(want), "copy": false, "objects": false, "patterns": true});
|
||||
assert_eq!(a, vec![Action::Preview("object.transform".into(), patterns), Action::Commit]);
|
||||
assert!(!t.busy());
|
||||
// View › Hide Bounding Box hides the handles, and a press there draws a new shape.
|
||||
let hidden = ToolContext { show_bbox: false, ..cx(&d, &s, &p) };
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.cursor(&hidden, corner, Mods::default()), Cursor::Crosshair);
|
||||
t.pointer(&hidden, &PointerEvent::new(PointerKind::Down, corner.x, corner.y));
|
||||
let a = t.pointer(&hidden, &PointerEvent::new(PointerKind::Drag, 240.0, 240.0));
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{a:?}");
|
||||
// The Line Segment tool shows no box, so its press draws.
|
||||
let mut t = ShapeTool::new("lineSegment");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 240.0, 240.0));
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Line".into())), "{a:?}");
|
||||
}
|
||||
|
||||
/// A shape tool's press scales the selection only on the drawn handle square: 3 screen points from
|
||||
/// the handle's centre along each axis at the default Anchor Size, at any zoom. A press 4 points
|
||||
/// from it draws a new shape; at Anchor Size 7 the square is 10 points wide and that press scales.
|
||||
#[test]
|
||||
fn a_shape_tool_draws_from_a_press_just_beside_a_handle() {
|
||||
let (d, s) = unfilled_live_rect();
|
||||
let p = paint();
|
||||
for zoom in [1.0, 4.0] {
|
||||
let c = ToolContext { zoom, ..cx(&d, &s, &p) };
|
||||
let on = Point::new(200.0 + 3.0 / zoom, 200.0 - 3.0 / zoom);
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.cursor(&c, on, Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{zoom}");
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, on.x, on.y)), vec![Action::Begin("Scale".into())], "{zoom}");
|
||||
let beside = Point::new(200.0 + 2.0 / zoom, 200.0 + 4.0 / zoom);
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.cursor(&c, beside, Mods::default()), Cursor::Crosshair, "{zoom}");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, beside.x, beside.y));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 260.0, 260.0));
|
||||
assert_eq!(a.first(), Some(&Action::Begin("Rectangle".into())), "{zoom}: {a:?}");
|
||||
let big = ToolContext { anchor_size: 7, ..c };
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.cursor(&big, beside, Mods::default()), handle_cursor(Handle::BottomRight, 0.0), "{zoom}");
|
||||
assert_eq!(t.pointer(&big, &PointerEvent::new(PointerKind::Down, beside.x, beside.y)), vec![Action::Begin("Scale".into())], "{zoom}");
|
||||
}
|
||||
// In a view turned 45°, the drawn square stays square to the screen: a press 3 screen points
|
||||
// right of and below the handle's centre, 4.24 pt along the box's x axis, scales.
|
||||
let screen = turned_view(45.0);
|
||||
let c = ToolContext { screen, ..cx(&d, &s, &p) };
|
||||
let f = screen.unwrap_or_else(|| panic!("a turned view"));
|
||||
let corner = Point::new(200.0, 200.0) + f.right * 3.0 + f.down * 3.0;
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale".into())]);
|
||||
}
|
||||
|
||||
/// Two selected live rectangles, the second placed so that its top-left Live Corners widget (10 pt
|
||||
/// in along the corner's bisector) lands on the first one's centre point.
|
||||
fn two_live_rects() -> (vectorcraft_doc::Document, Selection) {
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod compounds;
|
||||
pub mod contour;
|
||||
pub mod document;
|
||||
pub mod enclosing_lasso;
|
||||
pub mod image;
|
||||
pub mod pathtools;
|
||||
pub mod pen;
|
||||
pub mod select;
|
||||
@@ -26,6 +27,7 @@ pub fn plugin_menu() -> Vec<Item> {
|
||||
Item::Sub("Contour", contour::menu()),
|
||||
Item::Sub("Document Tools", document::menu()),
|
||||
Item::Sub("Enclosing Lasso", enclosing_lasso::menu()),
|
||||
Item::Sub("Image Tools", image::menu()),
|
||||
Item::Sub("Path Precision Tools", pathtools::menu()),
|
||||
Item::Sub("Selection Tools", select::menu()),
|
||||
Item::Sub("Smart Pen", pen::menu()),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Image Tools in the interface: its submenu (Object › Plug-ins › Image Tools). Remove Background's
|
||||
//! menu item opens the generic command dialog (`ui.paramDialog`) on its options, with a Preview
|
||||
//! (`dialogs::command`).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::menus::Item;
|
||||
|
||||
/// Remove Background's command.
|
||||
pub const REMOVE_BACKGROUND: &str = "addon.image.removeBackground";
|
||||
/// Remove Background › Background: what the command's `background` takes, by name.
|
||||
pub const BACKGROUNDS: &[(&str, &str)] = &[("Edge Color", "edges"), ("Corner Color", "corners"), ("Picked Colors", "colors")];
|
||||
/// Remove Background › Match: what the command's `match` takes, by name.
|
||||
pub const MATCHES: &[(&str, &str)] = &[("Distance", "distance"), ("Each Channel", "channels")];
|
||||
|
||||
/// Object › Plug-ins › Image Tools.
|
||||
pub fn menu() -> Vec<Item> {
|
||||
vec![Item::Cmd(
|
||||
"Remove Background…",
|
||||
REMOVE_BACKGROUND,
|
||||
json!({"background": "edges", "colors": "#ffffff", "contiguous": true, "match": "distance", "tolerance": 10, "softness": 10}),
|
||||
)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,111 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::tests_labels::painted_text;
|
||||
use crate::{VectorcraftApp, menus};
|
||||
|
||||
/// An app with one image open and selected: 12 × 10 pixels, white, a near-white ring one pixel in
|
||||
/// (13.7 % from white) and a red block inside it.
|
||||
fn app() -> VectorcraftApp {
|
||||
let img = image::RgbaImage::from_fn(12, 10, |x, y| {
|
||||
image::Rgba(if (3..9).contains(&x) && (3..7).contains(&y) {
|
||||
[200, 0, 0, 255]
|
||||
} else if (1..11).contains(&x) && (1..9).contains(&y) && (x == 1 || x == 10 || y == 1 || y == 8) {
|
||||
[220, 220, 220, 255]
|
||||
} else {
|
||||
[255; 4]
|
||||
})
|
||||
});
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("document.open", json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// The selected image's pixel key and bytes.
|
||||
fn image(app: &VectorcraftApp) -> (String, Arc<Vec<u8>>) {
|
||||
let st = app.session.doc().unwrap();
|
||||
let Some(NodeKind::Image(im)) = st.selection.objects.first().and_then(|id| st.doc.node(*id)).map(|n| &n.kind) else { panic!("an image") };
|
||||
(im.key.clone(), st.doc.images[&im.key].bytes.clone())
|
||||
}
|
||||
|
||||
/// The alpha of the selected image's pixels at the corner, on the ring and in the block.
|
||||
fn alphas(app: &VectorcraftApp) -> [u8; 3] {
|
||||
let px = image::load_from_memory(&image(app).1).unwrap().to_rgba8();
|
||||
[(0, 0), (1, 1), (4, 4)].map(|(x, y)| px.get_pixel(x, y).0[3])
|
||||
}
|
||||
|
||||
fn open(app: &mut VectorcraftApp) {
|
||||
let entries = menus::menu_entries(app);
|
||||
let item = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Image Tools"] && e.label == "Remove Background…").unwrap();
|
||||
assert!(item.enabled);
|
||||
let (id, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params);
|
||||
menus::invoke(app, &id, p);
|
||||
}
|
||||
|
||||
fn show(app: &mut VectorcraftApp) -> String {
|
||||
painted_text(app, |app, ui| crate::dialogs::show(app, ui.ctx()))
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, key: &str, v: serde_json::Value) {
|
||||
crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), key.into(), v);
|
||||
}
|
||||
|
||||
/// Object › Plug-ins › Image Tools › Remove Background… opens the command dialog on Edge Color
|
||||
/// with Preview off. With Preview on the canvas shows the result for the values in the dialog,
|
||||
/// each change worked out again from the image as it was; Cancel puts the image back with no step
|
||||
/// of history, and OK keeps the result for the values in the dialog as one step.
|
||||
#[test]
|
||||
fn remove_background_previews_cancels_and_runs_on_ok() {
|
||||
let mut app = app();
|
||||
let steps = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
let (start, n) = (image(&app), steps(&app));
|
||||
|
||||
open(&mut app);
|
||||
let d = app.ui.dialog.as_ref().expect("a dialog opens");
|
||||
assert_eq!((d.str("__command"), d.str("__label")), (super::REMOVE_BACKGROUND.to_string(), "Remove Background".to_string()));
|
||||
assert_eq!((d.str("background"), d.bool("preview")), ("edges".to_string(), false));
|
||||
assert!(crate::dialogs::accepts_field(d, "preview"));
|
||||
assert_eq!((d.bool("contiguous"), d.str("match")), (true, "distance".to_string()));
|
||||
let shown = show(&mut app);
|
||||
for s in ["Remove Background", "Edge Color", "Contiguous", "Match", "Distance", "Tolerance", "Softness", "Preview"] {
|
||||
assert!(shown.contains(s), "{s} in {shown}");
|
||||
}
|
||||
assert_eq!(image(&app), start, "no preview without the checkbox");
|
||||
|
||||
// Preview on: at tolerance 10 the ring fades; at 20 it is cleared; at 0, with no softness,
|
||||
// only the white is cleared and the ring is whole again.
|
||||
set(&mut app, "preview", json!(true));
|
||||
show(&mut app);
|
||||
let [corner, ring, block] = alphas(&app);
|
||||
assert!(corner == 0 && ring > 0 && ring < 255 && block == 255, "{:?}", alphas(&app));
|
||||
set(&mut app, "tolerance", json!(20));
|
||||
show(&mut app);
|
||||
assert_eq!(alphas(&app), [0, 0, 255]);
|
||||
set(&mut app, "tolerance", json!(0));
|
||||
set(&mut app, "softness", json!(0));
|
||||
show(&mut app);
|
||||
assert_eq!(alphas(&app), [0, 255, 255], "worked out again from the image as it was");
|
||||
|
||||
// Cancel: as it was, with no step of history.
|
||||
crate::dialogs::cancel(&mut app);
|
||||
assert_eq!((image(&app), steps(&app)), (start.clone(), n), "Cancel restores the image and adds no undo step");
|
||||
assert!(!app.session.in_interaction());
|
||||
|
||||
// OK: the values in the dialog, as one step.
|
||||
open(&mut app);
|
||||
set(&mut app, "preview", json!(true));
|
||||
set(&mut app, "tolerance", json!(20));
|
||||
show(&mut app);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(steps(&app), n + 1, "one undo step");
|
||||
assert_eq!(alphas(&app), [0, 0, 255]);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(image(&app), start);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
|
||||
// shows while the key is down, where its handles take that press.
|
||||
let held = super::drag::mods(p.ctx().input(|i| i.modifiers), false);
|
||||
let lends_selection = !app.session.tool_busy() && app.session.lent_tool_id(&held) == Some("selection");
|
||||
let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && !app.session.tool_busy()))
|
||||
let show_box = (app.session.tool_id() == "selection" || lends_selection || (shape_tool && (!app.session.tool_busy() || resizing)))
|
||||
&& (resizing || !app.session.tool_transforming() || moved.is_some())
|
||||
&& app.ui.view.bounding_box
|
||||
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
|
||||
|
||||
@@ -1173,6 +1173,56 @@ fn a_click_just_outside_the_canvas_does_not_reach_the_tool() {
|
||||
assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]);
|
||||
}
|
||||
|
||||
/// The box-drawing shape tools show the bounding box of the shape just drawn (#5477). A drag of
|
||||
/// its bottom-right handle with the tool still active scales that shape, as with the Selection
|
||||
/// tool, rather than drawing a second shape from the handle.
|
||||
#[test]
|
||||
fn a_shape_tool_scales_the_shape_just_drawn_by_its_bounding_box_handle() {
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
for tool in ["rectangle", "roundedRectangle", "ellipse", "polygon", "star"] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.select_tool(tool);
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let drag = |app: &mut VectorcraftApp, a: Pos2, b: Pos2| {
|
||||
for ev in [
|
||||
egui::Event::PointerMoved(a),
|
||||
button(a, true),
|
||||
egui::Event::PointerMoved(a.lerp(b, 0.5)),
|
||||
egui::Event::PointerMoved(b),
|
||||
button(b, false),
|
||||
] {
|
||||
frame(app, &ctx, vec![ev]);
|
||||
}
|
||||
frame(app, &ctx, vec![]);
|
||||
};
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
drag(&mut app, xf.to_screen(Point::new(150.0, 120.0)), xf.to_screen(Point::new(210.0, 170.0)));
|
||||
let objects = |app: &VectorcraftApp| {
|
||||
let mut n = 0;
|
||||
app.session.active().unwrap().doc.walk(|node| n += usize::from(node.path_data().is_some()));
|
||||
n
|
||||
};
|
||||
assert_eq!(objects(&app), 1, "{tool}: the drag draws one shape");
|
||||
let before = app.selection_box().unwrap_or_else(|| panic!("{tool}: the shape just drawn shows its box"));
|
||||
// The bottom-right handle, dragged 40 pt along the box's width and 30 pt along its height
|
||||
// (a polygon's or star's box turns with the shape).
|
||||
let (corner, to) = (Point::new(before.rect.x1, before.rect.y1), Point::new(before.rect.x1 + 40.0, before.rect.y1 + 30.0));
|
||||
drag(&mut app, xf.to_screen(before.to_doc() * corner), xf.to_screen(before.to_doc() * to));
|
||||
let after = app.selection_box().unwrap_or_else(|| panic!("{tool}: the shape stays selected"));
|
||||
let label = app.session.active().and_then(|st| st.history.undo.last().map(|e| e.label.clone()));
|
||||
assert_eq!(objects(&app), 1, "{tool}: no second shape, last step {label:?}");
|
||||
assert!(
|
||||
(after.rect.width() - before.rect.width() - 40.0).abs() < 0.5 && (after.rect.height() - before.rect.height() - 30.0).abs() < 0.5,
|
||||
"{tool}: {:?} -> {:?}, last step {label:?}",
|
||||
before.rect,
|
||||
after.rect
|
||||
);
|
||||
assert_eq!(label.as_deref(), Some("Scale"), "{tool}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -180,7 +180,8 @@ fn inline_transform_width(ui: &Ui, t: &Tokens) -> f32 {
|
||||
labels + fields + crate::panels::transform::CONSTRAIN_LINK_SIZE + ui.spacing().item_spacing.x * items as f32
|
||||
}
|
||||
|
||||
/// The size of the Control bar's Align, Distribute and area type vertical alignment buttons.
|
||||
/// The size of the Control bar's Align, Distribute and area type vertical alignment buttons, and
|
||||
/// the height of its Align To menu.
|
||||
const BUTTON: f32 = 24.0;
|
||||
|
||||
/// The space ahead of the separator that ends a group of the Control bar's controls.
|
||||
@@ -219,9 +220,9 @@ fn separator_width(ui: &Ui) -> f32 {
|
||||
|
||||
/// The room the controls at the end of the Control bar need: the Transform link, or Document Setup
|
||||
/// and Preferences with nothing selected, or none while the panel menu has that group off. The
|
||||
/// groups ahead of them (the Paragraph section, area type's vertical alignment, the Align and
|
||||
/// Distribute buttons) show only while it is left, so the bar trims them from its right end
|
||||
/// (#5372), after the inline X/Y/W/H fields.
|
||||
/// groups ahead of them (the Paragraph section, area type's vertical alignment, the Align buttons,
|
||||
/// the Align To menu and the Distribute buttons) show only while it is left, so the bar trims them
|
||||
/// from its right end (#5372), after the inline X/Y/W/H fields.
|
||||
fn end_width(app: &VectorcraftApp, ui: &Ui, nothing_selected: bool) -> f32 {
|
||||
if nothing_selected {
|
||||
if group_on(app, "documentOptions") { DOCUMENT_SETUP_WIDTH + ui.spacing().item_spacing.x + PREFERENCES_WIDTH } else { 0.0 }
|
||||
@@ -235,10 +236,10 @@ fn end_width(app: &VectorcraftApp, ui: &Ui, nothing_selected: bool) -> f32 {
|
||||
/// Draws the Control bar's groups between its Character controls and the Transform link, and
|
||||
/// returns whether the Transform link and X/Y/W/H follow (false: nothing is selected, and Document
|
||||
/// Setup and Preferences, drawn here, end the bar, or the Transform group is off). The groups are
|
||||
/// the Paragraph section and area type's vertical alignment (#3759), then the Align buttons and, for
|
||||
/// two or more objects, the
|
||||
/// Distribute buttons (a single object keeps the bar as it was); each shows only while the end of
|
||||
/// the bar keeps its room. Groups the panel menu has off are left out, and take no room.
|
||||
/// the Paragraph section and area type's vertical alignment (#3759), then the Align buttons, the
|
||||
/// Align To menu and, for two or more objects, the Distribute buttons (a single object keeps the bar
|
||||
/// as it was); each shows only while the end of the bar keeps its room. Groups the panel menu has
|
||||
/// off are left out, and take no room.
|
||||
fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize) -> bool {
|
||||
let nothing = objects == 0;
|
||||
let end = end_width(app, ui, nothing);
|
||||
@@ -267,9 +268,12 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize
|
||||
let end = if transform { separator_width(ui) + end } else { 0.0 };
|
||||
if group_on(app, "alignDistribute") && ui.available_width() >= buttons_width(ui, 7) + end {
|
||||
crate::panels::align::align_buttons(app, ui, BUTTON);
|
||||
if objects > 1 && ui.available_width() >= GROUP_GAP + buttons_width(ui, 6) + end {
|
||||
ui.add_space(GROUP_GAP);
|
||||
crate::panels::align::distribute_buttons(app, ui, BUTTON);
|
||||
if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x + end {
|
||||
crate::panels::align::align_to_menu(app, ui, BUTTON);
|
||||
if objects > 1 && ui.available_width() >= GROUP_GAP + buttons_width(ui, 6) + end {
|
||||
ui.add_space(GROUP_GAP);
|
||||
crate::panels::align::distribute_buttons(app, ui, BUTTON);
|
||||
}
|
||||
}
|
||||
if transform {
|
||||
separator(ui);
|
||||
|
||||
@@ -2722,6 +2722,109 @@ fn the_control_bar_shows_the_distribute_buttons_for_two_or_more_objects() {
|
||||
assert_eq!((left(&app, &a), left(&app, &b), left(&app, &c)), (10.0, 105.0, 200.0), "{}", app.ui.status);
|
||||
}
|
||||
|
||||
/// The Control bar's Align To menu follows its Align buttons. It shows the Align panel's Align To
|
||||
/// choice (Artboard for one object, which the buttons align to the artboard) and screen readers
|
||||
/// get it as the value of a combo box named "Align To:". Its menu checks that choice, has Align to
|
||||
/// Key Object off while no key object can be made, and a pick chooses as the Align panel does:
|
||||
/// Artboard and Selection are stored with the UI state and let go of the key object, and Key
|
||||
/// Object makes the topmost selected object the key, keeping the stored choice. The Align
|
||||
/// buttons then align to the choice.
|
||||
#[test]
|
||||
fn the_control_bar_align_to_menu_chooses_what_align_aligns_to() {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
|
||||
let rect = |app: &mut crate::VectorcraftApp, x: f64| {
|
||||
app.run("shape.rectangle", json!({"x": x, "y": 40, "width": 30, "height": 20})).unwrap()["id"].clone()
|
||||
};
|
||||
let (a, b) = (rect(&mut app, 50.0), rect(&mut app, 150.0));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0));
|
||||
let frame = |app: &mut crate::VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default()
|
||||
};
|
||||
type Nodes = Vec<(egui::accesskit::NodeId, egui::accesskit::Node)>;
|
||||
// A control by its name; a menu row's name follows its check column.
|
||||
let find = |nodes: &Nodes, name: &str| {
|
||||
nodes.iter().find(|(_, n)| n.label().is_some_and(|l| l.trim_start_matches(['✓', ' ']) == name)).map(|(_, n)| n.clone())
|
||||
};
|
||||
let center = |n: &egui::accesskit::Node| {
|
||||
let b = n.bounds().unwrap_or_else(|| panic!("{n:?}"));
|
||||
egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
|
||||
};
|
||||
let click = |app: &mut crate::VectorcraftApp, at: egui::Pos2| {
|
||||
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, vec![egui::Event::PointerMoved(at), press(true)]);
|
||||
frame(app, vec![press(false)]);
|
||||
frame(app, vec![]);
|
||||
frame(app, vec![])
|
||||
};
|
||||
let menu = |app: &mut crate::VectorcraftApp| {
|
||||
frame(app, vec![]);
|
||||
let nodes = frame(app, vec![]);
|
||||
find(&nodes, "Align To:").unwrap_or_else(|| panic!("the Control bar has the Align To menu"))
|
||||
};
|
||||
// Opens the menu, checks its rows and picks `pick`, or closes it with Escape.
|
||||
let choose = |app: &mut crate::VectorcraftApp, checked: &str, key_on: bool, pick: Option<&str>| {
|
||||
let at = center(&menu(app));
|
||||
let nodes = click(app, at);
|
||||
for name in ["Align to Selection", "Align to Key Object", "Align to Artboard"] {
|
||||
let row = find(&nodes, name).unwrap_or_else(|| panic!("the menu lists {name}"));
|
||||
assert_eq!(row.label().is_some_and(|l| l.starts_with('✓')), name == checked, "{name} checked");
|
||||
assert_eq!(row.is_disabled(), name == "Align to Key Object" && !key_on, "{name} off");
|
||||
}
|
||||
let nodes = match pick {
|
||||
Some(pick) => click(app, center(&find(&nodes, pick).unwrap_or_else(|| panic!("{pick}")))),
|
||||
None => {
|
||||
let escape =
|
||||
|pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||||
frame(app, vec![escape(true)]);
|
||||
frame(app, vec![escape(false)]);
|
||||
frame(app, vec![])
|
||||
}
|
||||
};
|
||||
assert!(find(&nodes, "Align to Artboard").is_none(), "the menu closes");
|
||||
};
|
||||
let key = |app: &crate::VectorcraftApp| app.session.active().unwrap().selection.key;
|
||||
let x0 = |app: &crate::VectorcraftApp, id: &serde_json::Value| {
|
||||
app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().x0
|
||||
};
|
||||
|
||||
// One object: the buttons align it to the artboard, and Align to Key Object is off.
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
let m = menu(&mut app);
|
||||
assert_eq!(m.role(), egui::accesskit::Role::ComboBox);
|
||||
assert_eq!(m.value(), Some("Align to Artboard"), "one object shows Artboard");
|
||||
choose(&mut app, "Align to Artboard", false, None);
|
||||
assert_eq!(key(&app), None);
|
||||
|
||||
// Two objects: the stored choice, Selection.
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
assert_eq!(menu(&mut app).value(), Some("Align to Selection"));
|
||||
choose(&mut app, "Align to Selection", true, Some("Align to Artboard"));
|
||||
assert_eq!(app.ui.align_to, crate::panels::align::AlignTo::Artboard, "the choice the Align panel shows");
|
||||
assert_eq!(menu(&mut app).value(), Some("Align to Artboard"));
|
||||
let left = find(&frame(&mut app, vec![]), "Horizontal Align Left").unwrap_or_else(|| panic!("Horizontal Align Left"));
|
||||
click(&mut app, center(&left));
|
||||
assert_eq!((x0(&app, &a), x0(&app, &b)), (0.0, 0.0), "aligned to the artboard: {}", app.ui.status);
|
||||
|
||||
// Key Object makes the topmost the key and keeps the stored choice.
|
||||
choose(&mut app, "Align to Artboard", true, Some("Align to Key Object"));
|
||||
assert_eq!(key(&app), Some(vectorcraft_doc::NodeId(b.as_u64().unwrap())), "the topmost object is the key");
|
||||
assert_eq!(app.ui.align_to, crate::panels::align::AlignTo::Artboard, "the stored choice stays");
|
||||
assert_eq!(menu(&mut app).value(), Some("Align to Key Object"));
|
||||
|
||||
// Selection lets go of the key and is stored.
|
||||
choose(&mut app, "Align to Key Object", true, Some("Align to Selection"));
|
||||
assert_eq!(key(&app), None, "the key object is let go");
|
||||
assert_eq!(app.ui.align_to, crate::panels::align::AlignTo::Selection);
|
||||
assert_eq!(menu(&mut app).value(), Some("Align to Selection"));
|
||||
}
|
||||
|
||||
/// The Control bar shows the Global Edit Options dropdown while Start Global Edit can run. Its
|
||||
/// options are the session's, which the Properties panel's dropdown also sets: Match Size from
|
||||
/// the Control bar narrows the Results and the objects Start Global Edit takes. The dropdown is off
|
||||
@@ -2824,7 +2927,8 @@ fn the_control_bar_shows_the_inline_transform_fields_only_when_they_fit() {
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut seen = false;
|
||||
let mut menu_seen = false;
|
||||
// The Align group, then for two objects the Distribute group (#5372): each by its controls.
|
||||
// The Align group and the Align To menu, then for two objects the Distribute group (#5372):
|
||||
// each by its controls.
|
||||
let mut groups: Vec<(&str, Vec<&str>)> = vec![(
|
||||
"Align",
|
||||
vec![
|
||||
@@ -2837,6 +2941,7 @@ fn the_control_bar_shows_the_inline_transform_fields_only_when_they_fit() {
|
||||
"Vertical Align Bottom",
|
||||
],
|
||||
)];
|
||||
groups.push(("Align To", vec!["Align To:"]));
|
||||
if count == 2 {
|
||||
groups.push((
|
||||
"Distribute",
|
||||
|
||||
@@ -26,9 +26,9 @@ 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", "rotation", "language"];
|
||||
const ADVANCED: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation", "language"];
|
||||
/// The number fields of Advanced Character Formats.
|
||||
const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "rotation"];
|
||||
const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "tcyUpDown", "tcyLeftRight", "rotation"];
|
||||
/// The attributes of Character Color.
|
||||
const COLOR: &[&str] = &["fill", "stroke", "stroke_width", "overprint_fill", "overprint_stroke"];
|
||||
/// The fields of Character Color: the fill and stroke paints, their tints in percent, the stroke
|
||||
@@ -78,6 +78,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
"h_scale",
|
||||
"v_scale",
|
||||
"baseline_shift",
|
||||
"tcyUpDown",
|
||||
"tcyLeftRight",
|
||||
"rotation",
|
||||
"language",
|
||||
"fill",
|
||||
@@ -250,6 +252,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result<Option<Value>, String> {
|
||||
"h_scale" => tl!("Horizontal Scale"),
|
||||
"v_scale" => tl!("Vertical Scale"),
|
||||
"baseline_shift" => tl!("Baseline Shift"),
|
||||
"tcyUpDown" => tl!("Tate-chu-yoko Up/Down"),
|
||||
"tcyLeftRight" => tl!("Tate-chu-yoko Left/Right"),
|
||||
"rotation" => tl!("Character Rotation"),
|
||||
"fill_tint" => tl!("Fill Tint"),
|
||||
"stroke_tint" => tl!("Stroke Tint"),
|
||||
@@ -281,7 +285,7 @@ fn optional_number(d: &Dialog, key: &str) -> Result<Option<Value>, String> {
|
||||
if ["h_scale", "v_scale"].contains(&key) && !(1.0..=10000.0).contains(&n) {
|
||||
return Err(format!("{label} must be a number from 1 to 10000"));
|
||||
}
|
||||
if key == "baseline_shift" && !(-1296.0..=1296.0).contains(&n) {
|
||||
if ["baseline_shift", "tcyUpDown", "tcyLeftRight"].contains(&key) && !(-1296.0..=1296.0).contains(&n) {
|
||||
return Err(format!("{label} must be a number from -1296 to 1296"));
|
||||
}
|
||||
// Weight from 0 to the document limit, a tint from 0% to 100%.
|
||||
@@ -564,7 +568,7 @@ pub(crate) fn reset_basic(d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset Panel in Advanced Character Formats: its five attributes become unset.
|
||||
/// Reset Panel in Advanced Character Formats: its seven attributes become unset.
|
||||
pub(crate) fn reset_advanced(d: &mut Dialog) {
|
||||
d.fields.insert("__resetAdvanced".into(), json!(true));
|
||||
for key in ADVANCED_NUMBERS {
|
||||
@@ -718,14 +722,16 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Advanced Character Formats: Horizontal Scale, Vertical Scale, Baseline Shift, Character Rotation
|
||||
/// and Language, each empty (unset) or set.
|
||||
/// Advanced Character Formats: Horizontal Scale, Vertical Scale, Baseline Shift, the Tate-chu-yoko
|
||||
/// Up/Down and Left/Right, Character Rotation 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 [
|
||||
("h_scale", tl!("Horizontal Scale"), "%"),
|
||||
("v_scale", tl!("Vertical Scale"), "%"),
|
||||
("baseline_shift", tl!("Baseline Shift"), "pt"),
|
||||
("tcyUpDown", tl!("Tate-chu-yoko Up/Down"), "pt"),
|
||||
("tcyLeftRight", tl!("Tate-chu-yoko Left/Right"), "pt"),
|
||||
("rotation", tl!("Character Rotation"), "°"),
|
||||
] {
|
||||
let row = ui.label(caption);
|
||||
@@ -1473,11 +1479,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_section_shows_its_five_controls() {
|
||||
fn advanced_section_shows_its_seven_controls() {
|
||||
let mut d = dialog_for(json!({}));
|
||||
let mut app = app();
|
||||
let shown = crate::tests_labels::painted_text(&mut app, |_, ui| advanced_body(ui, &mut d));
|
||||
for label in ["Horizontal Scale", "Vertical Scale", "Baseline Shift", "Character Rotation", "Language:"] {
|
||||
for label in [
|
||||
"Horizontal Scale",
|
||||
"Vertical Scale",
|
||||
"Baseline Shift",
|
||||
"Tate-chu-yoko Up/Down",
|
||||
"Tate-chu-yoko Left/Right",
|
||||
"Character Rotation",
|
||||
"Language:",
|
||||
] {
|
||||
assert!(shown.contains(label), "{label}: {shown}");
|
||||
}
|
||||
}
|
||||
@@ -1569,6 +1583,85 @@ mod tests {
|
||||
assert!(!def.attrs.contains_key("v_scale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tate_chu_yoko_offsets_show_set_reset_and_keep_their_limits() {
|
||||
let d = dialog_for(json!({"tcyUpDown": 2.0, "size": 12.0}));
|
||||
assert_eq!((d.fields["tcyUpDown"].clone(), d.fields["tcyLeftRight"].clone()), (json!(2.0), json!("")));
|
||||
assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tcyUpDown": 2.0, "size": 12.0})));
|
||||
// A value a style captured from scaled text stays while its field is untouched.
|
||||
assert_eq!(edited_attrs(&dialog_for(json!({"tcyLeftRight": 3000.0}))).unwrap(), attrs(json!({"tcyLeftRight": 3000.0})));
|
||||
|
||||
let mut d = dialog_for(json!({"tcyUpDown": 2.0}));
|
||||
d.fields.insert("tcyUpDown".into(), json!(""));
|
||||
d.fields.insert("tcyLeftRight".into(), json!("-1296"));
|
||||
assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tcyLeftRight": -1296.0})));
|
||||
d.fields.insert("tcyLeftRight".into(), json!("0"));
|
||||
assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"tcyLeftRight": 0.0})));
|
||||
for (key, value, message) in [
|
||||
("tcyUpDown", "1296.1", "Tate-chu-yoko Up/Down must be a number from -1296 to 1296"),
|
||||
("tcyLeftRight", "-1296.1", "Tate-chu-yoko Left/Right must be a number from -1296 to 1296"),
|
||||
("tcyUpDown", "high", "Tate-chu-yoko Up/Down must be a number"),
|
||||
("tcyLeftRight", "NaN", "Tate-chu-yoko Left/Right must be a number"),
|
||||
] {
|
||||
let mut d = dialog_for(json!({}));
|
||||
d.fields.insert(key.into(), json!(value));
|
||||
assert_eq!(edited_attrs(&d).unwrap_err(), message, "{key} {value}");
|
||||
}
|
||||
|
||||
let mut d = dialog_for(json!({"tcyUpDown": 2.0, "tcyLeftRight": -1.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})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tate_chu_yoko_offsets_preview_cancel_and_commit_to_the_style_and_its_text() {
|
||||
let mut app = app();
|
||||
app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0, "tcyLeftRight": 0.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();
|
||||
app.run("text.setRangeStyle", json!({"id": id, "start": 2, "end": 4, "tcyLeftRight": 5.0})).unwrap();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
open(&mut app, "Body").unwrap();
|
||||
{
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("section".into(), json!("advanced"));
|
||||
d.fields.insert("tcyUpDown".into(), json!("4"));
|
||||
}
|
||||
frame(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().doc.char_styles[0].attrs["tcyUpDown"], json!(4.0));
|
||||
super::super::cancel(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
|
||||
|
||||
open(&mut app, "Body").unwrap();
|
||||
{
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("tcyUpDown".into(), json!("4"));
|
||||
d.fields.insert("tcyLeftRight".into(), json!("-3"));
|
||||
}
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let state = app.session.doc().unwrap();
|
||||
assert_eq!(state.history.undo.len(), undo + 1);
|
||||
assert_eq!(state.doc.char_styles[0].attrs, attrs(json!({"size": 24.0, "tcyUpDown": 4.0, "tcyLeftRight": -3.0})));
|
||||
let vectorcraft_doc::NodeKind::Text(t) = &state.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.runs.iter().map(|r| (r.style.tcy_up_down, r.style.tcy_left_right)).collect::<Vec<_>>(), [(4.0, -3.0), (4.0, 5.0)]);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
|
||||
app.run("ui.newCharStyle", json!({})).unwrap();
|
||||
{
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("name".into(), json!("Shifted"));
|
||||
d.fields.insert("tcyLeftRight".into(), json!("6"));
|
||||
}
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let doc = &app.session.doc().unwrap().doc;
|
||||
let def = doc.char_styles.iter().find(|def| def.name == "Shifted").unwrap();
|
||||
assert_eq!(def.attrs["tcyLeftRight"], json!(6.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()
|
||||
|
||||
@@ -64,11 +64,12 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
/// Does this dialog show a Preview checkbox? Grid Repeat Options does: the public page for the
|
||||
/// grid options lists one, and the radial and mirror pages do not. Type on a Path Options does
|
||||
/// too (#5360), as public tutorials of the dialog show, and Type Tools › Release Type on a Path
|
||||
/// works the same way (#5532). (Cancel rolls a preview back whichever dialog started it.)
|
||||
/// works the same way (#5532), and so does Image Tools › Remove Background. (Cancel rolls a
|
||||
/// preview back whichever dialog started it.)
|
||||
fn previews(d: &Dialog) -> bool {
|
||||
match d.str("__command").as_str() {
|
||||
"object.repeat.options" => d.fields.contains_key("gridType"),
|
||||
"type.pathOptions" | crate::addons::typetools::RELEASE => true,
|
||||
"type.pathOptions" | crate::addons::typetools::RELEASE | crate::addons::image::REMOVE_BACKGROUND => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -117,6 +118,7 @@ fn order(command: &str) -> &'static [&'static str] {
|
||||
"object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"],
|
||||
"addon.color.printFixes" => &["inkLimit", "excludePureBlack", "removeWhiteOverprint", "overprintBlack", "textOnly", "fill", "stroke"],
|
||||
crate::addons::typetools::RELEASE => &["mode", "keepOriginal"],
|
||||
crate::addons::image::REMOVE_BACKGROUND => &["background", "colors", "contiguous", "match", "tolerance", "softness"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -136,6 +138,8 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
|
||||
("graph.setType", "pieLegend") => Some(PIE_LEGEND),
|
||||
("graph.setType", "piePosition") => Some(PIE_POSITION),
|
||||
("graph.setType", "pieSort") => Some(PIE_SORT),
|
||||
(crate::addons::image::REMOVE_BACKGROUND, "background") => Some(crate::addons::image::BACKGROUNDS),
|
||||
(crate::addons::image::REMOVE_BACKGROUND, "match") => Some(crate::addons::image::MATCHES),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,10 +146,12 @@ fn start_search(app: &mut VectorcraftApp, d: &mut Dialog, folder: &str) -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The files a search found for `row` (`{path, ids}`), best first: the answer's `links` row with
|
||||
/// the same path and ids.
|
||||
/// The files a search found for `row` (`{path?, ids}`), best first: the answer's `links` row with
|
||||
/// the same ids and, when both have one, the same path. A row has no `path` when its file is outside
|
||||
/// an automation client's read roots.
|
||||
fn files_for<'a>(found: &'a [Value], row: &Value) -> &'a [Value] {
|
||||
found.iter().find(|r| r["path"] == row["path"] && r["ids"] == row["ids"]).and_then(|r| r["files"].as_array()).map_or(&[], Vec::as_slice)
|
||||
let same_path = |r: &Value| r["path"].is_null() || row["path"].is_null() || r["path"] == row["path"];
|
||||
found.iter().find(|r| r["ids"] == row["ids"] && same_path(r)).and_then(|r| r["files"].as_array()).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// The file chosen at the start among `files`: the only one that is the same file, else the only
|
||||
|
||||
@@ -339,3 +339,12 @@ fn find_in_folder_asks_for_the_folder_off_the_ui_thread() {
|
||||
searched(&mut app);
|
||||
assert_eq!(pick(app.ui.dialog.as_ref().unwrap(), "here.png"), &json!(here));
|
||||
}
|
||||
|
||||
/// A row whose path `links.check` left out (its file is outside the read roots) still shows the
|
||||
/// files the search found for its objects.
|
||||
#[test]
|
||||
fn a_row_without_its_path_shows_the_files_found_for_its_objects() {
|
||||
let found = [json!({"name": "a.png", "path": "/elsewhere/a.png", "ids": [3], "files": [{"path": "/root/a.png"}]})];
|
||||
assert_eq!(files_for(&found, &json!({"name": "a.png", "ids": [3]})), [json!({"path": "/root/a.png"})]);
|
||||
assert!(files_for(&found, &json!({"name": "a.png", "ids": [4]})).is_empty());
|
||||
}
|
||||
|
||||
@@ -247,6 +247,8 @@ fn new_width_point_profile_and_compound_target() {
|
||||
assert!(d.fields.get("index").is_none_or(Value::is_null));
|
||||
let actual_t = d.f64("t", -1.0);
|
||||
assert!((actual_t - 0.25).abs() < 1e-6, "compound={compound} pointer={quarter:?} actual_t={actual_t} want=0.25");
|
||||
// The two-point profile is linear: at a quarter its sides are 1.25 and 0.75 of
|
||||
// 1..2 and 0.5..1.5, on a 10pt stroke (5pt half).
|
||||
assert!((d.f64("side1", 0.0) - 6.25).abs() < 1e-6);
|
||||
assert!((d.f64("side2", 0.0) - 3.75).abs() < 1e-6);
|
||||
assert!(!d.bool("linked"));
|
||||
|
||||
@@ -686,6 +686,15 @@ font-technology Variable Променливи
|
||||
Created: Създадено:
|
||||
Crop Изрязване
|
||||
Crop Image Изрязване на изображение
|
||||
Image Tools Инструменти за изображения
|
||||
Remove Background… Премахване на фона…
|
||||
Remove Background Премахване на фона
|
||||
Background Фон
|
||||
Edge Color Цвят на ръба
|
||||
Picked Colors Избрани цветове
|
||||
Corner Color Цвят на ъглите
|
||||
Contiguous Съседни
|
||||
Each Channel Всеки канал
|
||||
Edit Path Редактиране на контура
|
||||
Mask Маска
|
||||
Crop Marks Знаци за изрязване
|
||||
@@ -3447,6 +3456,8 @@ digits Malayalam Малаялам
|
||||
Scale at Center Мащабиране от центъра
|
||||
Up/Down: Нагоре/надолу:
|
||||
Left/Right: Наляво/надясно:
|
||||
Tate-chu-yoko Up/Down Тате-чу-йоко нагоре/надолу
|
||||
Tate-chu-yoko Left/Right Тате-чу-йоко наляво/надясно
|
||||
Temperature Температура
|
||||
Temperature/Tint… Температура/Нюанс…
|
||||
Template Шаблон
|
||||
@@ -4365,6 +4376,7 @@ axis Both И двете
|
||||
@msg not a stored central record не е съхранен централен запис
|
||||
@msg not a valid plug-in module: {0} не е валиден модул за плъгин: {0}
|
||||
@msg not an EMF or WMF file не е EMF или WMF файл
|
||||
@msg not an HP-GL file не е HP-GL файл
|
||||
@msg not found не е открито
|
||||
@msg not in pattern editing mode не е в режим на редактиране на мостра
|
||||
@msg finish editing the pattern first първо завършете редактирането на мострата
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -196,6 +196,15 @@
|
||||
Expand… Proširi…
|
||||
Expand Appearance Proširi izgled
|
||||
Crop Image Izreži sliku
|
||||
Image Tools Alati za slike
|
||||
Remove Background… Ukloni pozadinu…
|
||||
Remove Background Ukloni pozadinu
|
||||
Background Pozadina
|
||||
Edge Color Boja ruba
|
||||
Picked Colors Odabrane boje
|
||||
Corner Color Boja uglova
|
||||
Contiguous Susjedni
|
||||
Each Channel Svaki kanal
|
||||
Edit Path Uredi putanju
|
||||
Mask Maska
|
||||
Rasterize… Rasteriziraj…
|
||||
@@ -1564,6 +1573,8 @@ digits Malayalam Malajalam
|
||||
Scale at Center Skaliraj od centra
|
||||
Up/Down: Gore/dolje:
|
||||
Left/Right: Lijevo/desno:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko gore/dolje
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko lijevo/desno
|
||||
Underline Podvučeno
|
||||
Vertical Scale % Okomita skala %
|
||||
Axis Osa
|
||||
@@ -4756,6 +4767,7 @@ imageTrace Outline Obris
|
||||
@msg not a stored central record nije sačuvani centralni zapis
|
||||
@msg not a valid plug-in module: {0} nije važeći modul dodatka: {0}
|
||||
@msg not an EMF or WMF file nije EMF ni WMF datoteka
|
||||
@msg not an HP-GL file nije HP-GL datoteka
|
||||
@msg not an export presets file (format `{_1}`) nije datoteka predložaka izvoza (format `{_1}`)
|
||||
@msg not an export presets file: {e} nije datoteka predložaka izvoza: {e}
|
||||
@msg not found nije pronađeno
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -985,6 +985,15 @@ antiAlias Sharp Nítid
|
||||
CRLF (Windows) CRLF (Windows)
|
||||
Crop Retalla
|
||||
Crop Image Retalla la imatge
|
||||
Image Tools Eines d’imatge
|
||||
Remove Background… Elimina el fons…
|
||||
Remove Background Elimina el fons
|
||||
Background Fons
|
||||
Edge Color Color de la vora
|
||||
Picked Colors Colors triats
|
||||
Corner Color Color de les cantonades
|
||||
Contiguous Contigu
|
||||
Each Channel Cada canal
|
||||
Crop Marks Marques de retall
|
||||
Crop To Retalla fins a
|
||||
Cross Creu
|
||||
@@ -4441,6 +4450,8 @@ digits Malayalam Malaiàlam
|
||||
Scale at Center Escala des del centre
|
||||
Up/Down: Amunt/avall:
|
||||
Left/Right: Esquerra/dreta:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko amunt/avall
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko esquerra/dreta
|
||||
Technical Drawing Dibuix tècnic
|
||||
Temperature Temperatura
|
||||
Temperature/Tint… Temperatura/To…
|
||||
@@ -5368,6 +5379,7 @@ digits Malayalam Malaiàlam
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la pàgina del tauler {n} fa {size}, més que el límit de pàgina del PDF de 14.400 pt (200 in): s’escriu amb un UserUnit de {unit}, de manera que les aplicacions que ignoren UserUnit la mostren tantes vegades més petita
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la pàgina del tauler {n} fa {size}, més que el límit de pàgina del PDF de 14.400 pt (200 in): les pàgines {label} s’escriuen sense UserUnit, en punts, i les aplicacions que respecten el límit retallen la pàgina a 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 el PDF 1.5 no té UserUnit, que una pàgina de més de 14.400 pt (200 in) necessita per mantenir la mida: el fitxer s’escriu com a PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 el PDF 1.4 no té UserUnit, que una pàgina de més de 14.400 pt (200 in) necessita per quedar dins del límit de pàgina de 14.400 unitats del PDF/A-2: el fitxer s’escriu com a PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la pàgina del tauler {n} fa {size}, massa llarga i estreta per als costats de pàgina del PDF de 3 a 14.400 unitats: s'escriu amb una UserUnit de {unit}, que manté el costat curt en 3 unitats o més, i les aplicacions que respecten el límit retallen el costat llarg a 14.400 unitats
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard els fitxers {label} admeten nombres reals fins a {max}, i la pàgina del tauler {n}, de {side} pt, en necessita de més grans: trieu PDF 1.6 o posterior sense estàndard PDF/X, o un tauler més petit
|
||||
@msg Invalid real scale Escala real no vàlida
|
||||
@@ -5604,6 +5616,7 @@ digits Malayalam Malaiàlam
|
||||
@msg not a VectorCraft file: {0} no és un fitxer de VectorCraft: {0}
|
||||
@msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`)
|
||||
@msg not an EMF or WMF file no és un fitxer EMF o WMF
|
||||
@msg not an HP-GL file no és un fitxer HP-GL
|
||||
@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
|
||||
@@ -5754,6 +5767,14 @@ digits Malayalam Malaiàlam
|
||||
@msg exit isolation mode first surt primer del mode d’aïllament
|
||||
@msg select an envelope selecciona un embolcall
|
||||
@msg select an image selecciona una imatge
|
||||
@msg `background` must be edges, corners or colors `background` ha de ser edges, corners o colors
|
||||
@msg `match` must be distance or channels `match` ha de ser distance o channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` ha d’estar entre 0 i 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` ha de llistar d’1 a 16 colors, cadascun en hexadecimal (#rrggbb o #rgb) o com a black, white, red, green o blue
|
||||
@msg the linked image shows its preview: embed or relink it first la imatge enllaçada mostra la previsualització: incrusta-la o torna a enllaçar-la primer
|
||||
@msg a CMYK image has no transparency to set una imatge CMYK no té transparència per establir
|
||||
@msg every pixel matches the background: no image would be left cada píxel coincideix amb el fons: no quedaria cap imatge
|
||||
@msg an image of {w} × {h} pixels is too large to change una imatge de {w} × {h} píxels és massa gran per canviar-la
|
||||
@msg select an Image Trace object selecciona un objecte d’Image Trace
|
||||
@msg select an Intertwine group selecciona un grup Intertwine
|
||||
@msg select an object in perspective selecciona un objecte en perspectiva
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -202,6 +202,15 @@
|
||||
Expand… Rozložit…
|
||||
Expand Appearance Rozložit vzhled
|
||||
Crop Image Oříznout obraz
|
||||
Image Tools Nástroje obrazu
|
||||
Remove Background… Odstranit pozadí…
|
||||
Remove Background Odstranit pozadí
|
||||
Background Pozadí
|
||||
Edge Color Barva okraje
|
||||
Picked Colors Vybrané barvy
|
||||
Corner Color Barva rohů
|
||||
Contiguous Souvislé
|
||||
Each Channel Každý kanál
|
||||
Edit Path Upravit cestu
|
||||
Mask Maska
|
||||
Rasterize… Rastrovat…
|
||||
@@ -1588,6 +1597,8 @@ digits Malayalam Malajálamské
|
||||
Scale at Center Měřítko od středu
|
||||
Up/Down: Nahoru/dolů:
|
||||
Left/Right: Vlevo/vpravo:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko nahoru/dolů
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko vlevo/vpravo
|
||||
Underline Podtržení
|
||||
Vertical Scale % Svislá změna velikosti %
|
||||
Axis Osa
|
||||
@@ -4691,6 +4702,7 @@ imageTrace Outline Obrys
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller stránka pracovní plochy {n} má {size}, což přesahuje limit stránky PDF 14 400 pt (200 in): zapisuje se s UserUnit {unit}, takže aplikace, které UserUnit ignorují, ji zobrazí tolikrát menší
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in stránka pracovní plochy {n} má {size}, což přesahuje limit stránky PDF 14 400 pt (200 in): stránky {label} se zapisují bez UserUnit, v bodech, a aplikace, které limit dodržují, stránku oříznou na 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 nemá UserUnit, který stránka delší než 14 400 pt (200 in) potřebuje, aby si zachovala velikost: soubor se zapíše jako PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 nemá UserUnit, který stránka delší než 14 400 pt (200 in) potřebuje, aby zůstala v limitu stránky PDF/A-2, 14 400 jednotek: soubor se zapíše jako PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units stránka pracovní plochy {n} má rozměr {size} a je příliš dlouhá a úzká pro strany stránky PDF od 3 do 14 400 jednotek: zapisuje se s UserUnit {unit}, která drží kratší stranu na 3 jednotkách nebo více, a aplikace, které limit dodržují, ořežou delší stranu na 14 400 jednotek
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard soubory {label} pojmou reálná čísla nejvýše {max} a stránka pracovní plochy {n}, dlouhá {side} pt, potřebuje větší: zvolte PDF 1.6 nebo novější bez standardu PDF/X, nebo menší pracovní plochu
|
||||
@msg invalid image data for `{0}` neplatná obrazová data pro `{0}`
|
||||
@@ -4838,6 +4850,7 @@ imageTrace Outline Obrys
|
||||
@msg not a stored central record nejde o uložený centrální záznam
|
||||
@msg not a valid plug-in module: {0} neplatný modul zásuvného modulu: {0}
|
||||
@msg not an EMF or WMF file nejde o soubor EMF ani WMF
|
||||
@msg not an HP-GL file nejde o soubor HP-GL
|
||||
@msg not found nenalezeno
|
||||
@msg not in pattern editing mode není aktivní režim úprav vzorku
|
||||
@msg finish editing the pattern first nejprve dokončete úpravy vzorku
|
||||
@@ -4917,6 +4930,14 @@ imageTrace Outline Obrys
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset tato vektorizace by vytvořila více než {max} kotevních bodů, víc, než dokument plynule zobrazí: zvyšte Šum, snižte Cesty nebo Barvy, nebo zvolte přednastavení s nižší věrností
|
||||
@msg select an envelope vyberte obálku
|
||||
@msg select an image vyberte obraz
|
||||
@msg `background` must be edges, corners or colors `background` musí být edges, corners nebo colors
|
||||
@msg `match` must be distance or channels `match` musí být distance nebo channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` musí být od 0 do 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` musí obsahovat 1 až 16 barev, každou šestnáctkově (#rrggbb nebo #rgb) nebo jako black, white, red, green či blue
|
||||
@msg the linked image shows its preview: embed or relink it first propojený obraz zobrazuje náhled: nejprve jej vložte nebo znovu propojte
|
||||
@msg a CMYK image has no transparency to set obraz CMYK nemá průhlednost, kterou by bylo možné nastavit
|
||||
@msg every pixel matches the background: no image would be left každý obrazový bod odpovídá pozadí: nezbyl by žádný obraz
|
||||
@msg an image of {w} × {h} pixels is too large to change obraz o velikosti {w} × {h} obrazových bodů je příliš velký na změnu
|
||||
@msg select an object in perspective vyberte objekt v perspektivě
|
||||
@msg select artwork (or a colour group) to recolor vyberte kresbu (nebo skupinu barev) k přebarvení
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Přebarvení dokončeno; beze změny: {locked} a {hidden}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -189,6 +189,15 @@
|
||||
Expand… Udvid…
|
||||
Expand Appearance Udvid udseende
|
||||
Crop Image Beskær billede
|
||||
Image Tools Billedværktøjer
|
||||
Remove Background… Fjern baggrund…
|
||||
Remove Background Fjern baggrund
|
||||
Background Baggrund
|
||||
Edge Color Kantfarve
|
||||
Picked Colors Valgte farver
|
||||
Corner Color Hjørnefarve
|
||||
Contiguous Sammenhængende
|
||||
Each Channel Hver kanal
|
||||
Edit Path Redigér kurve
|
||||
Mask Maskér
|
||||
Rasterize… Rasterisér…
|
||||
@@ -1499,6 +1508,8 @@ digits Malayalam Malayalam
|
||||
Scale at Center Skalér fra midten
|
||||
Up/Down: Op/ned:
|
||||
Left/Right: Venstre/højre:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko op/ned
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko venstre/højre
|
||||
Underline Understreget
|
||||
Vertical Scale % Lodret skalering %
|
||||
Axis Akse
|
||||
@@ -4466,6 +4477,7 @@ imageTrace Outline Kontur
|
||||
@msg not a stored central record ikke en gemt central post
|
||||
@msg not a valid plug-in module: {0} ikke et gyldigt plug-in-modul: {0}
|
||||
@msg not an EMF or WMF file ikke en EMF- eller WMF-fil
|
||||
@msg not an HP-GL file ikke en HP-GL-fil
|
||||
@msg not found ikke fundet
|
||||
@msg not in pattern editing mode ikke i mønsterredigeringstilstand
|
||||
@msg finish editing the pattern first afslut først redigeringen af mønsteret
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -679,6 +679,15 @@ font-technology Variable Variabel
|
||||
Created: Erstellt:
|
||||
Crop Schnittmengenfläche
|
||||
Crop Image Bild beschneiden
|
||||
Image Tools Bildwerkzeuge
|
||||
Remove Background… Hintergrund entfernen…
|
||||
Remove Background Hintergrund entfernen
|
||||
Background Hintergrund
|
||||
Edge Color Randfarbe
|
||||
Picked Colors Ausgewählte Farben
|
||||
Corner Color Eckfarbe
|
||||
Contiguous Zusammenhängend
|
||||
Each Channel Jeder Kanal
|
||||
Edit Path Pfad bearbeiten
|
||||
Mask Maske
|
||||
Crop Marks Schnittmarken
|
||||
@@ -3479,6 +3488,8 @@ effect Points Punkte
|
||||
Scale at Center Von der Mitte aus skalieren
|
||||
Up/Down: Oben/Unten:
|
||||
Left/Right: Links/Rechts:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko oben/unten
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko links/rechts
|
||||
Temperature Temperatur
|
||||
Temperature/Tint… Temperatur/Farbstich…
|
||||
Template Vorlage
|
||||
@@ -4298,6 +4309,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller die Seite der Zeichenfläche {n} ist {size} groß und damit länger als die PDF-Seitengrenze von 14.400 pt (200 in): sie wird mit einer UserUnit von {unit} geschrieben, daher zeigen Apps, die UserUnit ignorieren, sie entsprechend oft kleiner an
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in die Seite der Zeichenfläche {n} ist {size} groß und damit länger als die PDF-Seitengrenze von 14.400 pt (200 in): {label}-Seiten werden ohne UserUnit in Punkt geschrieben, und Apps, die die Grenze einhalten, schneiden die Seite auf 200 in ab
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 hat keine UserUnit, die eine Seite länger als 14.400 pt (200 in) braucht, um ihre Größe zu behalten: die Datei wird als PDF 1.6 geschrieben
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 hat keine UserUnit, die eine Seite länger als 14.400 pt (200 in) braucht, um innerhalb der PDF/A-2-Seitengrenze von 14.400 Einheiten zu bleiben: die Datei wird als PDF 1.6 geschrieben
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units die Seite der Zeichenfläche {n} ist {size} groß und damit zu lang und schmal für PDF-Seitenlängen von 3 bis 14.400 Einheiten: sie wird mit einer UserUnit von {unit} geschrieben, die die kurze Seite bei mindestens 3 Einheiten hält, und Apps, die die Grenze einhalten, schneiden die lange Seite auf 14.400 Einheiten ab
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label}-Dateien fassen reelle Zahlen bis {max}, und die Seite der Zeichenfläche {n} mit {side} pt Länge braucht größere: wählen Sie PDF 1.6 oder höher ohne PDF/X-Standard oder eine kleinere Zeichenfläche
|
||||
@msg invalid image data for `{0}` ungültige Bilddaten für `{0}`
|
||||
@@ -4447,6 +4459,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg not a stored central record kein gespeicherter zentraler Eintrag
|
||||
@msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0}
|
||||
@msg not an EMF or WMF file keine EMF- oder WMF-Datei
|
||||
@msg not an HP-GL file keine HP-GL-Datei
|
||||
@msg not found nicht gefunden
|
||||
@msg not in pattern editing mode nicht im Musterbearbeitungsmodus
|
||||
@msg finish editing the pattern first beenden Sie zuerst die Bearbeitung des Musters
|
||||
@@ -4526,6 +4539,14 @@ theme Appearance Erscheinungsbild
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset diese Nachzeichnung würde mehr als {max} Ankerpunkte erzeugen, mehr als das Dokument flüssig darstellen kann: Erhöhen Sie „Rauschen“, verringern Sie „Pfade“ oder „Farben“ oder wählen Sie eine Vorgabe mit geringerer Genauigkeit
|
||||
@msg select an envelope wählen Sie eine Hülle aus
|
||||
@msg select an image wählen Sie ein Bild aus
|
||||
@msg `background` must be edges, corners or colors `background` muss edges, corners oder colors sein
|
||||
@msg `match` must be distance or channels `match` muss distance oder channels sein
|
||||
@msg `{key}` must be from 0 to 100 `{key}` muss von 0 bis 100 sein
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` muss 1 bis 16 Farben enthalten, jede hexadezimal (#rrggbb oder #rgb) oder als black, white, red, green oder blue
|
||||
@msg the linked image shows its preview: embed or relink it first das verknüpfte Bild zeigt seine Vorschau: Betten Sie es zuerst ein oder verknüpfen Sie es neu
|
||||
@msg a CMYK image has no transparency to set ein CMYK-Bild hat keine Transparenz, die festgelegt werden kann
|
||||
@msg every pixel matches the background: no image would be left jedes Pixel entspricht dem Hintergrund: Es würde kein Bild übrig bleiben
|
||||
@msg an image of {w} × {h} pixels is too large to change ein Bild mit {w} × {h} Pixeln ist zu groß, um geändert zu werden
|
||||
@msg select an object in perspective wählen Sie ein Objekt in der Perspektive aus
|
||||
@msg select artwork (or a colour group) to recolor wählen Sie eine Grafik (oder eine Farbgruppe) zum Neufärben aus
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Neufärben abgeschlossen; unverändert: {locked} und {hidden}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -660,6 +660,15 @@ font-technology Variable Μεταβλητές
|
||||
Created: Δημιουργήθηκε:
|
||||
Crop Περικοπή
|
||||
Crop Image Περικοπή εικόνας
|
||||
Image Tools Εργαλεία εικόνας
|
||||
Remove Background… Αφαίρεση φόντου…
|
||||
Remove Background Αφαίρεση φόντου
|
||||
Background Φόντο
|
||||
Edge Color Χρώμα άκρης
|
||||
Picked Colors Επιλεγμένα χρώματα
|
||||
Corner Color Χρώμα γωνιών
|
||||
Contiguous Συνεχόμενα
|
||||
Each Channel Κάθε κανάλι
|
||||
Edit Path Επεξεργασία διαδρομής
|
||||
Mask Μάσκα
|
||||
Crop Marks Σημάδια ξακρίσματος
|
||||
@@ -3918,6 +3927,8 @@ digits Malayalam Μαλαγιάλαμ
|
||||
Scale at Center Κλιμάκωση από το κέντρο
|
||||
Up/Down: Πάνω/Κάτω:
|
||||
Left/Right: Αριστερά/Δεξιά:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko πάνω/κάτω
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko αριστερά/δεξιά
|
||||
Temperature/Tint… Θερμοκρασία/Απόχρωση…
|
||||
Templates Folder Φάκελος προτύπων
|
||||
Text Displayed in Cell: Κείμενο που εμφανίζεται στο κελί:
|
||||
@@ -4337,6 +4348,7 @@ digits Malayalam Μαλαγιάλαμ
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller η σελίδα του πίνακα σχεδίασης {n} είναι {size}, μεγαλύτερη από το όριο σελίδας PDF των 14.400 pt (200 in): γράφεται με UserUnit {unit}, οπότε οι εφαρμογές που αγνοούν το UserUnit την εμφανίζουν τόσες φορές μικρότερη
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in η σελίδα του πίνακα σχεδίασης {n} είναι {size}, μεγαλύτερη από το όριο σελίδας PDF των 14.400 pt (200 in): οι σελίδες {label} γράφονται χωρίς UserUnit, σε στιγμές, και οι εφαρμογές που τηρούν το όριο κόβουν τη σελίδα στις 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 το PDF 1.5 δεν έχει UserUnit, που χρειάζεται μια σελίδα μεγαλύτερη από 14.400 pt (200 in) για να κρατήσει το μέγεθός της: το αρχείο γράφεται ως PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 το PDF 1.4 δεν έχει UserUnit, που χρειάζεται μια σελίδα μεγαλύτερη από 14.400 pt (200 in) για να μείνει εντός του ορίου σελίδας του PDF/A-2, 14.400 μονάδων: το αρχείο γράφεται ως PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units η σελίδα του πίνακα σχεδίασης {n} είναι {size}, πολύ μακριά και στενή για τις πλευρές σελίδας PDF από 3 έως 14.400 μονάδες: γράφεται με UserUnit {unit}, που κρατά τη μικρή πλευρά στις 3 μονάδες ή περισσότερο, και οι εφαρμογές που τηρούν το όριο κόβουν τη μεγάλη πλευρά στις 14.400 μονάδες
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard τα αρχεία {label} δέχονται πραγματικούς αριθμούς έως {max}, και η σελίδα του πίνακα σχεδίασης {n}, μήκους {side} pt, χρειάζεται μεγαλύτερους: επιλέξτε PDF 1.6 ή νεότερο χωρίς πρότυπο PDF/X, ή μικρότερο πίνακα σχεδίασης
|
||||
@msg invalid image data for `{0}` μη έγκυρα δεδομένα εικόνας για `{0}`
|
||||
@@ -4485,6 +4497,7 @@ digits Malayalam Μαλαγιάλαμ
|
||||
@msg not a stored central record δεν είναι αποθηκευμένη κεντρική εγγραφή
|
||||
@msg not a valid plug-in module: {0} δεν είναι έγκυρη μονάδα προσθέτου: {0}
|
||||
@msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF
|
||||
@msg not an HP-GL file δεν είναι αρχείο HP-GL
|
||||
@msg not found δεν βρέθηκε
|
||||
@msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου
|
||||
@msg finish editing the pattern first ολοκληρώστε πρώτα την επεξεργασία του μοτίβου
|
||||
@@ -4558,6 +4571,14 @@ digits Malayalam Μαλαγιάλαμ
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset αυτή η διανυσματοποίηση θα δημιουργούσε πάνω από {max} σημεία αγκύρωσης, περισσότερα από όσα μπορεί να εμφανίσει ομαλά το έγγραφο: αυξήστε τον Θόρυβο, μειώστε τις Διαδρομές ή τα Χρώματα ή επιλέξτε προεπιλογή χαμηλότερης πιστότητας
|
||||
@msg select an envelope επιλέξτε περίβλημα
|
||||
@msg select an image επιλέξτε εικόνα
|
||||
@msg `background` must be edges, corners or colors το `background` πρέπει να είναι edges, corners ή colors
|
||||
@msg `match` must be distance or channels το `match` πρέπει να είναι distance ή channels
|
||||
@msg `{key}` must be from 0 to 100 το `{key}` πρέπει να είναι από 0 έως 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue το `colors` πρέπει να περιέχει 1 έως 16 χρώματα, το καθένα σε δεκαεξαδική μορφή (#rrggbb ή #rgb) ή ως black, white, red, green ή blue
|
||||
@msg the linked image shows its preview: embed or relink it first η συνδεδεμένη εικόνα εμφανίζει την προεπισκόπησή της: ενσωματώστε ή επανασυνδέστε την πρώτα
|
||||
@msg a CMYK image has no transparency to set μια εικόνα CMYK δεν έχει διαφάνεια για να οριστεί
|
||||
@msg every pixel matches the background: no image would be left κάθε εικονοστοιχείο ταιριάζει με το φόντο: δεν θα έμενε καμία εικόνα
|
||||
@msg an image of {w} × {h} pixels is too large to change η εικόνα {w} × {h} εικονοστοιχείων είναι υπερβολικά μεγάλη για να αλλάξει
|
||||
@msg select an object in perspective επιλέξτε αντικείμενο σε προοπτική
|
||||
@msg select artwork (or a colour group) to recolor επιλέξτε σχέδιο (ή ομάδα χρωμάτων) για επαναχρωματισμό
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Ο επαναχρωματισμός ολοκληρώθηκε· χωρίς αλλαγή: {locked} και {hidden}
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -700,6 +700,15 @@ font-technology Variable Variables
|
||||
Created: Creado:
|
||||
Crop Recortar
|
||||
Crop Image Recortar imagen
|
||||
Image Tools Herramientas de imagen
|
||||
Remove Background… Quitar fondo…
|
||||
Remove Background Quitar fondo
|
||||
Background Fondo
|
||||
Edge Color Color del borde
|
||||
Picked Colors Colores elegidos
|
||||
Corner Color Color de las esquinas
|
||||
Contiguous Contiguo
|
||||
Each Channel Cada canal
|
||||
Edit Path Editar trazado
|
||||
Mask Máscara
|
||||
Crop Marks Marcas de corte
|
||||
@@ -3534,6 +3543,8 @@ digits Malayalam Malayálam
|
||||
Scale at Center Escalar desde el centro
|
||||
Up/Down: Arriba/abajo:
|
||||
Left/Right: Izquierda/derecha:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko arriba/abajo
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko izquierda/derecha
|
||||
Temperature Temperatura
|
||||
Temperature/Tint… Temperatura/tinte…
|
||||
Template Plantilla
|
||||
@@ -4382,6 +4393,7 @@ axis Both Ambos
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la página de la mesa de trabajo {n} mide {size}, más que el límite de página de PDF de 14.400 pt (200 in): se escribe con un UserUnit de {unit}, así que las aplicaciones que ignoran UserUnit la muestran ese número de veces más pequeña
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la página de la mesa de trabajo {n} mide {size}, más que el límite de página de PDF de 14.400 pt (200 in): las páginas {label} se escriben sin UserUnit, en puntos, y las aplicaciones que respetan el límite recortan la página a 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 no tiene UserUnit, que una página de más de 14.400 pt (200 in) necesita para conservar su tamaño: el archivo se escribe como PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 no tiene UserUnit, que una página de más de 14.400 pt (200 in) necesita para no superar el límite de página de PDF/A-2, de 14.400 unidades: el archivo se escribe como PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la página de la mesa de trabajo {n} mide {size}, demasiado larga y estrecha para los lados de página de PDF de 3 a 14.400 unidades: se escribe con una UserUnit de {unit}, que mantiene el lado corto en 3 unidades o más, y las aplicaciones que respetan el límite recortan el lado largo a 14.400 unidades
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard los archivos {label} admiten números reales de hasta {max}, y la página de la mesa de trabajo {n}, de {side} pt, necesita otros mayores: elija PDF 1.6 o posterior sin estándar PDF/X, o una mesa de trabajo más pequeña
|
||||
@msg invalid image data for `{0}` datos de imagen no válidos para `{0}`
|
||||
@@ -4530,6 +4542,7 @@ axis Both Ambos
|
||||
@msg not a stored central record no es un registro central almacenado
|
||||
@msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0}
|
||||
@msg not an EMF or WMF file no es un archivo EMF ni WMF
|
||||
@msg not an HP-GL file no es un archivo HP-GL
|
||||
@msg not found no se encontró
|
||||
@msg not in pattern editing mode no está en el modo de edición de motivos
|
||||
@msg finish editing the pattern first termina primero la edición del motivo
|
||||
@@ -4609,6 +4622,14 @@ axis Both Ambos
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset este calco crearía más de {max} puntos de ancla, más de los que el documento puede mostrar con fluidez: sube Ruido, baja Trazados o Colores, o elige un preajuste de menor fidelidad
|
||||
@msg select an envelope selecciona una envolvente
|
||||
@msg select an image selecciona una imagen
|
||||
@msg `background` must be edges, corners or colors `background` debe ser edges, corners o colors
|
||||
@msg `match` must be distance or channels `match` debe ser distance o channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` debe estar entre 0 y 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` debe contener de 1 a 16 colores, cada uno en hexadecimal (#rrggbb o #rgb) o como black, white, red, green o blue
|
||||
@msg the linked image shows its preview: embed or relink it first la imagen vinculada muestra su vista previa: incrústala o vuelve a vincularla primero
|
||||
@msg a CMYK image has no transparency to set una imagen CMYK no tiene transparencia que establecer
|
||||
@msg every pixel matches the background: no image would be left todos los píxeles coinciden con el fondo: no quedaría ninguna imagen
|
||||
@msg an image of {w} × {h} pixels is too large to change una imagen de {w} × {h} píxeles es demasiado grande para cambiarla
|
||||
@msg select an object in perspective selecciona un objeto en perspectiva
|
||||
@msg select artwork (or a colour group) to recolor selecciona la ilustración (o un grupo de colores) que quieres volver a colorear
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloreado completado; sin cambios: {locked} y {hidden}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -209,6 +209,15 @@
|
||||
Expand… Laajenna…
|
||||
Expand Appearance Laajenna ulkoasu
|
||||
Crop Image Rajaa kuva
|
||||
Image Tools Kuvatyökalut
|
||||
Remove Background… Poista tausta…
|
||||
Remove Background Poista tausta
|
||||
Background Tausta
|
||||
Edge Color Reunan väri
|
||||
Picked Colors Valitut värit
|
||||
Corner Color Kulmien väri
|
||||
Contiguous Yhtenäinen
|
||||
Each Channel Jokainen kanava
|
||||
Edit Path Muokkaa polkua
|
||||
Mask Maski
|
||||
Rasterize… Rasteroi…
|
||||
@@ -1561,6 +1570,8 @@ digits Malayalam Malajalam
|
||||
Scale at Center Skaalaa keskeltä
|
||||
Up/Down: Ylös/alas:
|
||||
Left/Right: Vasen/oikea:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko ylös/alas
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko vasen/oikea
|
||||
Underline Alleviivaus
|
||||
Vertical Scale % Pystyskaalaus %
|
||||
Axis Akseli
|
||||
@@ -4599,6 +4610,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller piirtoalustan {n} sivu on {size}, pidempi kuin PDF:n 14 400 pt:n (200 in) sivuraja: se kirjoitetaan UserUnit-arvolla {unit}, joten UserUnitin ohittavat sovellukset näyttävät sen niin monta kertaa pienempänä
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in piirtoalustan {n} sivu on {size}, pidempi kuin PDF:n 14 400 pt:n (200 in) sivuraja: {label}-sivut kirjoitetaan ilman UserUnitia, pisteinä, ja rajaa noudattavat sovellukset rajaavat sivun 200 in:iin
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5:ssä ei ole UserUnitia, jota yli 14 400 pt:n (200 in) sivu tarvitsee säilyttääkseen kokonsa: tiedosto kirjoitetaan PDF 1.6:na
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4:ssä ei ole UserUnitia, jota yli 14 400 pt:n (200 in) sivu tarvitsee pysyäkseen PDF/A-2:n 14 400 yksikön sivurajan sisällä: tiedosto kirjoitetaan PDF 1.6:na
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units piirtoalustan {n} sivu on {size}, liian pitkä ja kapea PDF:n sivuille, jotka ovat 3:sta 14 400 yksikköön: se kirjoitetaan UserUnit-arvolla {unit}, joka pitää lyhyen sivun vähintään 3 yksikössä, ja rajaa noudattavat sovellukset leikkaavat pitkän sivun 14 400 yksikköön
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label}-tiedostot sallivat reaaliluvut enintään {max}, ja piirtoalustan {n} sivu, {side} pt pitkä, tarvitsee suurempia: valitse PDF 1.6 tai uudempi ilman PDF/X-standardia tai pienempi piirtoalusta
|
||||
@msg invalid image data for `{0}` virheellinen kuvadata kohteelle `{0}`
|
||||
@@ -4746,6 +4758,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg not a stored central record ei tallennettu keskushakemistotietue
|
||||
@msg not a valid plug-in module: {0} ei kelvollinen laajennusmoduuli: {0}
|
||||
@msg not an EMF or WMF file ei EMF- tai WMF-tiedosto
|
||||
@msg not an HP-GL file ei HP-GL-tiedosto
|
||||
@msg not found ei löytynyt
|
||||
@msg not in pattern editing mode ei kuvion muokkaustilassa
|
||||
@msg finish editing the pattern first lopeta ensin kuvion muokkaus
|
||||
@@ -4819,6 +4832,14 @@ imageTrace Outline Ääriviiva
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset tämä jäljitys tuottaisi yli {max} ankkuripistettä, enemmän kuin asiakirja näyttää sujuvasti: nosta kohinaa, vähennä polkuja tai värejä tai valitse epätarkempi esiasetus
|
||||
@msg select an envelope valitse vääristyskuori
|
||||
@msg select an image valitse kuva
|
||||
@msg `background` must be edges, corners or colors `background`-arvon on oltava edges, corners tai colors
|
||||
@msg `match` must be distance or channels `match`-arvon on oltava distance tai channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}`-arvon on oltava väliltä 0–100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors`-arvossa on oltava 1–16 väriä, kukin heksadesimaalina (#rrggbb tai #rgb) tai muodossa black, white, red, green tai blue
|
||||
@msg the linked image shows its preview: embed or relink it first linkitetty kuva näyttää esikatselunsa: upota tai linkitä se ensin uudelleen
|
||||
@msg a CMYK image has no transparency to set CMYK-kuvalla ei ole asetettavaa läpinäkyvyyttä
|
||||
@msg every pixel matches the background: no image would be left jokainen pikseli vastaa taustaa: kuvaa ei jäisi jäljelle
|
||||
@msg an image of {w} × {h} pixels is too large to change kuva, jonka koko on {w} × {h} pikseliä, on liian suuri muutettavaksi
|
||||
@msg select an object in perspective valitse perspektiivissä oleva objekti
|
||||
@msg select artwork (or a colour group) to recolor valitse uudelleenväritettävä kuvitus (tai väriryhmä)
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Uudelleenväritys valmis; muuttamatta jäi: {locked} ja {hidden}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -702,6 +702,15 @@ font-technology Variable Variables
|
||||
Created: Création :
|
||||
Crop Recadrer
|
||||
Crop Image Recadrer l’image
|
||||
Image Tools Outils d’image
|
||||
Remove Background… Supprimer l’arrière-plan…
|
||||
Remove Background Supprimer l’arrière-plan
|
||||
Background Arrière-plan
|
||||
Edge Color Couleur du bord
|
||||
Picked Colors Couleurs choisies
|
||||
Corner Color Couleur des angles
|
||||
Contiguous Contigu
|
||||
Each Channel Chaque canal
|
||||
Edit Path Modifier le tracé
|
||||
Mask Masque
|
||||
Crop Marks Traits de coupe
|
||||
@@ -3526,6 +3535,8 @@ digits Malayalam Malayalam
|
||||
Scale at Center Mettre à l’échelle depuis le centre
|
||||
Up/Down: Haut/bas :
|
||||
Left/Right: Gauche/droite :
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko haut/bas
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko gauche/droite
|
||||
Temperature Température
|
||||
Temperature/Tint… Température/teinte…
|
||||
Template Modèle
|
||||
@@ -4370,6 +4381,7 @@ axis Both Les deux
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la page du plan de travail {n} mesure {size}, plus que la limite de page PDF de 14 400 pt (200 in) : elle est écrite avec une UserUnit de {unit}, donc les applications qui ignorent UserUnit l’affichent autant de fois plus petite
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la page du plan de travail {n} mesure {size}, plus que la limite de page PDF de 14 400 pt (200 in) : les pages {label} sont écrites sans UserUnit, en points, et les applications qui respectent la limite coupent la page à 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 n’a pas d’UserUnit, dont une page de plus de 14 400 pt (200 in) a besoin pour garder sa taille : le fichier est écrit en PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 n’a pas d’UserUnit, dont une page de plus de 14 400 pt (200 in) a besoin pour rester dans la limite de page de PDF/A-2, 14 400 unités : le fichier est écrit en PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la page du plan de travail {n} mesure {size}, trop longue et étroite pour les côtés de page PDF de 3 à 14 400 unités : elle est écrite avec une UserUnit de {unit}, qui garde le petit côté à 3 unités ou plus, et les apps qui respectent la limite coupent le grand côté à 14 400 unités
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard les fichiers {label} acceptent des nombres réels jusqu’à {max}, et la page du plan de travail {n}, longue de {side} pt, en demande de plus grands : choisissez PDF 1.6 ou ultérieur sans norme PDF/X, ou un plan de travail plus petit
|
||||
@msg invalid PDF setting: {0} réglage PDF non valide : {0}
|
||||
@@ -4519,6 +4531,7 @@ axis Both Les deux
|
||||
@msg not a stored central record ce n’est pas un enregistrement central stocké
|
||||
@msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0}
|
||||
@msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF
|
||||
@msg not an HP-GL file ce n’est pas un fichier HP-GL
|
||||
@msg not found introuvable
|
||||
@msg not in pattern editing mode pas en mode d’édition de motif
|
||||
@msg finish editing the pattern first terminez d’abord la modification du motif
|
||||
@@ -4598,6 +4611,14 @@ axis Both Les deux
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset cette vectorisation créerait plus de {max} points d’ancrage, plus que le document ne peut afficher de façon fluide : augmentez Bruit, réduisez Tracés ou Couleurs, ou choisissez un paramètre prédéfini moins fidèle
|
||||
@msg select an envelope sélectionnez une enveloppe
|
||||
@msg select an image sélectionnez une image
|
||||
@msg `background` must be edges, corners or colors `background` doit valoir edges, corners ou colors
|
||||
@msg `match` must be distance or channels `match` doit valoir distance ou channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` doit être compris entre 0 et 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` doit contenir de 1 à 16 couleurs, chacune en hexadécimal (#rrggbb ou #rgb) ou sous la forme black, white, red, green ou blue
|
||||
@msg the linked image shows its preview: embed or relink it first l’image liée affiche son aperçu : incorporez-la ou rétablissez d’abord le lien
|
||||
@msg a CMYK image has no transparency to set une image CMJN n’a pas de transparence à définir
|
||||
@msg every pixel matches the background: no image would be left chaque pixel correspond à l’arrière-plan : il ne resterait aucune image
|
||||
@msg an image of {w} × {h} pixels is too large to change une image de {w} × {h} pixels est trop grande pour être modifiée
|
||||
@msg select an object in perspective sélectionnez un objet en perspective
|
||||
@msg select artwork (or a colour group) to recolor sélectionnez l’illustration (ou un groupe de couleurs) dont redéfinir les couleurs
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloration terminée ; sans changement : {locked} et {hidden}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1099,6 +1099,15 @@ antiAlias Sharp शार्प
|
||||
Croatian क्रोएशियाई
|
||||
Crop क्रॉप करें
|
||||
Crop Image फसल की इमेज
|
||||
Image Tools इमेज टूल
|
||||
Remove Background… पृष्ठभूमि हटाएँ…
|
||||
Remove Background पृष्ठभूमि हटाएँ
|
||||
Background पृष्ठभूमि
|
||||
Edge Color किनारे का रंग
|
||||
Picked Colors चुने गए रंग
|
||||
Corner Color कोनों का रंग
|
||||
Contiguous सन्निहित
|
||||
Each Channel प्रत्येक चैनल
|
||||
Crop Marks क्रॉप मार्क
|
||||
Crop To करने के लिए फसल
|
||||
Cross क्रॉस
|
||||
@@ -2458,6 +2467,8 @@ antiAlias Sharp शार्प
|
||||
Left-to-Right Paragraph Direction बाएँ-से-दाएँ पैराग्राफ़ दिशा
|
||||
Left: बाएँ:
|
||||
Left/Right: बाएं / दाएं:
|
||||
Tate-chu-yoko Up/Down ताते-चू-योको ऊपर/नीचे
|
||||
Tate-chu-yoko Left/Right ताते-चू-योको बाएं/दाएं
|
||||
Legacy .ai Options पुराने .ai फ़ॉर्मैट के ऑप्शन्स
|
||||
Legacy (version 7) पुराना फ़ॉर्मैट (संस्करण 7)
|
||||
Legacy Macintosh RGB (Gamma 1.8) पुराना Macintosh RGB (गामा 1.8)
|
||||
@@ -6588,6 +6599,7 @@ antiAlias Sharp शार्प
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: इसे {unit} के UserUnit के साथ लिखा जाता है, इसलिए UserUnit को अनदेखा करने वाले ऐप इसे उतने गुना छोटा दिखाते हैं
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in आर्टबोर्ड {n} का पेज {size} है, जो PDF की 14,400 pt (200 in) की पेज सीमा से लंबा है: {label} पेज बिना UserUnit के, पॉइंट में लिखे जाते हैं, और सीमा मानने वाले ऐप पेज को 200 in पर काट देते हैं
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 में UserUnit नहीं है, जिसकी 14,400 pt (200 in) से लंबे पेज को अपना आकार बनाए रखने के लिए ज़रूरत होती है: फ़ाइल PDF 1.6 के रूप में लिखी जाती है
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 में UserUnit नहीं है, जिसकी 14,400 pt (200 in) से लंबे पेज को PDF/A-2 की 14,400 इकाइयों की पेज सीमा के भीतर रहने के लिए ज़रूरत होती है: फ़ाइल PDF 1.6 के रूप में लिखी जाती है
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units आर्टबोर्ड {n} का पेज {size} है, जो PDF की 3 से 14,400 इकाई की पेज भुजाओं के लिए बहुत लंबा और संकरा है: इसे {unit} की UserUnit के साथ लिखा जाता है, जो छोटी भुजा को 3 इकाई या अधिक पर रखती है, और सीमा का पालन करने वाले ऐप लंबी भुजा को 14,400 इकाई पर काट देते हैं
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} फ़ाइलें {max} तक की वास्तविक संख्याएँ रखती हैं, और आर्टबोर्ड {n} का {side} pt लंबा पेज इससे बड़ी संख्याएँ माँगता है: बिना PDF/X मानक के PDF 1.6 या बाद का चुनें, या छोटा आर्टबोर्ड
|
||||
@msg invalid point अमानवीय बिंदु
|
||||
@@ -7080,6 +7092,7 @@ antiAlias Sharp शार्प
|
||||
@msgplural Loaded “{name}”: {n} step was left out|Loaded “{name}”: {n} steps were left out “{name}” लोड किया गया: {n} चरण छोड़ा गया|“{name}” लोड किया गया: {n} चरण छोड़े गए
|
||||
@msg not an action set file एक एक्शन सेट फ़ाइल नहीं
|
||||
@msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल
|
||||
@msg not an HP-GL file HP-GL फ़ाइल नहीं है
|
||||
@msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`)
|
||||
@msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e}
|
||||
@msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें)
|
||||
@@ -7371,6 +7384,14 @@ antiAlias Sharp शार्प
|
||||
@msg select a text object एक टेक्स्ट ऑब्जेक्ट चुनें
|
||||
@msg select an envelope एक लिफाफे सिलेक्ट करें
|
||||
@msg select an image एक इमेज चुनें
|
||||
@msg `background` must be edges, corners or colors `background` edges, corners या colors होना चाहिए
|
||||
@msg `match` must be distance or channels `match` distance या channels होना चाहिए
|
||||
@msg `{key}` must be from 0 to 100 `{key}` 0 से 100 तक होना चाहिए
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` में 1 से 16 रंग होने चाहिए, हर एक हेक्स (#rrggbb या #rgb) में या black, white, red, green या blue के रूप में
|
||||
@msg the linked image shows its preview: embed or relink it first लिंक्ड इमेज अपना पूर्वावलोकन दिखा रही है: पहले इसे एम्बेड करें या फिर से लिंक करें
|
||||
@msg a CMYK image has no transparency to set CMYK इमेज में सेट करने के लिए कोई पारदर्शिता नहीं है
|
||||
@msg every pixel matches the background: no image would be left हर पिक्सेल पृष्ठभूमि से मेल खाता है: कोई इमेज नहीं बचेगी
|
||||
@msg an image of {w} × {h} pixels is too large to change {w} × {h} पिक्सेल की इमेज बदलने के लिए बहुत बड़ी है
|
||||
@msg select an image (or an Image Trace group) एक इमेज सिलेक्ट करें (या एक इमेज ट्रेस समूह)
|
||||
@msg select an Image Trace object एक इमेज ट्रेस ऑब्जेक्ट चुनें
|
||||
@msg select an image, or pass id एक इमेज सिलेक्ट करें, या आईडी पास करें
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -901,6 +901,15 @@ font-technology Variable Változó
|
||||
CRLF (Windows) CRLF (Windows)
|
||||
Crop Körülvágás
|
||||
Crop Image Kép körülvágása
|
||||
Image Tools Képeszközök
|
||||
Remove Background… Háttér eltávolítása…
|
||||
Remove Background Háttér eltávolítása
|
||||
Background Háttér
|
||||
Edge Color Szélszín
|
||||
Picked Colors Kiválasztott színek
|
||||
Corner Color Sarokszín
|
||||
Contiguous Összefüggő
|
||||
Each Channel Minden csatorna
|
||||
Edit Path Útvonal szerkesztése
|
||||
Crop Marks Vágójelek
|
||||
Crop To Körülvágás
|
||||
@@ -4195,6 +4204,8 @@ mark-style Japanese Japán
|
||||
Scale at Center Méretezés középről
|
||||
Up/Down: Fel/le:
|
||||
Left/Right: Balra/jobbra:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko fel/le
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko balra/jobbra
|
||||
Technical Drawing Műszaki rajz
|
||||
Temperature Színhőmérséklet
|
||||
Temperature/Tint… Színhőmérséklet/tónus…
|
||||
@@ -5021,6 +5032,7 @@ imageTrace Outline Körvonal
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller a(z) {n}. rajztábla oldala {size}, hosszabb a PDF 14 400 pt-os (200 in) oldalkorlátjánál: {unit} UserUnit értékkel íródik, így a UserUnit-ot figyelmen kívül hagyó alkalmazások ennyiszer kisebbnek mutatják
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in a(z) {n}. rajztábla oldala {size}, hosszabb a PDF 14 400 pt-os (200 in) oldalkorlátjánál: a(z) {label} oldalak UserUnit nélkül, pontokban íródnak, és a korlátot betartó alkalmazások 200 in-re vágják az oldalt
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 a PDF 1.5-ben nincs UserUnit, amelyre egy 14 400 pt-nál (200 in) hosszabb oldalnak szüksége van a mérete megtartásához: a fájl PDF 1.6-ként íródik
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 a PDF 1.4-ben nincs UserUnit, amelyre egy 14 400 pt-nál (200 in) hosszabb oldalnak szüksége van, hogy a PDF/A-2 14 400 egységes oldalkorlátján belül maradjon: a fájl PDF 1.6-ként íródik
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units a(z) {n}. rajztábla oldala {size}, túl hosszú és keskeny a PDF 3 és 14 400 egység közötti oldalaihoz: {unit} UserUnit értékkel íródik, amely a rövid oldalt legalább 3 egységen tartja, és a korlátot betartó alkalmazások a hosszú oldalt 14 400 egységre vágják
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard a(z) {label} fájlok legfeljebb {max} értékű valós számokat tárolnak, a(z) {n}. rajztábla {side} pt hosszú oldalához pedig nagyobbak kellenek: válassza a PDF 1.6-os vagy újabb verziót PDF/X szabvány nélkül, vagy egy kisebb rajztáblát
|
||||
@msg invalid restored panel group érvénytelen visszaállított panelcsoport
|
||||
@@ -5215,6 +5227,7 @@ imageTrace Outline Körvonal
|
||||
@msg not a VectorCraft shortcut set (missing `overrides`) nem VectorCraft-billentyűparancskészlet (hiányzik: `overrides`)
|
||||
@msg not a blend nem összemosás
|
||||
@msg not an EMF or WMF file nem EMF- vagy WMF-fájl
|
||||
@msg not an HP-GL file nem HP-GL-fájl
|
||||
@msg not found nem található
|
||||
@msg not in pattern editing mode nem mintázatszerkesztő módban vagy
|
||||
@msg finish editing the pattern first előbb fejezd be a mintázat szerkesztését
|
||||
@@ -5360,6 +5373,14 @@ imageTrace Outline Körvonal
|
||||
@msg a symbol can't hold an instance of itself egy szimbólum nem tartalmazhatja a saját példányát
|
||||
@msg select an envelope jelölj ki egy burkolót
|
||||
@msg select an image jelölj ki egy képet
|
||||
@msg `background` must be edges, corners or colors a `background` értéke edges, corners vagy colors lehet
|
||||
@msg `match` must be distance or channels a `match` értéke distance vagy channels lehet
|
||||
@msg `{key}` must be from 0 to 100 a `{key}` értéke 0 és 100 között lehet
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue a `colors` 1–16 színt sorolhat fel, mindegyiket hexadecimálisan (#rrggbb vagy #rgb) vagy black, white, red, green vagy blue alakban
|
||||
@msg the linked image shows its preview: embed or relink it first a csatolt kép az előnézetét mutatja: előbb ágyazd be vagy csatold újra
|
||||
@msg a CMYK image has no transparency to set a CMYK képnek nincs beállítható átlátszósága
|
||||
@msg every pixel matches the background: no image would be left minden képpont egyezik a háttérrel: nem maradna kép
|
||||
@msg an image of {w} × {h} pixels is too large to change egy {w} × {h} pixeles kép túl nagy a módosításhoz
|
||||
@msg select an Image Trace object jelölj ki egy képvektorizálási objektumot
|
||||
@msg select an object in perspective jelölj ki egy perspektivikus objektumot
|
||||
@msg select artwork (or a colour group) to recolor jelölj ki átszínezendő grafikát (vagy színcsoportot)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -189,6 +189,15 @@
|
||||
Expand… Perluas…
|
||||
Expand Appearance Perluas tampilan
|
||||
Crop Image Pangkas gambar
|
||||
Image Tools Alat Gambar
|
||||
Remove Background… Hapus latar belakang…
|
||||
Remove Background Hapus latar belakang
|
||||
Background Latar belakang
|
||||
Edge Color Warna tepi
|
||||
Picked Colors Warna pilihan
|
||||
Corner Color Warna sudut
|
||||
Contiguous Bersambung
|
||||
Each Channel Setiap kanal
|
||||
Edit Path Edit jalur
|
||||
Mask Masker
|
||||
Rasterize… Rasterisasi…
|
||||
@@ -1499,6 +1508,8 @@ digits Malayalam Malayalam
|
||||
Scale at Center Skalakan dari tengah
|
||||
Up/Down: Atas/bawah:
|
||||
Left/Right: Kiri/kanan:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko atas/bawah
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko kiri/kanan
|
||||
Underline Garis bawah
|
||||
Vertical Scale % Skala vertikal %
|
||||
Axis Sumbu
|
||||
@@ -4466,6 +4477,7 @@ imageTrace Outline Kontur
|
||||
@msg not a stored central record bukan rekaman pusat yang tersimpan
|
||||
@msg not a valid plug-in module: {0} bukan modul plug-in yang valid: {0}
|
||||
@msg not an EMF or WMF file bukan berkas EMF atau WMF
|
||||
@msg not an HP-GL file bukan berkas HP-GL
|
||||
@msg not found tidak ditemukan
|
||||
@msg not in pattern editing mode tidak dalam mode pengeditan pola
|
||||
@msg finish editing the pattern first selesaikan pengeditan pola terlebih dahulu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2350 and column 5.
|
@@ -700,6 +700,15 @@ font-technology Variable Variabili
|
||||
Created: Creato:
|
||||
Crop Ritaglia
|
||||
Crop Image Ritaglia immagine
|
||||
Image Tools Strumenti immagine
|
||||
Remove Background… Rimuovi sfondo…
|
||||
Remove Background Rimuovi sfondo
|
||||
Background Sfondo
|
||||
Edge Color Colore del bordo
|
||||
Picked Colors Colori scelti
|
||||
Corner Color Colore degli angoli
|
||||
Contiguous Contiguo
|
||||
Each Channel Ogni canale
|
||||
Edit Path Modifica tracciato
|
||||
Mask Maschera
|
||||
Crop Marks Segni di ritaglio
|
||||
@@ -3571,6 +3580,8 @@ digits Malayalam Malayalam
|
||||
Scale at Center Ridimensiona dal centro
|
||||
Up/Down: Su/giù:
|
||||
Left/Right: Sinistra/destra:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko su/giù
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko sinistra/destra
|
||||
Temperature Temperatura
|
||||
Temperature/Tint… Temperatura/tinta…
|
||||
Template Modello
|
||||
@@ -4425,6 +4436,7 @@ axis Both Entrambi
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller la pagina della tavola da disegno {n} misura {size}, più del limite di pagina PDF di 14.400 pt (200 in): viene scritta con una UserUnit di {unit}, quindi le app che ignorano UserUnit la mostrano altrettante volte più piccola
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in la pagina della tavola da disegno {n} misura {size}, più del limite di pagina PDF di 14.400 pt (200 in): le pagine {label} vengono scritte senza UserUnit, in punti, e le app che rispettano il limite tagliano la pagina a 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 non ha UserUnit, che serve a una pagina più lunga di 14.400 pt (200 in) per mantenere le sue dimensioni: il file viene scritto come PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 non ha UserUnit, che serve a una pagina più lunga di 14.400 pt (200 in) per restare entro il limite di pagina di PDF/A-2, di 14.400 unità: il file viene scritto come PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units la pagina della tavola da disegno {n} misura {size}, troppo lunga e stretta per i lati di pagina PDF da 3 a 14.400 unità: viene scritta con una UserUnit di {unit}, che tiene il lato corto a 3 unità o più, e le app che rispettano il limite tagliano il lato lungo a 14.400 unità
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard i file {label} accettano numeri reali fino a {max}, e la pagina della tavola da disegno {n}, lunga {side} pt, ne richiede di più grandi: scegli PDF 1.6 o successivo senza standard PDF/X, oppure una tavola da disegno più piccola
|
||||
@msg invalid image data for `{0}` dati immagine non validi per `{0}`
|
||||
@@ -4573,6 +4585,7 @@ axis Both Entrambi
|
||||
@msg not a stored central record non è un record centrale archiviato
|
||||
@msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0}
|
||||
@msg not an EMF or WMF file non è un file EMF o WMF
|
||||
@msg not an HP-GL file non è un file HP-GL
|
||||
@msg not found non trovato
|
||||
@msg not in pattern editing mode non sei in modalità modifica motivo
|
||||
@msg finish editing the pattern first termina prima la modifica del motivo
|
||||
@@ -4652,6 +4665,14 @@ axis Both Entrambi
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset questo ricalco creerebbe più di {max} punti di ancoraggio, più di quanti il documento possa mostrare in modo fluido: aumenta Disturbo, riduci Tracciati o Colori, oppure scegli un preset a fedeltà inferiore
|
||||
@msg select an envelope seleziona un involucro
|
||||
@msg select an image seleziona un'immagine
|
||||
@msg `background` must be edges, corners or colors `background` deve essere edges, corners o colors
|
||||
@msg `match` must be distance or channels `match` deve essere distance o channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` deve essere compreso tra 0 e 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` deve elencare da 1 a 16 colori, ciascuno in esadecimale (#rrggbb o #rgb) o come black, white, red, green o blue
|
||||
@msg the linked image shows its preview: embed or relink it first l’immagine collegata mostra la sua anteprima: incorporala o ricollegala prima
|
||||
@msg a CMYK image has no transparency to set un'immagine CMYK non ha trasparenza da impostare
|
||||
@msg every pixel matches the background: no image would be left ogni pixel corrisponde allo sfondo: non resterebbe alcuna immagine
|
||||
@msg an image of {w} × {h} pixels is too large to change un'immagine di {w} × {h} pixel è troppo grande per essere modificata
|
||||
@msg select an object in perspective seleziona un oggetto in prospettiva
|
||||
@msg select artwork (or a colour group) to recolor seleziona la grafica (o un gruppo colori) da ricolorare
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Ricolorazione completata; senza modifiche: {locked} e {hidden}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -231,6 +231,15 @@ digits Malayalam マラヤーラム数字
|
||||
Expand… 分割・拡張…
|
||||
Expand Appearance アピアランスを分割
|
||||
Crop Image 画像の切り抜き
|
||||
Image Tools 画像ツール
|
||||
Remove Background… 背景を削除…
|
||||
Remove Background 背景を削除
|
||||
Background 背景
|
||||
Edge Color 端の色
|
||||
Picked Colors 選択したカラー
|
||||
Corner Color 角の色
|
||||
Contiguous 隣接
|
||||
Each Channel 各チャンネル
|
||||
Edit Path パスを編集
|
||||
Mask マスク
|
||||
Rasterize… ラスタライズ…
|
||||
@@ -3898,6 +3907,8 @@ file-filter Templates テンプレート
|
||||
Scale at Center 中心から拡大・縮小
|
||||
Up/Down: 上下:
|
||||
Left/Right: 左右:
|
||||
Tate-chu-yoko Up/Down 縦中横の上下
|
||||
Tate-chu-yoko Left/Right 縦中横の左右
|
||||
Temperature 色温度
|
||||
Template テンプレート
|
||||
Templates テンプレート
|
||||
@@ -4644,6 +4655,7 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg the image's pixels don't match its size 画像のピクセル数がサイズと一致しません
|
||||
@msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません
|
||||
@msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません
|
||||
@msg not an HP-GL file HP-GL ファイルではありません
|
||||
@msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません
|
||||
@msg the EMF header is damaged EMF ヘッダーが破損しています
|
||||
@msg damaged 破損しています
|
||||
@@ -4660,6 +4672,7 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller アートボード {n} のページは {size} で、PDF のページ上限 14,400 pt (200 in) を超えています: UserUnit {unit} で書き出されるため、UserUnit を無視するアプリではその倍率だけ小さく表示されます
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in アートボード {n} のページは {size} で、PDF のページ上限 14,400 pt (200 in) を超えています: {label} のページは UserUnit なしでポイント単位で書き出され、上限を守るアプリではページが 200 in で切り取られます
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 には、14,400 pt (200 in) を超えるページがサイズを保つために必要な UserUnit がありません: ファイルは PDF 1.6 として書き出されます
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 には、14,400 pt (200 in) を超えるページが PDF/A-2 のページ上限 14,400 単位に収まるために必要な UserUnit がありません: ファイルは PDF 1.6 として書き出されます
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units アートボード {n} のページは {size} で、PDF のページ辺 3 から 14,400 単位には長く細すぎます: 短辺を 3 単位以上に保つ UserUnit {unit} で書き出され、上限を守るアプリは長辺を 14,400 単位で切り取ります
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} ファイルの実数は {max} までですが、アートボード {n} の {side} pt のページにはそれより大きな値が必要です: PDF/X 規格なしの PDF 1.6 以降を選ぶか、アートボードを小さくしてください
|
||||
@msg page {0} does not exist (the PDF has {1}) ページ {0} は存在しません (この PDF のページ数は {1} です)
|
||||
@@ -4854,6 +4867,14 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg no such view 指定された表示が見つかりません
|
||||
@msg printing isn't available here この環境ではプリントできません
|
||||
@msg select an image 画像を選択してください
|
||||
@msg `background` must be edges, corners or colors `background` は edges、corners または colors にしてください
|
||||
@msg `match` must be distance or channels `match` は distance または channels にしてください
|
||||
@msg `{key}` must be from 0 to 100 `{key}` は 0 から 100 までの値にしてください
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` には 16 進数(#rrggbb または #rgb)か black、white、red、green、blue で書いたカラーを 1〜16 個指定してください
|
||||
@msg the linked image shows its preview: embed or relink it first リンク画像はプレビューを表示しています: 先に埋め込むか再リンクしてください
|
||||
@msg a CMYK image has no transparency to set CMYK 画像には設定できる透明部分がありません
|
||||
@msg every pixel matches the background: no image would be left すべてのピクセルが背景に一致します: 画像が残りません
|
||||
@msg an image of {w} × {h} pixels is too large to change {w} × {h} ピクセルの画像は大きすぎて変更できません
|
||||
@msg the document has no artboards ドキュメントにアートボードがありません
|
||||
@msg tool (a tool id) is required tool (ツール ID) を指定してください
|
||||
@msg unknown tool `{t}` `{t}` は不明なツールです
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -161,6 +161,15 @@
|
||||
Expand… გაშლა…
|
||||
Expand Appearance იერსახის გაშლა
|
||||
Crop Image გამოსახულების ჩამოჭრა
|
||||
Image Tools გამოსახულების ხელსაწყოები
|
||||
Remove Background… ფონის მოშორება…
|
||||
Remove Background ფონის მოშორება
|
||||
Background ფონი
|
||||
Edge Color კიდის ფერი
|
||||
Picked Colors არჩეული ფერები
|
||||
Corner Color კუთხეების ფერი
|
||||
Contiguous მომიჯნავე
|
||||
Each Channel თითოეული არხი
|
||||
Mask ნიღაბი
|
||||
Rasterize… რასტრირება…
|
||||
Create Gradient Mesh… გრადიენტული ბადის შექმნა…
|
||||
@@ -1433,6 +1442,8 @@ digits Malayalam მალაიალამური
|
||||
Scale at Center ცენტრიდან მასშტაბირება
|
||||
Up/Down: ზემოთ/ქვემოთ:
|
||||
Left/Right: მარცხნივ/მარჯვნივ:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko ზემოთ/ქვემოთ
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko მარცხნივ/მარჯვნივ
|
||||
Underline ხაზგასმული
|
||||
Vertical Scale % ვერტიკალური მასშტაბი %
|
||||
Axis ღერძი
|
||||
@@ -4151,6 +4162,7 @@ font-technology Variable ცვლადი
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): ის იწერება UserUnit {unit}-ით, ამიტომ აპები, რომლებიც UserUnit-ს უგულებელყოფენ, მას ამდენჯერ პატარას აჩვენებენ
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in სამუშაო დაფა {n}-ის გვერდი არის {size}, რაც აღემატება PDF-ის გვერდის ლიმიტს 14 400 pt (200 in): {label} გვერდები იწერება UserUnit-ის გარეშე, პუნქტებში, და აპები, რომლებიც ლიმიტს იცავენ, გვერდს 200 in-ზე ჭრიან
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5-ს არ აქვს UserUnit, რომელიც 14 400 pt-ზე (200 in) გრძელ გვერდს ზომის შესანარჩუნებლად სჭირდება: ფაილი იწერება როგორც PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4-ს არ აქვს UserUnit, რომელიც 14 400 pt-ზე (200 in) გრძელ გვერდს PDF/A-2-ის 14 400 ერთეულიანი გვერდის ზღვარში დასარჩენად სჭირდება: ფაილი იწერება როგორც PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units სამუშაო დაფა {n}-ის გვერდი არის {size}, ძალიან გრძელი და ვიწრო PDF-ის 3-დან 14 400 ერთეულამდე გვერდის მხარეებისთვის: ის იწერება UserUnit {unit}-ით, რომელიც მოკლე მხარეს 3 ერთეულზე ან მეტზე ინარჩუნებს, ხოლო ზღვრის დამცველი აპები გრძელ მხარეს 14 400 ერთეულამდე ჭრიან
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} ფაილები ინახავს ნამდვილ რიცხვებს {max}-მდე, სამუშაო დაფა {n}-ის {side} pt სიგრძის გვერდს კი უფრო დიდი სჭირდება: აირჩიეთ PDF 1.6 ან უფრო ახალი PDF/X სტანდარტის გარეშე, ან უფრო პატარა სამუშაო დაფა
|
||||
@msg invalid image data for `{0}` არასწორი გამოსახულების მონაცემები `{0}`-სთვის
|
||||
@@ -4290,6 +4302,7 @@ font-technology Variable ცვლადი
|
||||
@msg not a stored central record ეს არ არის შენახული ცენტრალური ჩანაწერი
|
||||
@msg not a valid plug-in module: {0} ეს არ არის პლაგინის სწორი მოდული: {0}
|
||||
@msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი
|
||||
@msg not an HP-GL file ეს არ არის HP-GL ფაილი
|
||||
@msg not found ვერ მოიძებნა
|
||||
@msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული
|
||||
@msg finish editing the pattern first ჯერ დაასრულეთ პატერნის რედაქტირება
|
||||
@@ -4357,6 +4370,14 @@ font-technology Variable ცვლადი
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset ეს ვექტორიზაცია {max}-ზე მეტ საყრდენ წერტილს შექმნიდა, რასაც დოკუმენტი შეუფერხებლად ვერ აჩვენებს: გაზარდეთ ხმაური, შეამცირეთ ტრაექტორიები ან ფერები, ან აირჩიეთ ნაკლები სიზუსტის პრესეტი
|
||||
@msg select an envelope მონიშნეთ გარსი
|
||||
@msg select an image მონიშნეთ გამოსახულება
|
||||
@msg `background` must be edges, corners or colors `background` უნდა იყოს edges, corners ან colors
|
||||
@msg `match` must be distance or channels `match` უნდა იყოს distance ან channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` უნდა იყოს 0-დან 100-მდე
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` უნდა შეიცავდეს 1-დან 16-მდე ფერს, თითოეულს თექვსმეტობით ფორმატში (#rrggbb ან #rgb) ან როგორც black, white, red, green ან blue
|
||||
@msg the linked image shows its preview: embed or relink it first დაკავშირებული გამოსახულება გადახედვას აჩვენებს: ჯერ ჩააშენეთ ან ხელახლა მიაბით
|
||||
@msg a CMYK image has no transparency to set CMYK გამოსახულებას არ აქვს დასაყენებელი გამჭვირვალობა
|
||||
@msg every pixel matches the background: no image would be left ყველა პიქსელი ემთხვევა ფონს: გამოსახულება აღარ დარჩებოდა
|
||||
@msg an image of {w} × {h} pixels is too large to change {w} × {h} პიქსელის გამოსახულება შესაცვლელად ზედმეტად დიდია
|
||||
@msg select an object in perspective მონიშნეთ ობიექტი პერსპექტივაში
|
||||
@msg select artwork (or a colour group) to recolor მონიშნეთ ხელახლა შესაფერადებელი ნახატი (ან ფერთა ჯგუფი)
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged ხელახლა შეფერადება დასრულდა; უცვლელი დარჩა: {locked} და {hidden}
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -224,6 +224,15 @@ digits Malayalam 말라얄람어
|
||||
Expand… 확장…
|
||||
Expand Appearance 모양새 확장
|
||||
Crop Image 이미지 자르기
|
||||
Image Tools 이미지 도구
|
||||
Remove Background… 배경 제거…
|
||||
Remove Background 배경 제거
|
||||
Background 배경
|
||||
Edge Color 가장자리 색상
|
||||
Picked Colors 선택한 색상
|
||||
Corner Color 모퉁이 색상
|
||||
Contiguous 인접
|
||||
Each Channel 각 채널
|
||||
Edit Path 패스 편집
|
||||
Mask 마스크
|
||||
Rasterize… 래스터화…
|
||||
@@ -3886,6 +3895,8 @@ file-filter Templates 템플릿
|
||||
Scale at Center 중심에서 크기 조정
|
||||
Up/Down: 위/아래:
|
||||
Left/Right: 왼쪽/오른쪽:
|
||||
Tate-chu-yoko Up/Down 다테추요코 위/아래
|
||||
Tate-chu-yoko Left/Right 다테추요코 왼쪽/오른쪽
|
||||
Temperature 색온도
|
||||
Template 템플릿
|
||||
Templates 템플릿
|
||||
@@ -4571,6 +4582,7 @@ theme Appearance 외관
|
||||
@msg the image's pixels don't match its size 이미지의 픽셀이 크기와 맞지 않습니다
|
||||
@msg {bpp}-bit bitmaps can't be read {bpp}비트 비트맵은 읽을 수 없습니다
|
||||
@msg not an EMF or WMF file EMF 또는 WMF 파일이 아닙니다
|
||||
@msg not an HP-GL file HP-GL 파일이 아닙니다
|
||||
@msg its layers use {_1}, which aren't read yet 레이어에 {_1}이(가) 있으며, 아직 읽을 수 없습니다
|
||||
@msg the EMF header is damaged EMF 헤더가 손상되었습니다
|
||||
@msg damaged 손상됨
|
||||
@@ -4587,6 +4599,7 @@ theme Appearance 외관
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 아트보드 {n}의 페이지는 {size}로 PDF 페이지 한도 14,400 pt (200 in)보다 깁니다: UserUnit {unit}(으)로 기록되므로 UserUnit을 무시하는 앱에서는 그 배수만큼 작게 표시됩니다
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 아트보드 {n}의 페이지는 {size}로 PDF 페이지 한도 14,400 pt (200 in)보다 깁니다: {label} 페이지는 UserUnit 없이 포인트 단위로 기록되며, 한도를 지키는 앱은 페이지를 200 in으로 자릅니다
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5에는 14,400 pt (200 in)보다 긴 페이지가 크기를 유지하는 데 필요한 UserUnit이 없습니다: 파일이 PDF 1.6으로 기록됩니다
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4에는 14,400 pt (200 in)보다 긴 페이지가 PDF/A-2의 페이지 한도인 14,400 단위 안에 들어가는 데 필요한 UserUnit이 없습니다: 파일이 PDF 1.6으로 기록됩니다
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units 아트보드 {n}의 페이지는 {size}로, PDF 페이지 변 3에서 14,400 단위에 비해 너무 길고 좁습니다: 짧은 변을 3 단위 이상으로 유지하는 UserUnit {unit}로 기록되며, 한도를 지키는 앱은 긴 변을 14,400 단위로 자릅니다
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} 파일은 실수를 최대 {max}까지 담는데 아트보드 {n}의 {side} pt 페이지에는 더 큰 값이 필요합니다: PDF/X 표준 없이 PDF 1.6 이상을 선택하거나 더 작은 아트보드를 사용하세요
|
||||
@msg page {0} does not exist (the PDF has {1}) 페이지 {0}이(가) 없습니다(PDF의 페이지 수: {1})
|
||||
@@ -4781,6 +4794,14 @@ theme Appearance 외관
|
||||
@msg no such view 해당 보기가 없습니다
|
||||
@msg printing isn't available here 여기서는 인쇄할 수 없습니다
|
||||
@msg select an image 이미지를 선택하세요
|
||||
@msg `background` must be edges, corners or colors `background`는 edges, corners 또는 colors여야 합니다
|
||||
@msg `match` must be distance or channels `match`는 distance 또는 channels여야 합니다
|
||||
@msg `{key}` must be from 0 to 100 `{key}`는 0에서 100 사이여야 합니다
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors`에는 16진수(#rrggbb 또는 #rgb)나 black, white, red, green, blue로 쓴 색상이 1~16개 있어야 합니다
|
||||
@msg the linked image shows its preview: embed or relink it first 연결된 이미지가 미리 보기를 표시하고 있습니다: 먼저 포함하거나 다시 링크하세요
|
||||
@msg a CMYK image has no transparency to set CMYK 이미지에는 설정할 투명도가 없습니다
|
||||
@msg every pixel matches the background: no image would be left 모든 픽셀이 배경과 일치합니다: 남는 이미지가 없습니다
|
||||
@msg an image of {w} × {h} pixels is too large to change {w} × {h}픽셀 이미지는 너무 커서 변경할 수 없습니다
|
||||
@msg the document has no artboards 문서에 아트보드가 없습니다
|
||||
@msg tool (a tool id) is required 도구(도구 id)가 필요합니다
|
||||
@msg unknown tool `{t}` 알 수 없는 도구 `{t}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -203,6 +203,15 @@
|
||||
Expand… Uitbreiden…
|
||||
Expand Appearance Uiterlijk uitbreiden
|
||||
Crop Image Afbeelding bijsnijden
|
||||
Image Tools Afbeeldingshulpmiddelen
|
||||
Remove Background… Achtergrond verwijderen…
|
||||
Remove Background Achtergrond verwijderen
|
||||
Background Achtergrond
|
||||
Edge Color Randkleur
|
||||
Picked Colors Gekozen kleuren
|
||||
Corner Color Hoekkleur
|
||||
Contiguous Aaneengesloten
|
||||
Each Channel Elk kanaal
|
||||
Edit Path Pad bewerken
|
||||
Mask Masker
|
||||
Rasterize… Rasteriseren…
|
||||
@@ -1513,6 +1522,8 @@ digits Malayalam Malayalam
|
||||
Scale at Center Schalen vanuit het midden
|
||||
Up/Down: Omhoog/omlaag:
|
||||
Left/Right: Links/rechts:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko omhoog/omlaag
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko links/rechts
|
||||
Underline Onderstrepen
|
||||
Vertical Scale % Verticale schaal %
|
||||
Axis As
|
||||
@@ -4478,6 +4489,7 @@ imageTrace Outline Contour
|
||||
@msg not a stored central record geen opgeslagen centraal record
|
||||
@msg not a valid plug-in module: {0} geen geldige plug-inmodule: {0}
|
||||
@msg not an EMF or WMF file geen EMF- of WMF-bestand
|
||||
@msg not an HP-GL file geen HP-GL-bestand
|
||||
@msg not found niet gevonden
|
||||
@msg not in pattern editing mode niet in patroonbewerkingsmodus
|
||||
@msg finish editing the pattern first rond eerst het bewerken van het patroon af
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -166,6 +166,15 @@
|
||||
Expand… Rozszerz…
|
||||
Expand Appearance Rozszerz wygląd
|
||||
Crop Image Kadruj obraz
|
||||
Image Tools Narzędzia obrazu
|
||||
Remove Background… Usuń tło…
|
||||
Remove Background Usuń tło
|
||||
Background Tło
|
||||
Edge Color Kolor krawędzi
|
||||
Picked Colors Wybrane kolory
|
||||
Corner Color Kolor narożników
|
||||
Contiguous Ciągły
|
||||
Each Channel Każdy kanał
|
||||
Edit Path Edytuj ścieżkę
|
||||
Mask Maska
|
||||
Rasterize… Rasteryzuj…
|
||||
@@ -1500,6 +1509,8 @@ digits Malayalam Malajalam
|
||||
Scale at Center Skaluj od środka
|
||||
Up/Down: W górę/w dół:
|
||||
Left/Right: W lewo/w prawo:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko w górę/w dół
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko w lewo/w prawo
|
||||
Underline Podkreślenie
|
||||
Vertical Scale % Skala pionowa %
|
||||
Axis Oś
|
||||
@@ -4514,6 +4525,7 @@ imageTrace Outline Kontur
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller strona obszaru kompozycji {n} ma {size}, czyli więcej niż limit strony PDF wynoszący 14 400 pt (200 in): jest zapisywana z UserUnit {unit}, więc aplikacje ignorujące UserUnit pokazują ją tyle razy mniejszą
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in strona obszaru kompozycji {n} ma {size}, czyli więcej niż limit strony PDF wynoszący 14 400 pt (200 in): strony {label} są zapisywane bez UserUnit, w punktach, a aplikacje przestrzegające limitu przycinają stronę do 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 nie ma UserUnit, którego strona dłuższa niż 14 400 pt (200 in) potrzebuje, aby zachować rozmiar: plik jest zapisywany jako PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 nie ma UserUnit, którego strona dłuższa niż 14 400 pt (200 in) potrzebuje, aby zmieścić się w limicie strony PDF/A-2 wynoszącym 14 400 jednostek: plik jest zapisywany jako PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units strona obszaru kompozycji {n} ma {size} i jest za długa i za wąska dla boków strony PDF od 3 do 14 400 jednostek: jest zapisywana z UserUnit {unit}, która utrzymuje krótszy bok na co najmniej 3 jednostkach, a aplikacje przestrzegające limitu przycinają dłuższy bok do 14 400 jednostek
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard pliki {label} mieszczą liczby rzeczywiste do {max}, a strona obszaru kompozycji {n} o długości {side} pt wymaga większych: wybierz PDF 1.6 lub nowszy bez standardu PDF/X albo mniejszy obszar kompozycji
|
||||
@msg invalid image data for `{0}` nieprawidłowe dane obrazu dla `{0}`
|
||||
@@ -4663,6 +4675,7 @@ imageTrace Outline Kontur
|
||||
@msg not a stored central record to nie jest zapisany rekord centralny
|
||||
@msg not a valid plug-in module: {0} nieprawidłowy moduł wtyczki: {0}
|
||||
@msg not an EMF or WMF file to nie jest plik EMF ani WMF
|
||||
@msg not an HP-GL file to nie jest plik HP-GL
|
||||
@msg not found nie znaleziono
|
||||
@msg not in pattern editing mode nie jest w trybie edycji wzorka
|
||||
@msg finish editing the pattern first najpierw zakończ edycję wzorka
|
||||
@@ -4743,6 +4756,14 @@ imageTrace Outline Kontur
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset ten obrys utworzyłby ponad {max} punktów kontrolnych, więcej niż dokument może płynnie wyświetlić: zwiększ Szum, zmniejsz Ścieżki lub Kolory albo wybierz ustawienie o niższej wierności
|
||||
@msg select an envelope zaznacz kopertę
|
||||
@msg select an image zaznacz obraz
|
||||
@msg `background` must be edges, corners or colors `background` musi mieć wartość edges, corners lub colors
|
||||
@msg `match` must be distance or channels `match` musi mieć wartość distance lub channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` musi mieścić się w zakresie od 0 do 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` musi zawierać od 1 do 16 kolorów, każdy szesnastkowo (#rrggbb lub #rgb) albo jako black, white, red, green lub blue
|
||||
@msg the linked image shows its preview: embed or relink it first połączony obraz pokazuje podgląd: najpierw go osadź lub połącz ponownie
|
||||
@msg a CMYK image has no transparency to set obraz CMYK nie ma przezroczystości, którą można ustawić
|
||||
@msg every pixel matches the background: no image would be left każdy piksel pasuje do tła: nie zostałby żaden obraz
|
||||
@msg an image of {w} × {h} pixels is too large to change obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go zmienić
|
||||
@msg select an object in perspective zaznacz obiekt w perspektywie
|
||||
@msg select artwork (or a colour group) to recolor zaznacz kompozycję (lub grupę kolorów), aby zmienić kolory
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Zmiana kolorów zakończona; bez zmian: {locked} i {hidden}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -626,6 +626,15 @@ antiAlias Sharp Nítido
|
||||
Created: Criado:
|
||||
Crop Recortar
|
||||
Crop Image Recortar Imagem
|
||||
Image Tools Ferramentas de imagem
|
||||
Remove Background… Remover Fundo…
|
||||
Remove Background Remover Fundo
|
||||
Background Fundo
|
||||
Edge Color Cor da Borda
|
||||
Picked Colors Cores Escolhidas
|
||||
Corner Color Cor dos Cantos
|
||||
Contiguous Contíguo
|
||||
Each Channel Cada Canal
|
||||
Edit Path Editar caminho
|
||||
Mask Máscara
|
||||
Crop Marks Marcas de Corte
|
||||
@@ -3924,6 +3933,7 @@ axis Both Ambos
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller a página da prancheta {n} tem {size}, mais que o limite de página do PDF de 14.400 pt (200 in): ela é gravada com um UserUnit de {unit}, então apps que ignoram UserUnit a mostram essa quantidade de vezes menor
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in a página da prancheta {n} tem {size}, mais que o limite de página do PDF de 14.400 pt (200 in): páginas {label} são gravadas sem UserUnit, em pontos, e apps que respeitam o limite cortam a página em 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 o PDF 1.5 não tem UserUnit, de que uma página com mais de 14.400 pt (200 in) precisa para manter seu tamanho: o arquivo é gravado como PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 o PDF 1.4 não tem UserUnit, de que uma página com mais de 14.400 pt (200 in) precisa para ficar dentro do limite de página do PDF/A-2, de 14.400 unidades: o arquivo é gravado como PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units a página da prancheta {n} tem {size}, longa e estreita demais para os lados de página do PDF, de 3 a 14.400 unidades: ela é gravada com uma UserUnit de {unit}, que mantém o lado menor em 3 unidades ou mais, e os apps que seguem o limite cortam o lado maior em 14.400 unidades
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard os arquivos {label} aceitam números reais de até {max}, e a página da prancheta {n}, com {side} pt, precisa de números maiores: escolha PDF 1.6 ou posterior sem padrão PDF/X, ou uma prancheta menor
|
||||
@msg invalid image data for `{0}` dados de imagem inválidos para `{0}`
|
||||
@@ -4071,6 +4081,7 @@ axis Both Ambos
|
||||
@msg not a stored central record não é um registro central armazenado
|
||||
@msg not a valid plug-in module: {0} não é um módulo de plug-in válido: {0}
|
||||
@msg not an EMF or WMF file não é um arquivo EMF ou WMF
|
||||
@msg not an HP-GL file não é um arquivo HP-GL
|
||||
@msg not found não encontrado
|
||||
@msg not in pattern editing mode não está no modo de edição de padrão
|
||||
@msg finish editing the pattern first termine de editar o padrão primeiro
|
||||
@@ -4149,6 +4160,14 @@ axis Both Ambos
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset esta vetorização geraria mais de {max} pontos de ancoragem, mais do que o documento consegue exibir com fluidez: aumente Ruído, reduza Traçados ou Cores, ou escolha uma predefinição de menor fidelidade
|
||||
@msg select an envelope selecione um envelope
|
||||
@msg select an image selecione uma imagem
|
||||
@msg `background` must be edges, corners or colors `background` deve ser edges, corners ou colors
|
||||
@msg `match` must be distance or channels `match` deve ser distance ou channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` deve estar entre 0 e 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` deve listar de 1 a 16 cores, cada uma em hexadecimal (#rrggbb ou #rgb) ou como black, white, red, green ou blue
|
||||
@msg the linked image shows its preview: embed or relink it first a imagem vinculada mostra sua prévia: incorpore-a ou revincule-a primeiro
|
||||
@msg a CMYK image has no transparency to set uma imagem CMYK não tem transparência para definir
|
||||
@msg every pixel matches the background: no image would be left todos os pixels correspondem ao fundo: nenhuma imagem restaria
|
||||
@msg an image of {w} × {h} pixels is too large to change uma imagem de {w} × {h} pixels é grande demais para ser alterada
|
||||
@msg select an object in perspective selecione um objeto em perspectiva
|
||||
@msg select artwork (or a colour group) to recolor selecione uma arte-final (ou um grupo de cores) para recolorir
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Recoloração concluída; sem alteração: {locked} e {hidden}
|
||||
@@ -5048,6 +5067,8 @@ axis Both Ambos
|
||||
Scale at Center Dimensionar a partir do centro
|
||||
Up/Down: Para Cima/Para Baixo:
|
||||
Left/Right: Esquerda/Direita:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko Para Cima/Para Baixo
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko Esquerda/Direita
|
||||
Temperature Temperatura
|
||||
Templates Modelos
|
||||
Text Wrap Options Opções de Quebra de Texto
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -698,6 +698,15 @@ font-technology Variable Вариативные
|
||||
Created: Создано:
|
||||
Crop Обрезать
|
||||
Crop Image Обрезать изображение
|
||||
Image Tools Инструменты изображения
|
||||
Remove Background… Удалить фон…
|
||||
Remove Background Удалить фон
|
||||
Background Фон
|
||||
Edge Color Цвет края
|
||||
Picked Colors Выбранные цвета
|
||||
Corner Color Цвет углов
|
||||
Contiguous Смежные
|
||||
Each Channel Каждый канал
|
||||
Edit Path Редактировать контур
|
||||
Mask Маска
|
||||
Crop Marks Метки обрезки
|
||||
@@ -3566,6 +3575,8 @@ digits Malayalam Малаялам
|
||||
Scale at Center Масштабировать от центра
|
||||
Up/Down: Вверх/вниз:
|
||||
Left/Right: Влево/вправо:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko вверх/вниз
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko влево/вправо
|
||||
Temperature Температура
|
||||
Temperature/Tint… Температура/оттенок…
|
||||
Template Шаблон
|
||||
@@ -4419,6 +4430,7 @@ axis Both Оба
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller страница монтажной области {n} имеет размер {size}, что больше ограничения PDF на размер страницы в 14 400 pt (200 in): она записывается с UserUnit {unit}, поэтому приложения, которые игнорируют UserUnit, показывают её во столько же раз меньше
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in страница монтажной области {n} имеет размер {size}, что больше ограничения PDF на размер страницы в 14 400 pt (200 in): страницы {label} записываются без UserUnit, в пунктах, и приложения, соблюдающие ограничение, обрезают страницу до 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 в PDF 1.5 нет UserUnit, который нужен странице длиннее 14 400 pt (200 in), чтобы сохранить размер: файл записывается как PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 в PDF 1.4 нет UserUnit, который нужен странице длиннее 14 400 pt (200 in), чтобы уложиться в предел страницы PDF/A-2 в 14 400 единиц: файл записывается как PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units страница монтажной области {n} имеет размер {size} и слишком длинная и узкая для сторон страницы PDF от 3 до 14 400 единиц: она записывается с UserUnit {unit}, которая держит короткую сторону не меньше 3 единиц, а приложения, соблюдающие предел, обрезают длинную сторону до 14 400 единиц
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard файлы {label} вмещают вещественные числа до {max}, а странице монтажной области {n} длиной {side} pt нужны большие: выберите PDF 1.6 или новее без стандарта PDF/X или монтажную область меньшего размера
|
||||
@msg invalid image data for `{0}` недопустимые данные изображения для `{0}`
|
||||
@@ -4567,6 +4579,7 @@ axis Both Оба
|
||||
@msg not a stored central record не является сохранённой центральной записью
|
||||
@msg not a valid plug-in module: {0} не является допустимым модулем: {0}
|
||||
@msg not an EMF or WMF file не является файлом EMF или WMF
|
||||
@msg not an HP-GL file не является файлом HP-GL
|
||||
@msg not found не найдено
|
||||
@msg not in pattern editing mode не в режиме редактирования узора
|
||||
@msg finish editing the pattern first сначала завершите редактирование узора
|
||||
@@ -4646,6 +4659,14 @@ axis Both Оба
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset эта трассировка создала бы более {max} опорных точек, что больше, чем документ может плавно отобразить: увеличьте «Шум», уменьшите «Контуры» или «Цвета» либо выберите набор с меньшей точностью
|
||||
@msg select an envelope выделите оболочку
|
||||
@msg select an image выделите изображение
|
||||
@msg `background` must be edges, corners or colors `background` должен быть edges, corners или colors
|
||||
@msg `match` must be distance or channels `match` должен быть distance или channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` должен быть от 0 до 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` должен содержать от 1 до 16 цветов, каждый в шестнадцатеричном виде (#rrggbb или #rgb) или как black, white, red, green или blue
|
||||
@msg the linked image shows its preview: embed or relink it first связанное изображение показывает предпросмотр: сначала внедрите его или восстановите связь
|
||||
@msg a CMYK image has no transparency to set у изображения CMYK нет прозрачности, которую можно задать
|
||||
@msg every pixel matches the background: no image would be left каждый пиксель совпадает с фоном: от изображения ничего не осталось бы
|
||||
@msg an image of {w} × {h} pixels is too large to change изображение размером {w} × {h} пикселей слишком велико для изменения
|
||||
@msg select an object in perspective выделите объект в перспективе
|
||||
@msg select artwork (or a colour group) to recolor выделите рисунок (или цветовую группу) для перекрашивания
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Перекрашивание завершено; без изменений: {locked} и {hidden}
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -688,6 +688,15 @@ font-technology Variable Variabla
|
||||
Created: Skapad:
|
||||
Crop Beskär
|
||||
Crop Image Beskär bild
|
||||
Image Tools Bildverktyg
|
||||
Remove Background… Ta bort bakgrund…
|
||||
Remove Background Ta bort bakgrund
|
||||
Background Bakgrund
|
||||
Edge Color Kantfärg
|
||||
Picked Colors Valda färger
|
||||
Corner Color Hörnfärg
|
||||
Contiguous Sammanhängande
|
||||
Each Channel Varje kanal
|
||||
Edit Path Redigera bana
|
||||
Mask Maskera
|
||||
Crop Marks Skärmärken
|
||||
@@ -3456,6 +3465,8 @@ digits Malayalam Malayalam
|
||||
Scale at Center Skala från mitten
|
||||
Up/Down: Upp/ned:
|
||||
Left/Right: Vänster/höger:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko upp/ned
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko vänster/höger
|
||||
Temperature Temperatur
|
||||
Temperature/Tint… Temperatur/färgton…
|
||||
Template Mall
|
||||
@@ -4286,6 +4297,7 @@ axis Both Båda
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller sidan för rityta {n} är {size}, längre än PDF:s sidgräns på 14 400 pt (200 in): den skrivs med en UserUnit på {unit}, så appar som ignorerar UserUnit visar den så många gånger mindre
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in sidan för rityta {n} är {size}, längre än PDF:s sidgräns på 14 400 pt (200 in): {label}-sidor skrivs utan UserUnit, i punkter, och appar som håller sig till gränsen beskär sidan till 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 har ingen UserUnit, som en sida längre än 14 400 pt (200 in) behöver för att behålla sin storlek: filen skrivs som PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 har ingen UserUnit, som en sida längre än 14 400 pt (200 in) behöver för att hålla sig inom PDF/A-2:s sidgräns på 14 400 enheter: filen skrivs som PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units sidan för rityta {n} är {size}, för lång och smal för PDF:s sidlängder på 3 till 14 400 enheter: den skrivs med UserUnit {unit}, som håller den korta sidan på minst 3 enheter, och appar som följer gränsen klipper den långa sidan vid 14 400 enheter
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label}-filer rymmer reella tal upp till {max}, och sidan för rityta {n}, {side} pt lång, behöver större: välj PDF 1.6 eller senare utan PDF/X-standard, eller en mindre rityta
|
||||
@msg invalid image data for `{0}` ogiltiga bilddata för `{0}`
|
||||
@@ -4434,6 +4446,7 @@ axis Both Båda
|
||||
@msg not a stored central record inte en lagrad central post
|
||||
@msg not a valid plug-in module: {0} inte ett giltigt plugin: {0}
|
||||
@msg not an EMF or WMF file inte en EMF- eller WMF-fil
|
||||
@msg not an HP-GL file inte en HP-GL-fil
|
||||
@msg not found hittades inte
|
||||
@msg not in pattern editing mode inte i läget för mönsterredigering
|
||||
@msg finish editing the pattern first slutför redigeringen av mönstret först
|
||||
@@ -4507,6 +4520,14 @@ axis Both Båda
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset spårningen skulle ge fler än {max} ankarpunkter, fler än dokumentet kan visa smidigt: höj Brus, sänk Banor eller Färger, eller välj en förinställning med lägre återgivning
|
||||
@msg select an envelope markera ett kuvert
|
||||
@msg select an image markera en bild
|
||||
@msg `background` must be edges, corners or colors `background` måste vara edges, corners eller colors
|
||||
@msg `match` must be distance or channels `match` måste vara distance eller channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` måste vara från 0 till 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` måste lista 1 till 16 färger, var och en hexadecimalt (#rrggbb eller #rgb) eller som black, white, red, green eller blue
|
||||
@msg the linked image shows its preview: embed or relink it first den länkade bilden visar sin förhandsvisning: bädda in eller länka om den först
|
||||
@msg a CMYK image has no transparency to set en CMYK-bild har ingen transparens att ställa in
|
||||
@msg every pixel matches the background: no image would be left varje pixel matchar bakgrunden: ingen bild skulle bli kvar
|
||||
@msg an image of {w} × {h} pixels is too large to change en bild på {w} × {h} pixlar är för stor för att ändras
|
||||
@msg select an object in perspective markera ett objekt i perspektiv
|
||||
@msg select artwork (or a colour group) to recolor markera grafik (eller en färggrupp) att färga om
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Omfärgning klar; utan ändring: {locked} och {hidden}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -680,6 +680,15 @@ font-technology Variable Değişken
|
||||
Created: Oluşturuldu:
|
||||
Crop Kırpma
|
||||
Crop Image Resmi Kırp
|
||||
Image Tools Resim Araçları
|
||||
Remove Background… Arka Planı Kaldır…
|
||||
Remove Background Arka Planı Kaldır
|
||||
Background Arka Plan
|
||||
Edge Color Kenar Rengi
|
||||
Picked Colors Seçilen Renkler
|
||||
Corner Color Köşe Rengi
|
||||
Contiguous Bitişik
|
||||
Each Channel Her Kanal
|
||||
Mask Maske
|
||||
Crop Marks Kırpma İşaretleri
|
||||
Crop To Kırpma Hedefi
|
||||
@@ -3347,6 +3356,8 @@ digits Malayalam Malayalam
|
||||
Scale at Center Merkezden Ölçekle
|
||||
Up/Down: Yukarı/Aşağı:
|
||||
Left/Right: Sol/Sağ:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko Yukarı/Aşağı
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko Sol/Sağ
|
||||
Temperature Sıcaklık
|
||||
Temperature/Tint… Sıcaklık/Renk Tonu…
|
||||
Template Şablon
|
||||
@@ -4145,6 +4156,7 @@ axis Both Her İkisi
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller {n} çalışma yüzeyinin sayfası {size}, PDF'nin 14.400 pt (200 in) sayfa sınırından uzun: {unit} UserUnit değeriyle yazılır, bu yüzden UserUnit'i yok sayan uygulamalar onu o kadar kat küçük gösterir
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in {n} çalışma yüzeyinin sayfası {size}, PDF'nin 14.400 pt (200 in) sayfa sınırından uzun: {label} sayfaları UserUnit olmadan, punto cinsinden yazılır ve sınıra uyan uygulamalar sayfayı 200 in'de keser
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5'te, 14.400 pt (200 in) uzunluğunu aşan bir sayfanın boyutunu koruması için gereken UserUnit yoktur: dosya PDF 1.6 olarak yazılır
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4'te, 14.400 pt (200 in) uzunluğunu aşan bir sayfanın PDF/A-2'nin 14.400 birimlik sayfa sınırı içinde kalması için gereken UserUnit yoktur: dosya PDF 1.6 olarak yazılır
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units {n} çalışma yüzeyinin sayfası {size}, PDF'nin 3 ile 14.400 birim arasındaki sayfa kenarları için fazla uzun ve dar: kısa kenarı en az 3 birimde tutan {unit} UserUnit değeriyle yazılır ve sınıra uyan uygulamalar uzun kenarı 14.400 birimde keser
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} dosyaları en fazla {max} değerinde gerçek sayı tutar ve {n} çalışma yüzeyinin {side} pt uzunluğundaki sayfası daha büyüklerini gerektiriyor: PDF/X standardı olmadan PDF 1.6 veya sonrasını ya da daha küçük bir çalışma yüzeyi seçin
|
||||
@msg invalid image data for `{0}` `{0}` için geçersiz resim verileri
|
||||
@@ -4288,6 +4300,7 @@ axis Both Her İkisi
|
||||
@msg not a stored central record saklanan merkezi bir kayıt değil
|
||||
@msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0}
|
||||
@msg not an EMF or WMF file EMF veya WMF dosyası değil
|
||||
@msg not an HP-GL file HP-GL dosyası değil
|
||||
@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
|
||||
@@ -4359,6 +4372,14 @@ axis Both Her İkisi
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset bu iz, {max} bağlantı noktalarından daha fazlasını, yani belgenin düzgün bir şekilde gösterebileceğinden daha fazlasını oluşturur: Gürültüyü artırın, Yolları veya Renkleri düşürün veya aslına uygunluğu daha düşük bir ön ayar seçin
|
||||
@msg select an envelope bir zarf seç
|
||||
@msg select an image bir resim seç
|
||||
@msg `background` must be edges, corners or colors `background` edges, corners veya colors olmalıdır
|
||||
@msg `match` must be distance or channels `match` distance veya channels olmalıdır
|
||||
@msg `{key}` must be from 0 to 100 `{key}` 0 ile 100 arasında olmalıdır
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors`, her biri onaltılık (#rrggbb veya #rgb) ya da black, white, red, green veya blue olarak yazılmış 1 ile 16 arasında renk içermelidir
|
||||
@msg the linked image shows its preview: embed or relink it first bağlantılı resim önizlemesini gösteriyor: önce gömün veya yeniden bağlayın
|
||||
@msg a CMYK image has no transparency to set CMYK görüntüsünde ayarlanacak saydamlık yok
|
||||
@msg every pixel matches the background: no image would be left her piksel arka planla eşleşiyor: hiç görüntü kalmaz
|
||||
@msg an image of {w} × {h} pixels is too large to change {w} × {h} piksellik bir görüntü değiştirilemeyecek kadar büyük
|
||||
@msg select an object in perspective perspektifte bir nesne seç
|
||||
@msg select artwork (or a colour group) to recolor yeniden renklendirilecek resmi (veya bir renk grubunu) seçin
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Yeniden renklendirme tamamlandı; değiştirilmeden kalan: {locked} ve {hidden}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -694,6 +694,15 @@ font-technology Variable Варіативні
|
||||
Created: Створено:
|
||||
Crop Обрізати
|
||||
Crop Image Обрізати зображення
|
||||
Image Tools Інструменти зображення
|
||||
Remove Background… Видалити фон…
|
||||
Remove Background Видалити фон
|
||||
Background Фон
|
||||
Edge Color Колір краю
|
||||
Picked Colors Вибрані кольори
|
||||
Corner Color Колір кутів
|
||||
Contiguous Суміжні
|
||||
Each Channel Кожен канал
|
||||
Edit Path Редагувати контур
|
||||
Mask Маска
|
||||
Crop Marks Мітки обрізання
|
||||
@@ -3564,6 +3573,8 @@ effect Points Точки
|
||||
Scale at Center Масштабувати від центру
|
||||
Up/Down: Вгору/вниз:
|
||||
Left/Right: Ліворуч/праворуч:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko вгору/вниз
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko ліворуч/праворуч
|
||||
Temperature Температура
|
||||
Temperature/Tint… Температура/відтінок…
|
||||
Template Шаблон
|
||||
@@ -4409,6 +4420,7 @@ axis Both Обидві
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller сторінка монтажної області {n} має розмір {size}, що більше за обмеження PDF на розмір сторінки в 14 400 pt (200 in): її записано з UserUnit {unit}, тому застосунки, які ігнорують UserUnit, показують її в стільки ж разів меншою
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in сторінка монтажної області {n} має розмір {size}, що більше за обмеження PDF на розмір сторінки в 14 400 pt (200 in): сторінки {label} записуються без UserUnit, у пунктах, і застосунки, що дотримуються обмеження, обрізають сторінку до 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 у PDF 1.5 немає UserUnit, який потрібен сторінці, довшій за 14 400 pt (200 in), щоб зберегти розмір: файл записується як PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 у PDF 1.4 немає UserUnit, який потрібен сторінці, довшій за 14 400 pt (200 in), щоб вкластися в межу сторінки PDF/A-2 у 14 400 одиниць: файл записується як PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units сторінка монтажної області {n} має розмір {size} і задовга та завузька для сторін сторінки PDF від 3 до 14 400 одиниць: її записано з UserUnit {unit}, що тримає коротку сторону не менше 3 одиниць, а застосунки, які дотримуються межі, обрізають довгу сторону до 14 400 одиниць
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard файли {label} вміщують дійсні числа до {max}, а сторінці монтажної області {n} завдовжки {side} pt потрібні більші: виберіть PDF 1.6 або новіший без стандарту PDF/X чи меншу монтажну область
|
||||
@msg invalid image data for `{0}` неприпустимі дані зображення для `{0}`
|
||||
@@ -4557,6 +4569,7 @@ axis Both Обидві
|
||||
@msg not a stored central record не є збереженим центральним записом
|
||||
@msg not a valid plug-in module: {0} не є допустимим модулем: {0}
|
||||
@msg not an EMF or WMF file не є файлом EMF або WMF
|
||||
@msg not an HP-GL file не є файлом HP-GL
|
||||
@msg not found не знайдено
|
||||
@msg not in pattern editing mode не в режимі редагування візерунка
|
||||
@msg finish editing the pattern first спочатку завершіть редагування візерунка
|
||||
@@ -4636,6 +4649,14 @@ axis Both Обидві
|
||||
@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset це трасування створило б більше {max} опорних точок, що більше, ніж документ може плавно відобразити: збільште «Шум», зменште «Контури» або «Кольори» або виберіть набір з меншою точністю
|
||||
@msg select an envelope виділіть оболонку
|
||||
@msg select an image виділіть зображення
|
||||
@msg `background` must be edges, corners or colors `background` має бути edges, corners або colors
|
||||
@msg `match` must be distance or channels `match` має бути distance або channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` має бути від 0 до 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` має містити від 1 до 16 кольорів, кожен у шістнадцятковому вигляді (#rrggbb або #rgb) або як black, white, red, green чи blue
|
||||
@msg the linked image shows its preview: embed or relink it first пов’язане зображення показує попередній перегляд: спочатку вбудуйте його або відновіть зв’язок
|
||||
@msg a CMYK image has no transparency to set зображення CMYK не має прозорості, яку можна задати
|
||||
@msg every pixel matches the background: no image would be left кожен піксель збігається з фоном: від зображення нічого не залишилося б
|
||||
@msg an image of {w} × {h} pixels is too large to change зображення розміром {w} × {h} пікселів завелике для зміни
|
||||
@msg select an object in perspective виділіть об’єкт у перспективі
|
||||
@msg select artwork (or a colour group) to recolor виділіть малюнок (або колірну групу) для перефарбовування
|
||||
@msg Recolor completed; {locked} and {hidden} were left unchanged Перефарбовування завершено; без змін: {locked} і {hidden}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -801,6 +801,15 @@ font-technology Variable Biến thiên
|
||||
Created: Ngày tạo:
|
||||
Crop Cắt xén
|
||||
Crop Image Cắt xén ảnh
|
||||
Image Tools Công cụ hình ảnh
|
||||
Remove Background… Xóa nền…
|
||||
Remove Background Xóa nền
|
||||
Background Nền
|
||||
Edge Color Màu cạnh
|
||||
Picked Colors Màu đã chọn
|
||||
Corner Color Màu góc
|
||||
Contiguous Liền kề
|
||||
Each Channel Từng kênh
|
||||
Edit Path Sửa đường dẫn
|
||||
Crop Marks Dấu cắt xén
|
||||
Crop To Cắt xén đến
|
||||
@@ -3905,6 +3914,8 @@ mark-style Japanese Kiểu Nhật
|
||||
Scale at Center Co giãn từ tâm
|
||||
Up/Down: Lên/Xuống:
|
||||
Left/Right: Trái/Phải:
|
||||
Tate-chu-yoko Up/Down Tate-chu-yoko lên/xuống
|
||||
Tate-chu-yoko Left/Right Tate-chu-yoko trái/phải
|
||||
Temperature Nhiệt độ màu
|
||||
Temperature/Tint… Nhiệt độ màu/Tông…
|
||||
Template Mẫu
|
||||
@@ -4668,6 +4679,7 @@ theme Appearance Giao diện
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller trang của bảng vẽ {n} có kích thước {size}, dài hơn giới hạn trang PDF 14.400 pt (200 in): trang được ghi với UserUnit là {unit}, nên các ứng dụng bỏ qua UserUnit sẽ hiển thị nó nhỏ đi bấy nhiêu lần
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in trang của bảng vẽ {n} có kích thước {size}, dài hơn giới hạn trang PDF 14.400 pt (200 in): các trang {label} được ghi không có UserUnit, theo đơn vị điểm, và các ứng dụng tuân theo giới hạn sẽ cắt trang ở 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 không có UserUnit, thứ mà trang dài hơn 14.400 pt (200 in) cần để giữ kích thước: tệp được ghi dưới dạng PDF 1.6
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 không có UserUnit, thứ mà trang dài hơn 14.400 pt (200 in) cần để nằm trong giới hạn trang 14.400 đơn vị của PDF/A-2: tệp được ghi dưới dạng PDF 1.6
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units trang của bảng vẽ {n} có kích thước {size}, quá dài và hẹp so với cạnh trang PDF từ 3 đến 14.400 đơn vị: trang được ghi với UserUnit {unit}, giữ cạnh ngắn từ 3 đơn vị trở lên, và các ứng dụng tuân theo giới hạn sẽ cắt cạnh dài ở 14.400 đơn vị
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard tệp {label} chỉ chứa số thực tối đa {max}, còn trang của bảng vẽ {n} dài {side} pt cần số lớn hơn: hãy chọn PDF 1.6 trở lên không theo tiêu chuẩn PDF/X, hoặc một bảng vẽ nhỏ hơn
|
||||
@msg invalid restored panel group nhóm bảng được phục hồi không hợp lệ
|
||||
@@ -4870,6 +4882,7 @@ theme Appearance Giao diện
|
||||
@msg not a shortcut set: the text doesn't start with a slash name and an opening brace không phải bộ phím tắt: văn bản không bắt đầu bằng một tên có dấu gạch chéo và một dấu ngoặc nhọn mở
|
||||
@msg This shortcut file is in a form VectorCraft can't read. Tệp phím tắt này có dạng mà VectorCraft không đọc được.
|
||||
@msg not an EMF or WMF file không phải tệp EMF hoặc WMF
|
||||
@msg not an HP-GL file không phải tệp HP-GL
|
||||
@msg not found không tìm thấy
|
||||
@msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết
|
||||
@msg finish editing the pattern first hãy hoàn tất chỉnh sửa họa tiết trước
|
||||
@@ -5015,6 +5028,14 @@ theme Appearance Giao diện
|
||||
@msg a symbol can't hold an instance of itself ký hiệu không thể chứa đối tượng của chính nó
|
||||
@msg select an envelope hãy chọn một Envelope
|
||||
@msg select an image hãy chọn ảnh
|
||||
@msg `background` must be edges, corners or colors `background` phải là edges, corners hoặc colors
|
||||
@msg `match` must be distance or channels `match` phải là distance hoặc channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` phải nằm trong khoảng 0 đến 100
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` phải liệt kê từ 1 đến 16 màu, mỗi màu viết dạng hex (#rrggbb hoặc #rgb) hoặc là black, white, red, green hay blue
|
||||
@msg the linked image shows its preview: embed or relink it first hình ảnh được liên kết đang hiển thị bản xem trước: hãy nhúng hoặc liên kết lại trước
|
||||
@msg a CMYK image has no transparency to set ảnh CMYK không có độ trong suốt để đặt
|
||||
@msg every pixel matches the background: no image would be left mọi điểm ảnh đều khớp với nền: sẽ không còn lại ảnh nào
|
||||
@msg an image of {w} × {h} pixels is too large to change ảnh {w} × {h} điểm ảnh quá lớn nên không thể thay đổi
|
||||
@msg select an Image Trace object hãy chọn đối tượng vector hóa ảnh
|
||||
@msg select an object in perspective hãy chọn đối tượng trong phối cảnh
|
||||
@msg select artwork (or a colour group) to recolor hãy chọn tác phẩm (hoặc nhóm màu) để tô lại
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -645,6 +645,15 @@ antiAlias Sharp 锐利
|
||||
Created: 创建时间:
|
||||
Crop 裁剪
|
||||
Crop Image 裁剪图像
|
||||
Image Tools 图像工具
|
||||
Remove Background… 移除背景…
|
||||
Remove Background 移除背景
|
||||
Background 背景
|
||||
Edge Color 边缘颜色
|
||||
Picked Colors 选取的颜色
|
||||
Corner Color 转角颜色
|
||||
Contiguous 连续
|
||||
Each Channel 每个通道
|
||||
Edit Path 编辑路径
|
||||
Mask 蒙版
|
||||
Crop Marks 裁切标记
|
||||
@@ -3399,6 +3408,8 @@ digits Malayalam 马拉雅拉姆数字
|
||||
Scale at Center 从中心缩放
|
||||
Up/Down: 上/下:
|
||||
Left/Right: 左/右:
|
||||
Tate-chu-yoko Up/Down 纵中横上/下
|
||||
Tate-chu-yoko Left/Right 纵中横左/右
|
||||
Temperature 色温
|
||||
Temperature/Tint… 色温/色调…
|
||||
Template 模板
|
||||
@@ -4350,6 +4361,7 @@ imageTrace Outline 轮廓
|
||||
@msg the image's pixels don't match its size 图像的像素与其尺寸不符
|
||||
@msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图
|
||||
@msg not an EMF or WMF file 不是 EMF 或 WMF 文件
|
||||
@msg not an HP-GL file 不是 HP-GL 文件
|
||||
@msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取
|
||||
@msg the EMF header is damaged EMF 文件头已损坏
|
||||
@msg damaged 已损坏
|
||||
@@ -4366,6 +4378,7 @@ imageTrace Outline 轮廓
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 画板 {n} 的页面为 {size},超过 PDF 的页面上限 14,400 pt (200 in):它以 UserUnit {unit} 写入,因此忽略 UserUnit 的应用会将其显示为缩小相应倍数
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 画板 {n} 的页面为 {size},超过 PDF 的页面上限 14,400 pt (200 in):{label} 页面不带 UserUnit,以点为单位写入,遵守上限的应用会将页面裁切为 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 没有 UserUnit,而长于 14,400 pt (200 in) 的页面需要它来保持尺寸:文件将以 PDF 1.6 写入
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 没有 UserUnit,而长于 14,400 pt (200 in) 的页面需要它来保持在 PDF/A-2 的 14,400 单位页面上限之内:文件将以 PDF 1.6 写入
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units 画板 {n} 的页面为 {size},对 PDF 3 至 14,400 单位的页面边长而言过长过窄:以 UserUnit {unit} 写入,使短边保持 3 单位以上,遵守上限的应用会把长边裁到 14,400 单位
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} 文件的实数最大为 {max},而画板 {n} 的页面长 {side} pt,需要更大的数:请选择不带 PDF/X 标准的 PDF 1.6 或更高版本,或使用更小的画板
|
||||
@msg page {0} does not exist (the PDF has {1}) 页面 {0} 不存在 (该 PDF 有 {1} 页)
|
||||
@@ -4554,6 +4567,14 @@ imageTrace Outline 轮廓
|
||||
@msg no such view 没有此视图
|
||||
@msg printing isn't available here 此处无法打印
|
||||
@msg select an image 请选择一个图像
|
||||
@msg `background` must be edges, corners or colors `background` 必须为 edges、corners 或 colors
|
||||
@msg `match` must be distance or channels `match` 必须为 distance 或 channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` 必须介于 0 到 100 之间
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` 必须列出 1 到 16 种颜色,每种写为十六进制(#rrggbb 或 #rgb)或 black、white、red、green、blue
|
||||
@msg the linked image shows its preview: embed or relink it first 链接图像显示的是预览:请先嵌入或重新链接
|
||||
@msg a CMYK image has no transparency to set CMYK 图像没有可设置的透明度
|
||||
@msg every pixel matches the background: no image would be left 每个像素都与背景匹配:不会留下任何图像
|
||||
@msg an image of {w} × {h} pixels is too large to change {w} × {h} 像素的图像过大,无法更改
|
||||
@msg the document has no artboards 文档没有画板
|
||||
@msg tool (a tool id) is required 需要 tool (工具 id)
|
||||
@msg unknown tool `{t}` 未知工具 `{t}`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -662,6 +662,15 @@ font-technology Variable 可變
|
||||
Created: 建立時間:
|
||||
Crop 裁切
|
||||
Crop Image 裁切影像
|
||||
Image Tools 影像工具
|
||||
Remove Background… 移除背景…
|
||||
Remove Background 移除背景
|
||||
Background 背景
|
||||
Edge Color 邊緣顏色
|
||||
Picked Colors 選取的顏色
|
||||
Corner Color 轉角顏色
|
||||
Contiguous 連續
|
||||
Each Channel 每個通道
|
||||
Edit Path 編輯路徑
|
||||
Mask 遮色片
|
||||
Crop Marks 裁切標記
|
||||
@@ -2164,6 +2173,7 @@ file-filter Templates 範本
|
||||
@msg the image's pixels don't match its size 影像的像素與其尺寸不符
|
||||
@msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖
|
||||
@msg not an EMF or WMF file 不是 EMF 或 WMF 檔案
|
||||
@msg not an HP-GL file 不是 HP-GL 檔案
|
||||
@msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取
|
||||
@msg the EMF header is damaged EMF 檔頭已損毀
|
||||
@msg damaged 已損毀
|
||||
@@ -2180,6 +2190,7 @@ file-filter Templates 範本
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): it is written with a UserUnit of {unit}, so apps that ignore UserUnit show it that many times smaller 工作區域 {n} 的頁面為 {size},超過 PDF 的頁面上限 14,400 pt (200 in):它以 UserUnit {unit} 寫入,因此忽略 UserUnit 的應用程式會將其顯示為縮小相應倍數
|
||||
@msg the page of artboard {n} is {size}, longer than PDF's page limit of 14,400 pt (200 in): {label} pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in 工作區域 {n} 的頁面為 {size},超過 PDF 的頁面上限 14,400 pt (200 in):{label} 頁面不含 UserUnit,以點為單位寫入,遵守上限的應用程式會將頁面裁切為 200 in
|
||||
@msg PDF 1.5 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to keep its size: the file is written as PDF 1.6 PDF 1.5 沒有 UserUnit,而長於 14,400 pt (200 in) 的頁面需要它來保持尺寸:檔案將以 PDF 1.6 寫入
|
||||
@msg PDF 1.4 has no UserUnit, which a page longer than 14,400 pt (200 in) needs to stay within PDF/A-2's page limit of 14,400 units: the file is written as PDF 1.6 PDF 1.4 沒有 UserUnit,而長於 14,400 pt (200 in) 的頁面需要它來保持在 PDF/A-2 的 14,400 單位頁面上限之內:檔案將以 PDF 1.6 寫入
|
||||
@msg the page of artboard {n} is {size}, too long and narrow for PDF's page sides of 3 to 14,400 units: it is written with a UserUnit of {unit}, which keeps the short side at 3 units or more, and apps that keep to the limit cut the long side to 14,400 units 工作區域 {n} 的頁面為 {size},對 PDF 3 至 14,400 單位的頁面邊長而言過長過窄:以 UserUnit {unit} 寫入,使短邊保持 3 單位以上,遵守上限的應用程式會把長邊裁到 14,400 單位
|
||||
@msg {label} files hold real numbers up to {max}, and the page of artboard {n}, {side} pt long, needs larger ones: choose PDF 1.6 or later without a PDF/X standard, or a smaller artboard {label} 檔案的實數最大為 {max},而工作區域 {n} 的頁面長 {side} pt,需要更大的數:請選擇不含 PDF/X 標準的 PDF 1.6 或更新版本,或使用較小的工作區域
|
||||
@msg page {0} does not exist (the PDF has {1}) 頁面 {0} 不存在 (此 PDF 有 {1} 頁)
|
||||
@@ -2367,6 +2378,14 @@ file-filter Templates 範本
|
||||
@msg no such view 沒有這個檢視
|
||||
@msg printing isn't available here 此處無法列印
|
||||
@msg select an image 請選取影像
|
||||
@msg `background` must be edges, corners or colors `background` 必須為 edges、corners 或 colors
|
||||
@msg `match` must be distance or channels `match` 必須為 distance 或 channels
|
||||
@msg `{key}` must be from 0 to 100 `{key}` 必須介於 0 到 100 之間
|
||||
@msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` 必須列出 1 到 16 種顏色,每種寫為十六進位(#rrggbb 或 #rgb)或 black、white、red、green、blue
|
||||
@msg the linked image shows its preview: embed or relink it first 連結影像顯示的是預覽:請先嵌入或重新連結
|
||||
@msg a CMYK image has no transparency to set CMYK 影像沒有可設定的透明度
|
||||
@msg every pixel matches the background: no image would be left 每個像素都與背景相符:不會留下任何影像
|
||||
@msg an image of {w} × {h} pixels is too large to change {w} × {h} 像素的影像過大,無法變更
|
||||
@msg the document has no artboards 文件沒有工作區域
|
||||
@msg tool (a tool id) is required 需要 tool (工具 id)
|
||||
@msg unknown tool `{t}` 未知的工具 `{t}`
|
||||
@@ -4521,6 +4540,8 @@ digits Malayalam 馬來亞拉姆數字
|
||||
Scale at Center 從中心縮放
|
||||
Up/Down: 上/下:
|
||||
Left/Right: 左/右:
|
||||
Tate-chu-yoko Up/Down 縱中橫上/下
|
||||
Tate-chu-yoko Left/Right 縱中橫左/右
|
||||
Temperature 色溫
|
||||
Temperature/Tint… 色溫/色調…
|
||||
Template 範本
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -233,11 +233,106 @@ fn align_to(app: &VectorcraftApp) -> AlignTo {
|
||||
if has_key(app) { AlignTo::Key } else { app.ui.align_to }
|
||||
}
|
||||
|
||||
/// The Align To choices of the Align panel and of the Control bar's Align To menu, in their order.
|
||||
const ALIGN_TO: [AlignTo; 3] = [AlignTo::Selection, AlignTo::Key, AlignTo::Artboard];
|
||||
|
||||
/// The icon and the translated name of Align To choice `v`.
|
||||
fn align_to_icon_name(v: AlignTo) -> (&'static str, &'static str) {
|
||||
match v {
|
||||
AlignTo::Selection => ("dc-alignto-selection", tl!("Align to Selection")),
|
||||
AlignTo::Key => ("dc-alignto-key", tl!("Align to Key Object")),
|
||||
AlignTo::Artboard => ("dc-alignto-artboard", tl!("Align to Artboard")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Align To choice that the Align panel and the Control bar show chosen: Artboard while one
|
||||
/// object is selected (the buttons then align it to the artboard), else [`align_to`].
|
||||
fn shown_align_to(app: &VectorcraftApp) -> AlignTo {
|
||||
aligns_to(align_to(app), objects_aligned(app))
|
||||
}
|
||||
|
||||
/// Whether Align To choice `v` can be chosen. Align to Key Object needs a key object or a key
|
||||
/// guide, or something to make one of: two or more selected objects, or a guide selected with
|
||||
/// objects.
|
||||
fn can_choose(app: &VectorcraftApp, v: AlignTo) -> bool {
|
||||
v != AlignTo::Key || selection_len(app) >= 2 || has_key(app) || guide_for_key(app).is_some()
|
||||
}
|
||||
|
||||
/// Chooses Align To `v`, as the Align panel's Align To buttons and the Control bar's Align To menu
|
||||
/// do. Align to Key Object makes the topmost selected object the key object, unless there is one
|
||||
/// already; with one object, a guide selected with it becomes the key. The stored choice stays as
|
||||
/// it was, and Align To shows it again once the key is cleared. Align to Selection and Align to
|
||||
/// Artboard store the choice and let go of the key object.
|
||||
pub(crate) fn choose_align_to(app: &mut VectorcraftApp, v: AlignTo) {
|
||||
if !can_choose(app, v) {
|
||||
return;
|
||||
}
|
||||
if v == AlignTo::Key {
|
||||
if !has_key(app) {
|
||||
match (topmost(app).filter(|_| selection_len(app) >= 2), guide_for_key(app)) {
|
||||
(Some(id), _) => app.run("select.key", json!({"id": id.0})).ok(),
|
||||
(None, Some(g)) => app.run("select.key", json!({"guide": g})).ok(),
|
||||
(None, None) => None,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
app.ui.align_to = v;
|
||||
if has_key(app) {
|
||||
app.run("select.key", json!({})).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The width of the Control bar's Align To menu ([`align_to_menu`]) for buttons `size` points
|
||||
/// square: the icon of the choice and the arrow after it.
|
||||
pub(crate) fn align_to_menu_width(size: f32) -> f32 {
|
||||
size + ALIGN_TO_ARROW
|
||||
}
|
||||
|
||||
/// The width of the arrow of the Control bar's Align To menu.
|
||||
const ALIGN_TO_ARROW: f32 = 14.0;
|
||||
|
||||
/// The Control bar's Align To menu, next to its Align buttons: the icon of the choice that the
|
||||
/// Align panel's Align To shows ([`shown_align_to`]) and an arrow, `size` points tall. Its menu
|
||||
/// lists Align to Selection, Align to Key Object and Align to Artboard with that choice checked,
|
||||
/// and a pick chooses it as the Align panel does ([`choose_align_to`]). Align to Key Object is off
|
||||
/// while no key object can be made. Screen readers get a combo box named "Align To:" whose value
|
||||
/// is the choice.
|
||||
pub(crate) fn align_to_menu(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let shown = shown_align_to(app);
|
||||
let (icon, name) = align_to_icon_name(shown);
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(align_to_menu_width(size), size), egui::Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::same(5), t.hover);
|
||||
}
|
||||
let square = egui::Rect::from_min_size(rect.min, egui::vec2(size, size));
|
||||
crate::icons::paint(ui, icon, square.shrink((size * 0.2).round()), t.icon);
|
||||
let arrow = egui::Rect::from_center_size(egui::pos2(square.right() + ALIGN_TO_ARROW / 2.0, rect.center().y), egui::vec2(10.0, 10.0));
|
||||
crate::icons::paint(ui, "chevron-down", arrow, t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo {
|
||||
current_text_value: Some(name.to_owned()),
|
||||
..egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), tl!("Align To:"))
|
||||
});
|
||||
let resp = resp.on_hover_text(name);
|
||||
let picked = egui::Popup::menu(&resp).show(|ui| {
|
||||
let mut picked = None;
|
||||
for v in ALIGN_TO {
|
||||
if menu_item(ui, align_to_icon_name(v).1, can_choose(app, v), shown == v) {
|
||||
picked = Some(v);
|
||||
}
|
||||
}
|
||||
picked
|
||||
});
|
||||
if let Some(v) = picked.and_then(|r| r.inner) {
|
||||
choose_align_to(app, v);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let n = selection_len(app);
|
||||
let guides = selected_guides(app);
|
||||
let can_align = n + guides >= 1;
|
||||
let to = align_to(app);
|
||||
// `spacing` is the Distribute Spacing value. The Distribute Objects and Distribute Spacing
|
||||
// buttons send it while there is a key object or a key guide (#2143).
|
||||
let spacing = app.ui.align_spacing;
|
||||
@@ -299,36 +394,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.vertical(|ui| {
|
||||
widgets::subheader(ui, tl!("Align To:"));
|
||||
// With one object selected the buttons align it to the artboard, and Align To shows Artboard.
|
||||
let shown = aligns_to(to, objects_aligned(app));
|
||||
let shown = shown_align_to(app);
|
||||
ui.horizontal(|ui| {
|
||||
for (v, icon, tip) in [
|
||||
(AlignTo::Selection, "dc-alignto-selection", tl!("Align to Selection")),
|
||||
(AlignTo::Key, "dc-alignto-key", tl!("Align to Key Object")),
|
||||
(AlignTo::Artboard, "dc-alignto-artboard", tl!("Align to Artboard")),
|
||||
] {
|
||||
// A key object is one of two or more selected objects, or a guide selected
|
||||
// with objects.
|
||||
let on = v != AlignTo::Key || n >= 2 || has_key(app) || guide_for_key(app).is_some();
|
||||
if widgets::icon_button_enabled(ui, icon, tl!(tip), shown == v, on, 28.0).clicked() {
|
||||
if v == AlignTo::Key {
|
||||
// The topmost selected object becomes the key object, unless
|
||||
// there is one already. The Align To choice stays as it was, and
|
||||
// Align To shows it again once the key is cleared.
|
||||
// With one object, a guide selected with it becomes the key.
|
||||
if !has_key(app) {
|
||||
match (topmost(app).filter(|_| n >= 2), guide_for_key(app)) {
|
||||
(Some(id), _) => app.run("select.key", json!({"id": id.0})).ok(),
|
||||
(None, Some(g)) => app.run("select.key", json!({"guide": g})).ok(),
|
||||
(None, None) => None,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
app.ui.align_to = v;
|
||||
// Aligning to the selection or the artboard lets go of the key object.
|
||||
if has_key(app) {
|
||||
app.run("select.key", json!({})).ok();
|
||||
}
|
||||
}
|
||||
for v in ALIGN_TO {
|
||||
let (icon, tip) = align_to_icon_name(v);
|
||||
if widgets::icon_button_enabled(ui, icon, tip, shown == v, can_choose(app, v), 28.0).clicked() {
|
||||
choose_align_to(app, v);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -730,10 +730,11 @@ fn the_character_and_paragraph_groups_hold_the_type_controls() {
|
||||
}
|
||||
|
||||
/// #5372: at any width the Control bar trims itself from its right end, ending inside its margin.
|
||||
/// With type selected the inline X/Y/W/H fields go first, then the Distribute and Align buttons,
|
||||
/// area type's vertical alignment, the Paragraph buttons and last the Paragraph: link, while the
|
||||
/// Transform link keeps its room; a wider bar never leaves out what a narrower one showed. For one point
|
||||
/// type object, area type (its vertical alignment) and two objects (the Distribute buttons).
|
||||
/// With type selected the inline X/Y/W/H fields go first, then the Distribute buttons, the Align To
|
||||
/// menu and the Align buttons, area type's vertical alignment, the Paragraph buttons and last the
|
||||
/// Paragraph: link, while the Transform link keeps its room; a wider bar never leaves out what a
|
||||
/// narrower one showed. For one point type object, area type (its vertical alignment) and two
|
||||
/// objects (the Distribute buttons).
|
||||
#[test]
|
||||
fn the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right() {
|
||||
let ctx = control_bar_ctx();
|
||||
@@ -748,12 +749,13 @@ fn the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right()
|
||||
groups.push(("vertical alignment", VERTICAL.to_vec()));
|
||||
}
|
||||
groups.push(("Align", vec!["Horizontal Align Left", "Vertical Align Bottom"]));
|
||||
groups.push(("Align To", vec!["Align To:"]));
|
||||
if case == "two objects" {
|
||||
groups.push(("Distribute", vec!["Vertical Distribute Top", "Horizontal Distribute Right"]));
|
||||
}
|
||||
groups.push(("X/Y/W/H", vec!["X:", "H:"]));
|
||||
let mut seen = vec![false; groups.len()];
|
||||
for width in (600..=2400).step_by(10) {
|
||||
for width in (600..=2600).step_by(10) {
|
||||
let nodes = control_bar_nodes(&ctx, &mut app, width as f32, vec![]);
|
||||
let inside = |x: f64| x <= f64::from(width) - 10.0;
|
||||
let shown: Vec<bool> = groups
|
||||
|
||||
@@ -374,12 +374,48 @@ pub(crate) struct Snapshot {
|
||||
/// a live-effect or filter plug-in installed later has its row, and one removed has none.
|
||||
pub(crate) fn snapshot() -> Arc<Snapshot> {
|
||||
static LAST: Mutex<Option<Arc<Snapshot>>> = Mutex::new(None);
|
||||
let revision = menus::plugin_revision();
|
||||
let mut revision = menus::plugin_revision();
|
||||
let last = LAST.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(s) = last.filter(|s| s.revision == revision) {
|
||||
// A test's walk hook runs even when a snapshot of this revision is already stored.
|
||||
#[cfg(test)]
|
||||
let hooked = {
|
||||
let f = tests::WHILE_WALKING.take();
|
||||
let hooked = f.is_some();
|
||||
tests::WHILE_WALKING.set(f);
|
||||
hooked
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let hooked = false;
|
||||
if let Some(s) = last.filter(|s| s.revision == revision && !hooked) {
|
||||
return s;
|
||||
}
|
||||
// When a plug-in is installed or removed during the walk, the entries may not match
|
||||
// `revision`, and the keymap cached for it would lack rows or hold extra ones. The menus are
|
||||
// walked again at the new revision, and a snapshot that still doesn't match is not cached.
|
||||
let mut s = build_snapshot(revision);
|
||||
for _ in 0..8 {
|
||||
let now = menus::plugin_revision();
|
||||
if now == revision {
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// A newer snapshot another thread stored meanwhile stays.
|
||||
if last.as_ref().is_none_or(|l| l.revision <= revision) {
|
||||
*last = Some(s.clone());
|
||||
}
|
||||
return s;
|
||||
}
|
||||
revision = now;
|
||||
s = build_snapshot(revision);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The snapshot of the menus as they are now, under plug-in revision `revision`.
|
||||
fn build_snapshot(revision: u64) -> Arc<Snapshot> {
|
||||
// Built without the lock held: the Effect menu is walked anew.
|
||||
#[cfg(test)]
|
||||
if let Some(f) = tests::WHILE_WALKING.take() {
|
||||
f();
|
||||
}
|
||||
let effects: Arc<[EffectItem]> = build_effect_items().into();
|
||||
let filters: Arc<[FilterItem]> = build_filter_items().into();
|
||||
let (head, tail) = fixed_entries();
|
||||
@@ -396,13 +432,7 @@ pub(crate) fn snapshot() -> Arc<Snapshot> {
|
||||
}))
|
||||
.chain(tail.iter().cloned())
|
||||
.collect();
|
||||
let s = Arc::new(Snapshot { revision, effects, filters, entries });
|
||||
let mut last = LAST.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// A newer snapshot another thread stored meanwhile stays.
|
||||
if last.as_ref().is_none_or(|l| l.revision <= revision) {
|
||||
*last = Some(s.clone());
|
||||
}
|
||||
s
|
||||
Arc::new(Snapshot { revision, effects, filters, entries })
|
||||
}
|
||||
|
||||
/// Every tool, command and panel the editor can give a shortcut; [`listed`] says which it lists.
|
||||
@@ -1496,6 +1526,39 @@ mod tests {
|
||||
panic!("the installed plug-ins kept changing");
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Run by [`snapshot`] on this thread once, after it reads the plug-in revision and before
|
||||
/// it walks the menus.
|
||||
pub(super) static WHILE_WALKING: std::cell::Cell<Option<Box<dyn FnOnce()>>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// A plug-in installed while the menus are walked doesn't leave a snapshot under the revision
|
||||
/// read before the walk: the keymap built for that revision would then lack the plug-in's row.
|
||||
#[test]
|
||||
fn a_plugin_installed_while_the_menus_are_walked_gets_a_new_snapshot() {
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
let install = |id: &str| {
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"{id}","version":"1","kind":"filter","params":{{}}}}"#);
|
||||
vectorcraft_plugins::registry::install_bytes(&wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap()).unwrap();
|
||||
};
|
||||
let (before, during) = ("test.ui.walk-before", "test.ui.walk-during");
|
||||
install(before);
|
||||
// Stores the snapshot of this revision, as another test thread may already have done.
|
||||
snapshot();
|
||||
WHILE_WALKING.set(Some(Box::new(move || {
|
||||
// Another caller builds the snapshot and keymap of the revision read before the walk.
|
||||
crate::keymap::template_for(&snapshot());
|
||||
install(during);
|
||||
})));
|
||||
let s = snapshot();
|
||||
let keys: Vec<&str> = s.entries.iter().map(|e| e.key.as_str()).collect();
|
||||
let catalog = crate::keymap::template_for(&s).catalog().len();
|
||||
vectorcraft_plugins::registry::remove(before);
|
||||
vectorcraft_plugins::registry::remove(during);
|
||||
assert!(keys.contains(&"plugin:test.ui.walk-during"), "the walk lists the plug-in");
|
||||
assert_eq!(catalog, s.entries.len(), "the keymap of the snapshot's revision has every entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_parse_round_trip_for_every_default() {
|
||||
for e in entries().iter() {
|
||||
|
||||
@@ -291,6 +291,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
|
||||
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
|
||||
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) |
|
||||
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open |
|
||||
| HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | no | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `tests_hpgl.rs`, `import_fuzz.rs` |
|
||||
| DWG | open, export | no | no | no open spec; DXF instead |
|
||||
| EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) |
|
||||
| PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -166,6 +166,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
|
||||
- **Improved:** Eyedropper has an original pipette cursor at its sampling tip; Use Precise
|
||||
Cursors keeps the crosshair. Bitmap/DPI and preference checks cover the distinction (#1269).
|
||||
OS cursor display and Caps Lock remain unverified.
|
||||
- **Done (#6404):** Eyedropper character copying compensates for source and target text transforms, including highlighted ranges and reverse sampling; invalid dimensions reject the pick before document, default-paint or sample changes. G1 scores and estimates unchanged.
|
||||
- **Done (#1251):** Eyedropper source reads are independent of locks and isolation, including Shift/Shift+Alt and gradient stops. Source and selection preservation, unchanged Alt reverse/selection restrictions, hidden art and Undo/Redo have regression coverage. This addresses the reported behavior, with no new full-parity measurement.
|
||||
- **Done (#1253):** adding paint to an unpainted zero-width stroke restores the existing 1 pt default, including new-art defaults and Swap Fill/Stroke. The real Eyedropper inheritance sequence, targeted appearance items, preserved widths and Undo/Redo have regression coverage.
|
||||
- **Done:** a busy cursor while a file opens off the UI thread or a labelled background save or export runs (`Cursor::Busy` in `crates/tools/src/cursors.toml`). Heavy synchronous effect previews remain open.
|
||||
@@ -855,6 +856,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. |
|
||||
| 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. |
|
||||
| 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. |
|
||||
| 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user