Merge main into fix/image-codes-created-inside-a-rolled-back-node

This commit is contained in:
dotCooCoo
2026-11-14 20:55:00 -08:00
118 changed files with 4731 additions and 311 deletions
Generated
+1
View File
@@ -6824,6 +6824,7 @@ version = "0.24.1"
dependencies = [
"craft-docpath",
"craft-svg",
"data-url",
"flate2",
"image",
"kurbo 0.13.1",
+15
View File
@@ -172,6 +172,21 @@ impl IccProfile {
Some(dst.map(|x| x.clamp(0.0, 1.0)))
}
/// Device pixels `v` (RGB / CMYK / Gray, 0..1, the profile's channels a pixel, any number of
/// pixels) → sRGB in `dst` (three values a pixel), converted with `intent` in one pass of the
/// cached transform. `None` when `v` doesn't hold whole pixels, `dst` doesn't hold three values
/// for each, or no transform can be built.
pub fn to_srgb_pixels(&self, v: &[f32], dst: &mut [f32], intent: Intent) -> Option<()> {
let n = self.channels();
if !v.len().is_multiple_of(n) || dst.len() != v.len() / n * 3 {
return None;
}
let xf = self.to_xf(intent)?;
xf.transform(v, dst).ok()?;
dst.iter_mut().for_each(|x| *x = x.clamp(0.0, 1.0));
Some(())
}
/// sRGB → device values.
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option<Vec<f32>> {
let xf = self.inverse_xf(intent)?;
+22
View File
@@ -48,6 +48,28 @@ fn gray_is_srgb_toned() {
}
}
#[test]
fn many_pixels_convert_as_each_one_does() {
let p = IccProfile::from_bytes(None, &icc_bytes(DISPLAY_P3).unwrap()).unwrap();
let px = [[1.0f32, 0.0, 0.0], [0.2, 0.6, 0.9], [0.5, 0.5, 0.5], [0.0, 1.0, 0.0]];
for intent in Intent::ALL {
let v: Vec<f32> = px.iter().flatten().copied().collect();
let mut dst = vec![0.0; v.len()];
p.to_srgb_pixels(&v, &mut dst, intent).unwrap();
for (i, rgb) in px.iter().enumerate() {
let one = p.to_srgb(rgb, intent).unwrap();
assert!(dst[i * 3..i * 3 + 3].iter().zip(one).all(|(a, b)| (a - b).abs() < 1e-5), "{intent:?} {rgb:?}: {dst:?} vs {one:?}");
}
}
// Lengths that aren't whole pixels, or a destination of another size, are refused.
assert!(p.to_srgb_pixels(&[0.5; 4], &mut [0.0; 3], Intent::Perceptual).is_none());
assert!(p.to_srgb_pixels(&[0.5; 3], &mut [0.0; 6], Intent::Perceptual).is_none());
let gray = IccProfile::from_bytes(None, &icc_bytes(GRAY).unwrap()).unwrap();
let mut dst = [0.0; 6];
gray.to_srgb_pixels(&[0.25, 0.75], &mut dst, Intent::RelativeColorimetric).unwrap();
assert!(dst.iter().zip([0.25, 0.25, 0.25, 0.75, 0.75, 0.75]).all(|(a, b)| (a - b).abs() < 0.01), "{dst:?}");
}
#[test]
fn encoded_profiles_are_reproducible() {
assert_eq!(icc::builtin_rgb_bytes(DISPLAY_P3).unwrap(), icc::builtin_rgb_bytes(DISPLAY_P3).unwrap());
+5
View File
@@ -173,6 +173,10 @@ pub struct PatternDef {
/// Size Tile to Art: the tile is the art bounds grown by the spacing.
#[serde(default)]
pub size_tile_to_art: bool,
/// Move Tile with Art: in pattern editing mode, a move of all of the tile art moves the tile by
/// the same offset.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub move_tile_with_art: bool,
#[serde(default)]
pub h_spacing: f64,
#[serde(default)]
@@ -202,6 +206,7 @@ impl PatternDef {
art,
overlap: Overlap::default(),
size_tile_to_art: false,
move_tile_with_art: false,
h_spacing: 0.0,
v_spacing: 0.0,
copies: 5,
+18
View File
@@ -298,6 +298,15 @@ impl GlyphForm {
GlyphForm::QuarterWidth => b"qwid",
})
}
/// Is this one of the monospaced width forms (`fwid`, `hwid`, `twid`, `qwid`)? A font may
/// draw them with other glyphs (`GSUB`) or with other widths for the same glyphs (`GPOS`), and
/// they set the characters on a fixed width, so Proportional Metrics (`palt`) is not applied
/// with them.
/// <https://learn.microsoft.com/en-us/typography/opentype/spec/features_fj#tag-fwid>
pub fn is_width(self) -> bool {
matches!(self, GlyphForm::FullWidth | GlyphForm::HalfWidth | GlyphForm::ThirdWidth | GlyphForm::QuarterWidth)
}
}
/// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it
@@ -785,6 +794,15 @@ impl CharStyle {
self.position = self.position.in_range();
self.small_caps = self.small_caps.map(small_caps_in_range);
}
/// Sets the East Asian glyph form ([`CharStyle::glyph_form`]). A form other than the default
/// also takes away the character's glyph choice ([`CharStyle::glyph_alternate`]), which would
/// otherwise keep drawing its own glyph; the default form keeps it.
pub fn set_glyph_form(&mut self, form: GlyphForm) {
self.glyph_form = form;
if form != GlyphForm::Default {
self.glyph_alternate = None;
}
}
}
impl crate::Document {
+1 -1
View File
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Assign Profile…",
["Edit"],
None,
"{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: the working space). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}",
"{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (color numbers are kept; null: no profile, Don't Color Manage This Document, so colors show through the working space; the working space's name: that profile stays assigned when the working space changes). Colors then show and convert through the profiles assigned; the working spaces stay as edit.colorSettings set them → {rgb, cmyk}",
has_doc,
assign_profile
),
+1 -1
View File
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eraser",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one",
"{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1, tilt −90..90°, barrel rotation ° clockwise, as path.freehand), size?: pt (10), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100), angleMode?, roundnessMode?, sizeMode?: \"fixed\" (default) | \"random\" | \"pressure\" | \"tilt\" | \"bearing\" | \"rotation\" (Eraser Tool Options' variation menus; a varied angle turns either way, clockwise or counter-clockwise), angleVariation?: ° (0–180, default 0, either way: clockwise or counter-clockwise), roundnessVariation?: % (0–100, default 0), sizeVariation?: pt (0 or more, default 0)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one. A value whose mode is not fixed varies by up to its variation either way: random draws one value for the drag; pressure goes from the value less the variation where the pen presses lightest to the value plus it where it presses hardest (size on pressure as path.blob); tilt from less with the pen lying flat to more with it upright; bearing and rotation from less at −180° to more at 180° of the pen's lean direction or barrel turn. A drag without that pen reading (a mouse) erases at the value itself. An unknown mode, or a variation below 0 or past its range, is an error and erases nothing",
has_doc,
erase_region
),
+107 -5
View File
@@ -1,11 +1,14 @@
use super::common::{
dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces, samples_param,
sibling_with, size_pressure, target_paths, usize_opt,
PenRecord, Sample, dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, pressure_region, replace_with_pieces,
samples_param, sibling_with, size_pressure, target_paths, usize_opt,
};
use super::{
Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath,
Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po,
Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, Paint, ParamCurve, PathData, Point, Result, Session,
SubPath, Value, Vec2, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po,
};
use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation};
use vectorcraft_doc::StrokeLayer;
use vectorcraft_tools::draw2::MIN_BRUSH_SIZE;
/// Split a filled path into connected pieces (each outer contour with its holes).
pub(in super::super) fn components(pd: &PathData) -> Vec<PathData> {
@@ -278,7 +281,11 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
}
let angle = number_or(p, "angle", 0.0, C)?;
let roundness = number_or(p, "roundness", 100.0, C)?;
let pressure = size_pressure(p, &samples, C)?;
let vary = eraser_variations(p, C)?;
if varies_beyond_size_pressure(&vary) {
return erase_varied(s, &samples, [angle, roundness, size], vary);
}
let pressure = if vary[2].0 == Variation::Pressure { size_pressure(p, &samples, C)? } else { None };
let pts: Vec<Point> = samples.into_iter().map(|s| s.pt).collect();
let r = size / 2.0;
// The tip's short axis (its narrowest with Pressure): an open path is sampled at least every half of it.
@@ -318,6 +325,101 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result<Value> {
apply_plan(s, "Eraser", plan)
}
/// The variation modes `path.eraseRegion` takes for each of `angleMode`, `roundnessMode` and
/// `sizeMode`.
const ERASER_MODES: &str = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\"";
/// Eraser Tool Options' Angle, Roundness and Size variations: for each of angle (`angleMode` with
/// `angleVariation` in degrees, at most 180), roundness (`roundnessMode` with `roundnessVariation`
/// in percent, at most 100) and size (`sizeMode` with `sizeVariation` in points), its mode and how
/// far it varies either way. A mode left out or null is Fixed, and a variation left out or null
/// is 0; any other mode, or a variation that is negative or past its most, is refused.
fn eraser_variations(p: &Value, cmd: &str) -> Result<[(Variation, f64); 3]> {
const KEYS: [(&str, &str, f64); 3] =
[("angleMode", "angleVariation", 180.0), ("roundnessMode", "roundnessVariation", 100.0), ("sizeMode", "sizeVariation", f64::INFINITY)];
let mut out = [(Variation::Fixed, 0.0); 3];
for (slot, (mode_key, var_key, most)) in out.iter_mut().zip(KEYS) {
let mode = match p.get(mode_key).filter(|v| !v.is_null()) {
None => Variation::Fixed,
Some(Value::String(id)) => Variation::parse(id).ok_or_else(|| bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not `{id}`")))?,
Some(other) => return Err(bad(cmd, format!("{mode_key} is one of {ERASER_MODES}, not {other}"))),
};
let variation = number_or(p, var_key, 0.0, cmd)?;
if !(variation.is_finite() && variation >= 0.0) {
return Err(bad(cmd, format!("{var_key} must be 0 or more")));
}
if variation > most {
return Err(bad(cmd, format!("{var_key} must be at most {most}")));
}
*slot = (mode, variation);
}
Ok(out)
}
/// Does any of the Eraser's Angle, Roundness and Size vary with something other than Size on
/// Pressure? A mode whose variation is 0 does not vary.
fn varies_beyond_size_pressure(vary: &[(Variation, f64); 3]) -> bool {
vary.iter().enumerate().any(|(i, (mode, v))| *v > 0.0 && *mode != Variation::Fixed && !(i == 2 && *mode == Variation::Pressure))
}
/// `path.eraseRegion` with its tip varying along the drag ([`eraser_variations`]): the area the
/// tip covers is swept as a Calligraphic brush nib of the same angle, roundness, size, modes and
/// variations, with what the pen recorded along the drag. Random draws one value for the drag
/// and the pen modes follow the pen at each point; a drag without that recording (a mouse) takes
/// the value itself. `tip`: the angle, roundness and size.
fn erase_varied(s: &mut Session, samples: &[Sample], tip: [f64; 3], vary: [(Variation, f64); 3]) -> Result<Value> {
let [angle, roundness, size] = tip;
let mut pts: Vec<Point> = samples.iter().map(|s| s.pt).collect();
// A press without a drag is a dab: a sweep needs some length.
if let [only] = pts[..] {
pts.push(only + Vec2::new(0.01, 0.0));
}
// A nib's size is kept within 1296 pt and the stroke's width scales it: a larger tip is swept
// at a fraction of its size on a stroke as many times wider.
let [(am, av), (rm, rv), (sm, sv)] = vary;
let sv = if sm == Variation::Fixed { 0.0 } else { sv };
let scale = ((size + sv) / Calligraphic::RANGES[2].1).max(1.0);
let nib = Calligraphic {
angle,
roundness: roundness.clamp(1.0, 100.0),
size: size / scale,
variation: [av, rv, sv / scale],
modes: Some([am, rm, sm]),
};
let brush = Brush { name: "Eraser".into(), kind: BrushKind::Calligraphic(nib) };
let mut stroke = StrokeLayer::new(Paint::None, scale);
PenRecord::of_samples(samples).write(&mut stroke);
let spine = SubPath::polyline(&pts, false);
let st = s.doc()?;
let pieces = vectorcraft_brush::stroke_pieces(&st.doc, &brush, &PathData::single(spine).to_bezpath(), &stroke);
let areas: Vec<PathData> = pieces.iter().filter_map(|n| n.path_data().cloned()).collect();
if areas.is_empty() {
return Ok(json!({ "ids": [] }));
}
let region =
po::try_unite_all(&areas.iter().map(|a| (a, FillRule::NonZero)).collect::<Vec<_>>()).map_err(|e| EngineError::Other(e.to_string()))?;
let bp = region.to_bezpath();
// The narrowest the tip gets across: an open path is sampled at least every half of it.
let low = |i: usize| match vary[i] {
(Variation::Fixed, _) => tip[i],
(_, v) => tip[i] - v,
};
let short = low(2).max(MIN_BRUSH_SIZE) * low(1).clamp(1.0, 100.0) / 100.0;
let reach = (size + sv) / 2.0;
let mut plan: Vec<(NodeId, Vec<PathData>)> = vec![];
for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, reach))? {
if pd.is_closed() {
if po::area(&po::boolean(&pd, rule, &region, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 {
continue;
}
plan.push((id, components(&po::boolean(&pd, rule, &region, FillRule::NonZero, BoolOp::Difference))));
} else if let Some(pieces) = erase_outline(&pd, short / 2.0, &|q| fill_contains(&bp, FillRule::NonZero, q)) {
plan.push((id, pieces));
}
}
apply_plan(s, "Eraser", plan)
}
fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>) -> Result<Value> {
if plan.is_empty() {
return Ok(json!({ "ids": [] }));
+1
View File
@@ -91,6 +91,7 @@ pub(crate) use mesh3d::{mesh_file_names, mesh_files};
pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name};
pub(crate) use native::preview_png;
pub use native::with_compression_pref;
pub(crate) use pdfimport::opened_page_document;
pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub(crate) use save::save as save_document;
File diff suppressed because one or more lines are too long
+160 -8
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SymbolOverride};
use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SYMBOL_SIZES, SymbolOverride};
use vectorcraft_geom::Rect;
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
@@ -454,7 +454,12 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> {
/// make the same appearance changes to its art are measured on a copy of the symbol of their own,
/// in which each object is judged with those changes ([`as_drawn`]); the objects taken out of
/// a dynamic symbol's art leave empty groups in their places, by which instances find the objects
/// they change.
/// they change. Each copy keeps its symbol's 9-slice guides and natural size. An instance whose art
/// goes through 9-slice guides at any depth is measured as its art placed through them
/// ([`placed_instances`]), with the objects `left_out` picks hidden after placing: the guides cut
/// the box of the whole art. Where an instance stays one, the art of a symbol with 9-slice guides,
/// and of the symbols whose instances are in that art at any depth ([`sliced_art`]), is measured
/// whole.
fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Rect> {
// The symbols instances show (the first of each name).
let mut names = HashSet::new();
@@ -492,12 +497,15 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
let Some(s) = d.symbols.get(*i).cloned() else { continue };
for (k, c) in sets.get(s.name.as_str()).into_iter().flatten().flatten().enumerate() {
copies.push((d.symbols.len(), c.as_slice()));
d.symbols.push(vectorcraft_doc::Symbol { name: format!("{}\u{0}{k}", s.name), ..s.clone() });
let name = format!("{}\u{0}{k}", s.name);
copy_placement(&mut d, &s.name, &name);
d.symbols.push(vectorcraft_doc::Symbol { name, ..s.clone() });
}
}
d
});
let src = remapped_doc.as_ref().unwrap_or(doc);
let whole = sliced_art(src);
// The symbols to measure with the changes their instances make (`None` for a static symbol),
// without the dynamic symbols measured whole.
let entries: Vec<(usize, Option<&[&SymbolOverride]>)> = shown
@@ -517,11 +525,25 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
}
let left_out =
&|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n));
// The instances whose art goes through 9-slice guides, at any depth, are measured as their art
// placed by those guides without the objects `left_out` picks, and without a clip group that
// holds only such objects.
let layers = hidden_nodes(&src.layers, left_out);
let placing = holding(src, &whole);
let layers = if placing.is_empty() {
layers
} else {
let picked = &|n: &Node| {
left_out(n) || matches!(n.kind, NodeKind::Group { clip: true, .. }) && all_left_out(n, left_out, &empty, None, &mut Vec::new())
};
let mut budget = 0;
let from = layers.as_deref().unwrap_or(&src.layers);
Some(from.iter().map(|l| placed_instances(src, l, &placing, picked, &mut budget, 0).unwrap_or_else(|| l.clone())).collect())
};
let symbols: Vec<(usize, Arc<Node>)> = entries
.iter()
.filter_map(|(i, c)| {
let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()))?;
let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()) && !whole.contains(s.name.as_str()))?;
without_nodes(&s.art, left_out, *c, &mut Vec::new()).map(|art| (*i, art))
})
.collect();
@@ -540,6 +562,135 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
vectorcraft_render::encode::art_bounds(&d)
}
/// Give symbol `to` of `d` the 9-slice guides and the natural size that symbol `from` has.
fn copy_placement(d: &mut Document, from: &str, to: &str) {
if let Some(guides) = d.symbol_nine_slice(from) {
d.set_symbol_nine_slice(to, Some(guides));
}
if let Some(size) = d.unknown.get(SYMBOL_SIZES).and_then(|m| m.get(from)).cloned()
&& let Some(sizes) = d.unknown.get_mut(SYMBOL_SIZES).and_then(Value::as_object_mut)
{
sizes.insert(to.to_string(), size);
}
}
/// The names of the symbols of `doc` with 9-slice guides, and of the symbols whose instances are in
/// the art of those at any depth.
fn sliced_art(doc: &Document) -> HashSet<&str> {
let mut arts: HashMap<&str, &Arc<Node>> = HashMap::new();
for s in &doc.symbols {
arts.entry(s.name.as_str()).or_insert(&s.art);
}
let mut todo: Vec<&str> = arts.keys().copied().filter(|name| doc.symbol_nine_slice(name).is_some()).collect();
let mut found = HashSet::new();
while let Some(name) = todo.pop() {
if !found.insert(name) {
continue;
}
if let Some(&art) = arts.get(name) {
art.walk(&mut |n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
todo.push(symbol.as_str());
}
});
}
}
found
}
/// The names of the symbols of `doc` in `sliced`, and of the symbols whose art holds an instance of
/// one of those at any depth.
fn holding<'a>(doc: &'a Document, sliced: &HashSet<&'a str>) -> HashSet<&'a str> {
let mut held_by: HashMap<&str, Vec<&str>> = HashMap::new();
let mut seen = HashSet::new();
for s in doc.symbols.iter().filter(|s| seen.insert(s.name.as_str())) {
s.art.walk(&mut |n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
held_by.entry(symbol.as_str()).or_default().push(s.name.as_str());
}
});
}
let mut found = HashSet::new();
let mut todo: Vec<&str> = sliced.iter().copied().collect();
while let Some(name) = todo.pop() {
if found.insert(name) {
todo.extend(held_by.get(name).into_iter().flatten().copied());
}
}
found
}
/// The most instances deep [`placed_instances`] places symbol art.
const MAX_PLACED_DEPTH: usize = 32;
/// `n` with each instance of the `placing` symbols in it, and in its layers and groups at any depth,
/// made a group with the instance's appearance that holds the instance's art (with its changes and
/// stain) as `doc` places it, through the symbol's 9-slice guides when it has them, without the
/// objects `picked` picks ([`without_nodes`]; hidden when it picks the whole art); `None` when `n`
/// holds no such instance, or is hidden. An instance on a perspective grid, one
/// [`MAX_PLACED_DEPTH`] instances deep, one in another kind of object (a blend, for example), and
/// one whose art would take the objects placed in all (`budget`) past
/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] stay instances.
/// Their symbols' art is measured whole, as taking objects out of it would change the box the
/// guides cut, and the page can come out larger than the plot.
fn placed_instances(
doc: &Document,
n: &Arc<Node>,
placing: &HashSet<&str>,
picked: &dyn Fn(&Node) -> bool,
budget: &mut u64,
depth: usize,
) -> Option<Arc<Node>> {
// Hidden art is not plotted, and placing it would take from the budget of the art that is.
if !n.visible {
return None;
}
match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => {
if !placing.contains(symbol.as_str()) || depth >= MAX_PLACED_DEPTH || n.projection().is_some() {
return None;
}
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
let most = doc.symbol_slice_map(symbol, *xf).map_or(art.count_with_masks() as u64, |m| m.most_placed(&art, doc));
if budget.saturating_add(most) > vectorcraft_doc::MAX_SYMBOL_NODES {
return None;
}
let art = Arc::new(doc.place_symbol_art(symbol, &art, *xf)?);
*budget = budget.saturating_add(art.count_with_masks() as u64);
let art = placed_instances(doc, &art, placing, picked, budget, depth + 1).unwrap_or(art);
let art = if picked(&art) {
let mut hidden = (*art).clone();
hidden.visible = false;
Arc::new(hidden)
} else {
without_nodes(&art, picked, None, &mut Vec::new()).unwrap_or(art)
};
let mut g = (**n).clone();
g.kind = NodeKind::Group { children: vec![art], clip: false };
Some(Arc::new(g))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
let mut out: Option<Vec<Arc<Node>>> = None;
for (i, c) in children.iter().enumerate() {
if let Some(new) = placed_instances(doc, c, placing, picked, budget, depth)
&& let Some(slot) = out.get_or_insert_with(|| children.clone()).get_mut(i)
{
*slot = new;
}
}
out.map(|c| {
let mut m = (**n).clone();
if let Some(children) = m.children_mut() {
*children = c;
}
Arc::new(m)
})
}
_ => None,
}
}
/// The appearance changes instance `n` makes to its symbol's art, without those that change nothing.
fn own_changes(n: &Node) -> Vec<&SymbolOverride> {
n.extras().symbol_overrides.iter().filter(|o| !o.is_empty()).collect()
@@ -600,9 +751,10 @@ fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str
/// Add the sets of appearance changes that the instances in `n` and its subtree make to the art of
/// the symbols `sets` names, each set once, and those of the instances in the art of the symbols the
/// instances show (`walked`, the symbols already looked in). The instances that `left_out` picks or
/// that are in an object it picks are left out, and with `hidden` (in a layer) the hidden ones and
/// those in a hidden object. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`.
/// instances show (`walked`, the symbols already looked in). With `hidden` (in a layer), the
/// instances that are hidden or that `left_out` picks, and those in such an object, are left out.
/// In symbol art every instance counts, since an instance of the symbol can change the objects
/// that hold it. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`.
fn drawn_changes<'a>(
doc: &'a Document,
n: &'a Node,
@@ -611,7 +763,7 @@ fn drawn_changes<'a>(
walked: &mut HashSet<&'a str>,
sets: &mut HashMap<&'a str, Option<Vec<Vec<&'a SymbolOverride>>>>,
) {
if hidden && !n.visible || left_out(n) {
if hidden && (!n.visible || left_out(n)) {
return;
}
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
File diff suppressed because one or more lines are too long
+58 -15
View File
@@ -205,7 +205,7 @@ pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Docu
}
/// [`page_document`] of `opened`, the PDF `bytes` already read.
fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
pub(crate) fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
vectorcraft_pdf::import_opened(bytes, opened, &page_options(page, o)).map(|r| (r.document, r.warnings)).map_err(err)
}
@@ -217,8 +217,11 @@ fn page_options(page: usize, o: &LoadOptions) -> ImportOptions {
/// `document.open` with `linkPages`: the pages `o` picks of the PDF (or PDF-compatible `.ai` or
/// `.ait`) `name`, read from `path`, as a document holding one placed document per page, linked to
/// that page of the file as File › Place with Link places one ([`document::source`]). Each page
/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`), in a row from the left
/// with the gap a PDF's artboards open with, and a layer named after the page. The document opens
/// gets an artboard of its `o.crop` box (its art's bounds for `bounding`, a blank page's crop box;
/// a PDF-compatible `.ai` asked for its crop box gets its trim box, as it opens), in a row from the
/// left with the gap a PDF's artboards open with, and a layer named after the page. On its trim box,
/// a page shows its bleed box round the artboard, and the document's bleed is the pages' bleed (the
/// widest on each side); the artboards are then the gap apart plus the bleed on both sides. The document opens
/// in CMYK when most of the pages read in CMYK, unless `o.color_mode` names a mode. Refused without a
/// path, for any other file, with a password (a linked page is read again without one), with
/// `textAs: outlines` (a linked page shows the file's own type) and for a file over
@@ -239,12 +242,16 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa
if bytes.len() > document::MAX_FILE {
return Err(bad(C, format!("linkPages: `{file}` is over {} MB", document::MAX_FILE >> 20)));
}
let count = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?.page_count();
// The file is read once: every page is read from it.
let opened = vectorcraft_pdf::open_pdf(bytes, None).map_err(err)?;
let count = opened.page_count();
let picked = match &o.pages {
None => (0..count).collect(),
Some(range) => parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?,
};
let bounding = o.crop == CropTo::Bounding;
// A PDF-compatible `.ai` asked for its crop box opens on its trim boxes ([`LoadOptions::import_options`]).
let crop = if o.crop == CropTo::Crop && opened.has_illustrator_data() { CropTo::Trim } else { o.crop };
let mut doc = Document::new(1.0, 1.0);
let board = doc.artboards.first().cloned().ok_or_else(|| err("a new document has no artboard"))?;
doc.artboards.clear();
@@ -253,14 +260,55 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa
let gap = ImportOptions::default().artboard_gap;
// Every page's object keeps the same one copy of the file.
let shared = Arc::new(bytes.to_vec());
let (mut x, mut warnings, mut cmyk_pages) = (0.0, Vec::<String>::new(), 0);
for (i, &index) in picked.iter().enumerate() {
// The file is hashed once for all its pages.
let hash = link.hash.clone().unwrap_or_else(|| vectorcraft_doc::links::hash_bytes(bytes));
let (mut warnings, mut cmyk_pages) = (Vec::<String>::new(), 0);
// Each page as read, with its bleed (top, bottom, left, right). The pages are laid out once
// the document's bleed is known.
let mut read_pages = Vec::with_capacity(picked.len());
for &index in &picked {
let page = index as u32 + 1;
let s = document::shared_source(name, &shared, Some(path), page, bounding, document::page_box(o.crop), C)?;
let file_once = document::SharedFile { bytes: &shared, hash: &hash, opened: &opened };
// On its trim box, a page shows its bleed box, the artboard on the trim box inside it, so
// the art in the bleed shows and prints as bleed.
let bleed = if crop == CropTo::Trim { opened.page_bleed(index) } else { None };
let page_box = if bleed.is_some() { vectorcraft_doc::PageBox::Bleed } else { document::page_box(crop) };
let read = |bounding| document::shared_source(name, file_once, Some(path), page, bounding, page_box, C);
// A blank page has no art's bounds: it keeps its crop box, as opening it as art does.
let (mut s, bounding) = match read(bounding) {
Err(e) if bounding && e.to_string().contains("has no art to place") => (read(false)?, false),
s => (s?, bounding),
};
// A page that is only a placeholder (a `.ai` saved without PDF compatibility) shows the
// file's editing data, not a page of the file.
if s.editing_data {
return Err(bad(C, format!("linkPages: `{file}` isn't a PDF or a PDF-compatible .ai file")));
}
cmyk_pages += usize::from(s.color_mode == ColorMode::Cmyk);
// The document's bleed is the widest of the pages' on each side.
let sides = bleed.unwrap_or_default();
for (side, b) in doc.setup.bleed.iter_mut().zip(sides) {
*side = side.max(b.min(vectorcraft_doc::setup::MAX_BLEED));
}
for w in std::mem::take(&mut s.warnings) {
if !warnings.contains(&w) {
warnings.push(w);
}
}
read_pages.push((page, s, bounding, sides));
}
// Each artboard is the gap from the one before plus the bleed on both sides of it: the
// document's bleed, which export adds round every artboard, or a page's own bleed box where it
// reaches farther. Neither page then shows or exports the other's art.
let [_, _, doc_left, doc_right] = doc.setup.bleed;
let mut x = 0.0;
for (i, (page, s, bounding, [top, bottom, left, right])) in read_pages.into_iter().enumerate() {
if i > 0 {
x += doc_left.max(left);
}
let mut node = document::node(&mut doc, &s, LinkInfo { page: Some(page), ..link.clone() }, bounding);
if let NodeKind::PlacedDocument(p) = &mut node.kind {
p.xf = Affine::translate((x, 0.0));
p.xf = Affine::translate((x - left, -top));
}
node.name = Some(file.clone());
let mut layer = Node::layer(doc.alloc_id(), &format!("Page {page}"), LayerColor::Preset((i % 27) as u8));
@@ -268,14 +316,9 @@ pub(super) fn linked_pages(name: &str, bytes: &[u8], path: Option<&str>, o: &Loa
children.push(Arc::new(node));
}
doc.layers.push(Arc::new(layer));
let rect = Rect::new(x, 0.0, x + s.frame.width(), s.frame.height());
let rect = Rect::new(x, 0.0, x + s.frame.width() - left - right, s.frame.height() - top - bottom);
doc.artboards.push(Artboard { id: i as u32 + 1, name: format!("Artboard {}", i + 1), rect, ..board.clone() });
x = rect.x1 + gap;
for w in s.warnings {
if !warnings.contains(&w) {
warnings.push(w);
}
}
x = rect.x1 + doc_right.max(right) + gap;
}
// Pages painted mostly in CMYK read as CMYK documents, as opening them as art does: the
// document opens in CMYK (with the CMYK default swatches) when most of its pages do.
@@ -1160,6 +1160,91 @@ fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() {
assert!(e.contains("placeholder"), "{e}");
}
/// `document.open` with `linkPages` refuses a `.ai` saved without PDF compatibility, whose page is
/// only a placeholder, and opens a PDF-compatible one.
#[test]
fn link_pages_refuses_an_ai_file_saved_without_its_pdf_part() {
let d = std::env::temp_dir().join(format!("vc-ailinkpages-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let file = |name: &str, page: &str| {
let path = d.join(name);
std::fs::write(&path, ai::ai(&compressed(&ai::sample_data()), page)).unwrap();
path.to_string_lossy().to_string()
};
let (plain, compatible) = (file("plain.ai", PLACEHOLDER), file("compatible.ai", "0 g 10 10 40 40 re f"));
let mut s = Session::new();
let e = s.execute("document.open", &json!({"path": plain, "linkPages": true})).unwrap_err().to_string();
assert!(e.contains("isn't a PDF or a PDF-compatible .ai file"), "{e}");
assert!(s.documents().is_empty(), "nothing opened");
s.execute("document.open", &json!({"path": compatible, "linkPages": true})).unwrap();
assert!(s.doc().unwrap().doc.has_placed());
let _ = std::fs::remove_dir_all(&d);
}
/// `document.open` with `linkPages` opens a PDF-compatible `.ai` asked for its crop box on its trim
/// box, as it opens as art, showing its bleed box round the artboard, which is the document's bleed.
#[test]
fn link_pages_open_an_ai_file_on_its_trim_box_with_its_bleed() {
let d = std::env::temp_dir().join(format!("vc-ailinkpagestrim-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let path = d.join("trimmed.ai");
let boxes = "/TrimBox [10 10 190 90] /BleedBox [4 2 196 98]";
std::fs::write(&path, ai::ai_with(&compressed(&ai::sample_data()), "0 g 10 10 40 40 re f", boxes)).unwrap();
let path = path.to_string_lossy().to_string();
let mut s = Session::new();
s.execute("document.open", &json!({"path": path, "linkPages": true})).unwrap();
let doc = &s.doc().unwrap().doc;
let board = doc.artboards[0].rect;
assert_eq!((board.width(), board.height()), (180.0, 80.0), "the trim box");
assert_eq!(doc.setup.bleed, [8.0, 8.0, 6.0, 6.0], "top, bottom, left, right");
// The page shows its bleed box, so the art in the bleed is kept.
let placed = doc.layers[0].children().unwrap()[0].clone();
let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("{:?}", placed.kind) };
assert_eq!((p.width, p.height, p.page_box), (192.0, 96.0, vectorcraft_doc::PageBox::Bleed));
assert_eq!(p.xf.translation(), vectorcraft_geom::Vec2::new(-6.0, -8.0), "the trim box on the artboard");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn link_pages_on_their_trim_boxes_keep_the_bleed_between_the_artboards() {
// Two 100 pt trim boxes: the first with 60 pt of bleed on each side, the second with 80 pt on
// its left, more than the largest document bleed (72 pt).
let entries = format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(2));
let first = PdfPage {
bleed: Some([0.0, 0.0, 220.0, 220.0]),
trim: Some([60.0, 60.0, 160.0, 160.0]),
entries,
..PdfPage::new(220.0, 220.0, "0 g 0 0 220 220 re f")
};
let second = PdfPage {
bleed: Some([0.0, 0.0, 240.0, 220.0]),
trim: Some([80.0, 60.0, 180.0, 160.0]),
..PdfPage::new(240.0, 220.0, "0 g 0 0 240 220 re f")
};
let d = std::env::temp_dir().join(format!("vc-ailinkpagesgap-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let path = d.join("pages.ai");
std::fs::write(&path, pdf_with(&[first, second], &["<< /Length 4 >>\nstream\ndata\nendstream"], None)).unwrap();
let path = path.to_string_lossy().to_string();
let mut s = Session::new();
s.execute("document.open", &json!({"path": path, "linkPages": true})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.setup.bleed, [60.0, 60.0, 72.0, 60.0], "top, bottom, left, right");
// The first page's bleed ends at 160; the second's starts 36 pt later, 80 pt left of its trim box.
let boards: Vec<(f64, f64)> = doc.artboards.iter().map(|a| (a.rect.x0, a.rect.x1)).collect();
assert_eq!(boards, [(0.0, 100.0), (276.0, 376.0)]);
let shown: Vec<f64> = doc
.layers
.iter()
.map(|l| match &l.children().unwrap()[0].kind {
NodeKind::PlacedDocument(p) => p.xf.translation().x,
k => panic!("{k:?}"),
})
.collect();
assert_eq!(shown, [-60.0, 196.0], "each page's bleed box round its artboard");
let _ = std::fs::remove_dir_all(&d);
}
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
+146
View File
@@ -338,6 +338,152 @@ fn a_plot_of_the_arts_bounds_leaves_out_images_in_dynamic_symbol_art() {
assert!(e.contains("no visible art"), "{e}");
}
/// A rectangle filled black, or one that paints nothing.
fn plot_rect(d: &mut vectorcraft_doc::Document, r: vectorcraft_geom::Rect, painted: bool) -> std::sync::Arc<vectorcraft_doc::Node> {
use vectorcraft_color::{Color, Paint};
let fill = if painted { Paint::solid(Color::BLACK) } else { Paint::None };
let appearance = vectorcraft_doc::Appearance::basic(fill, Paint::None, 0.0);
std::sync::Arc::new(vectorcraft_doc::Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), appearance))
}
/// An instance of symbol `symbol` placed by `xf`.
fn plot_instance(d: &mut vectorcraft_doc::Document, symbol: &str, xf: vectorcraft_geom::Affine) -> vectorcraft_doc::Node {
vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf })
}
/// Symbol `name` holding `art` in a group.
fn plot_symbol(d: &mut vectorcraft_doc::Document, name: &str, art: Vec<std::sync::Arc<vectorcraft_doc::Node>>, dynamic: bool) {
let group = vectorcraft_doc::Node::group(d.alloc_id(), art);
d.symbols.push(vectorcraft_doc::Symbol { dynamic, name: name.into(), art: std::sync::Arc::new(group) });
}
/// Puts `n` on the first layer of `d`.
fn plot_on_layer(d: &mut vectorcraft_doc::Document, n: vectorcraft_doc::Node) {
std::sync::Arc::make_mut(&mut d.layers[0]).children_mut().unwrap().push(std::sync::Arc::new(n));
}
/// The art of a symbol with 9-slice guides, static or dynamic, is placed whole and measured
/// without what the plot leaves out: a frame that paints nothing sets where the guides place the
/// painted art inside it, and is not measured itself.
#[test]
fn a_plot_of_the_arts_bounds_places_9_slice_symbol_art_by_all_of_it() {
use vectorcraft_geom::{Affine, Rect};
// Static and dynamic, and with the instance's fill staining the art.
for (dynamic, stained) in [(false, false), (true, false), (false, true)] {
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
let square = plot_rect(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), true);
plot_symbol(&mut d, "Panel", vec![frame, square], dynamic);
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let mut wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
if stained {
let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
wide.appearance = vectorcraft_doc::Appearance::basic(red, vectorcraft_color::Paint::None, 0.0).into();
}
plot_on_layer(&mut d, wide);
// Scaled 2 times across, the middle column maps x to 3x - 50: the square draws from x 70
// to 130.
let b = super::export_art_bounds(&d, "hpgl").unwrap();
let exact = [(b.x0, 70.0), (b.y0, 40.0), (b.x1, 130.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6);
assert!(exact, "dynamic: {dynamic}, stained: {stained}, {b:?}");
}
}
/// In art placed through 9-slice guides, a group with a fill of its own keeps the path it paints,
/// which paints nothing by itself.
#[test]
fn a_plot_of_the_arts_bounds_keeps_what_a_group_paints_in_9_slice_symbol_art() {
use vectorcraft_geom::{Affine, Rect};
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
let bare = plot_rect(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), false);
let mut group = vectorcraft_doc::Node::group(d.alloc_id(), vec![bare]);
let black = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
group.appearance = vectorcraft_doc::Appearance::basic(black, vectorcraft_color::Paint::None, 0.0).into();
plot_symbol(&mut d, "Panel", vec![frame, std::sync::Arc::new(group)], false);
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
plot_on_layer(&mut d, wide);
let b = super::export_art_bounds(&d, "hpgl").expect("the group's path is plotted");
let exact = [(b.x0, 70.0), (b.y0, 40.0), (b.x1, 130.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6);
assert!(exact, "{b:?}");
}
/// A symbol without guides in the art of a symbol with 9-slice guides is measured without what the
/// plot leaves out, in the placed art and in an instance of its own.
#[test]
fn a_plot_of_the_arts_bounds_leaves_out_unplotted_art_of_symbols_in_9_slice_art() {
use vectorcraft_geom::{Affine, Rect};
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
let far = plot_rect(&mut d, Rect::new(45.0, -500.0, 55.0, -480.0), false);
let dot = plot_rect(&mut d, Rect::new(45.0, 45.0, 55.0, 55.0), true);
plot_symbol(&mut d, "Dot", vec![far, dot], false);
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
let inner = std::sync::Arc::new(plot_instance(&mut d, "Dot", Affine::IDENTITY));
plot_symbol(&mut d, "Panel", vec![frame, inner], false);
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
plot_on_layer(&mut d, wide);
let near = |b: Rect, e: [f64; 4]| [b.x0, b.y0, b.x1, b.y1].iter().zip(e).all(|(a, e)| (a - e).abs() < 1e-6);
// The dot's box is in the middle column, which maps x to 3x - 50.
let b = super::export_art_bounds(&d, "hpgl").unwrap();
assert!(near(b, [85.0, 45.0, 115.0, 55.0]), "{b:?}");
let own = plot_instance(&mut d, "Dot", Affine::translate((200.0, 0.0)));
plot_on_layer(&mut d, own);
let b = super::export_art_bounds(&d, "hpgl").unwrap();
assert!(near(b, [85.0, 45.0, 255.0, 55.0]), "{b:?}");
}
/// The copy of a dynamic symbol that an instance's changes are measured on keeps the symbol's
/// 9-slice guides.
#[test]
fn a_plot_of_the_arts_bounds_measures_changed_9_slice_instances_by_their_guides() {
use vectorcraft_geom::{Affine, Rect};
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
let bar = plot_rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), true);
plot_symbol(&mut d, "Bar", vec![bar], false);
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
let inner = std::sync::Arc::new(plot_instance(&mut d, "Bar", Affine::IDENTITY));
plot_symbol(&mut d, "Panel", vec![frame, inner], true);
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let mut wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
wide.extras_mut().symbol_overrides =
vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(vectorcraft_color::Paint::None), ..Default::default() }];
plot_on_layer(&mut d, wide);
// The bar crosses the left guide and is placed by the middle column's map, x to 3x - 50: it
// draws from x -20 to 160.
let b = super::export_art_bounds(&d, "hpgl").unwrap();
let exact = |b: Rect| [(b.x0, -20.0), (b.y0, 40.0), (b.x1, 160.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6);
assert!(exact(b), "{b:?}");
// The same instance in the art of a symbol without guides.
let wide = std::sync::Arc::make_mut(&mut d.layers[0]).children_mut().unwrap().pop().unwrap();
plot_symbol(&mut d, "Holder", vec![wide], false);
let holder = plot_instance(&mut d, "Holder", Affine::IDENTITY);
plot_on_layer(&mut d, holder);
let b = super::export_art_bounds(&d, "hpgl").unwrap();
assert!(exact(b), "{b:?}");
}
/// An instance in a dynamic symbol's art, in a group at 0% opacity that an instance of the symbol
/// brings back to full opacity, is measured with the paint it gives its own symbol's art.
#[test]
fn a_plot_of_the_arts_bounds_measures_instances_that_an_instance_brings_back() {
use vectorcraft_geom::{Affine, Rect};
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
let bare = plot_rect(&mut d, Rect::new(300.0, 300.0, 320.0, 320.0), false);
plot_symbol(&mut d, "Inner", vec![bare], true);
let mut inner = plot_instance(&mut d, "Inner", Affine::IDENTITY);
let black = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
inner.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(black), ..Default::default() }];
plot_symbol(&mut d, "Outer", vec![std::sync::Arc::new(inner)], true);
std::sync::Arc::make_mut(&mut d.symbols[1].art).opacity = 0.0;
let mut outer = plot_instance(&mut d, "Outer", Affine::IDENTITY);
outer.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![], opacity: Some(1.0), ..Default::default() }];
plot_on_layer(&mut d, outer);
let b = super::export_art_bounds(&d, "hpgl").expect("the painted rectangle is plotted");
assert!((b.x0 - 300.0).abs() < 1e-6 && (b.x1 - 320.0).abs() < 1e-6, "{b:?}");
}
/// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left
/// out when it would take more cells than the effect makes.
#[test]
@@ -158,6 +158,9 @@ fn apply_transform_moving_guides(
}
None => ids.clone(),
};
// Pattern editing with Move Tile with Art: the tile art's bounds before a move.
let moves_only = c[0] == 1.0 && c[1] == 0.0 && c[2] == 0.0 && c[3] == 1.0;
let tile_art = if objects && !copy && moves_only { crate::cmd::patterncmds::tile_art_bounds(d) } else { None };
// Transforms don't move nodes in the tree, so reuse the paths found for the no-copy
// case; copies and a settled typing session need paths from the edited document.
let paths = if reindex { d.index_paths(targets.iter().copied()) } else { paths };
@@ -172,6 +175,10 @@ fn apply_transform_moving_guides(
for id in &targets {
super::pixelgrid::keep_compound_aligned(d, *id, sc);
}
// Moving all of the tile art moves the tile as far.
if let Some(before) = tile_art {
crate::cmd::patterncmds::move_tile_with_art(d, &targets, before);
}
}
if with_guides && (delta.x != 0.0 || delta.y != 0.0) {
for i in sel.guides.clone() {
File diff suppressed because one or more lines are too long
+64 -8
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pattern Options",
["Window", "Pattern Options"],
None,
"{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
"{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, moveTileWithArt?: bool (in pattern editing mode, a move of all of the tile art moves the tile with it; off for a new pattern), overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
has_doc,
pattern_options
),
@@ -530,7 +530,7 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?;
let tt = tile_type_param(p, C, cur.tile_type)?;
check_numbers(p, &["width", "height", "hSpacing", "vSpacing", "copies", "dimCopies"], C)?;
check_switches(p, &["sizeTileToArt", "showTileEdge", "showSwatchBounds"], C)?;
check_switches(p, &["sizeTileToArt", "moveTileWithArt", "showTileEdge", "showSwatchBounds"], C)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
for k in ["width", "height"] {
if num(k).is_some_and(|v| !(v.is_finite() && v > 0.0)) {
@@ -560,11 +560,11 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
};
let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer);
let out_name = new_name.clone().unwrap_or(name.clone());
s.edit("Pattern Options", |d, _| {
// While editing, the tile art is the edit layer's.
let live_art: Option<Vec<Arc<Node>>> =
edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect());
let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?;
// While editing, the tile art is the edit layer's.
let live_art = |d: &Document| -> Option<Vec<Arc<Node>>> {
edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect())
};
let apply = |def: &mut PatternDef, live_art: Option<Vec<Arc<Node>>>| {
def.tile_type = tt;
def.overlap = overlap;
if let Some(v) = num("hSpacing") {
@@ -576,6 +576,9 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = p.get("sizeTileToArt").and_then(Value::as_bool) {
def.size_tile_to_art = v;
}
if let Some(v) = p.get("moveTileWithArt").and_then(Value::as_bool) {
def.move_tile_with_art = v;
}
if let Some(v) = num("copies") {
def.copies = (v as u32).clamp(1, 15) | 1;
}
@@ -590,13 +593,24 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
}
if def.size_tile_to_art {
let saved = std::mem::take(&mut def.art);
def.art = live_art.clone().unwrap_or_else(|| saved.clone());
def.art = live_art.unwrap_or_else(|| saved.clone());
def.fit_tile_to_art();
def.art = saved;
} else if num("width").is_some() || num("height").is_some() {
let c = def.tile.center();
def.tile = Rect::from_center_size(c, (num("width").unwrap_or(def.tile.width()), num("height").unwrap_or(def.tile.height())));
}
};
// Options that change nothing leave the document and its undo history as they are.
let mut next = cur.clone();
apply(&mut next, live_art(&s.doc()?.doc));
if next == cur && new_name.is_none() {
return Ok(json!({ "name": out_name }));
}
s.edit("Pattern Options", |d, _| {
let live = live_art(d);
let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?;
apply(def, live);
if let Some(nn) = &new_name {
def.name = nn.clone();
rename_pattern_uses(d, &name, nn);
@@ -609,6 +623,47 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": out_name }))
}
/// The tile art's objects (the editing layer's, ruler guides aside) while `d` edits a pattern that
/// has Move Tile with Art on, with the pattern's name.
fn moving_tile_art(d: &Document) -> Option<(String, Vec<Arc<Node>>)> {
let pe = d.pattern_edit.as_ref()?;
if !d.pattern(&pe.pattern).is_some_and(|p| p.move_tile_with_art) {
return None;
}
let art = d.node(pe.layer)?.children()?.iter().filter(|c| !c.is_ruler_guide()).cloned().collect();
Some((pe.pattern.clone(), art))
}
/// Before a move: the bounds of the tile art for [`move_tile_with_art`], `None` unless `d` edits a
/// pattern with Move Tile with Art on.
pub(crate) fn tile_art_bounds(d: &Document) -> Option<Rect> {
moving_tile_art(d).and_then(|(_, art)| vectorcraft_doc::live::nodes_bounds(&art))
}
/// Move Tile with Art: after a move of `moved` that takes every object of the tile art (a group
/// counting when all of its objects moved), the tile moves by as much as the art did, measured from
/// its bounds `before` the move (objects aligned to the pixel grid snap back to it). A move of only
/// some of the art leaves the tile where it is.
pub(crate) fn move_tile_with_art(d: &mut Document, moved: &[NodeId], before: Rect) {
let Some((name, art)) = moving_tile_art(d) else { return };
let moved: std::collections::HashSet<NodeId> = moved.iter().copied().collect();
// An object moves whole when it moved, or when every object in it did (a group whose members
// were selected and moved one by one).
fn whole(n: &Node, moved: &std::collections::HashSet<NodeId>) -> bool {
moved.contains(&n.id) || n.children().is_some_and(|ch| !ch.is_empty() && ch.iter().all(|c| whole(c, moved)))
}
if art.is_empty() || !art.iter().all(|c| whole(c, &moved)) {
return;
}
let Some(after) = vectorcraft_doc::live::nodes_bounds(&art) else { return };
let by = after.origin() - before.origin();
if (by.x != 0.0 || by.y != 0.0)
&& let Some(def) = d.pattern_mut(&name)
{
def.tile = def.tile + by;
}
}
/// Side `side` of the `overlap` object `o` gives `cmd`: true for the second of `names`, false for the
/// first, `current` when `o` leaves it out or gives null. Any other value is an error.
fn overlap_side(o: &Value, side: &str, names: [&str; 2], current: bool, cmd: &str) -> Result<bool> {
@@ -680,6 +735,7 @@ fn pattern_list(s: &mut Session, _: &Value) -> Result<Value> {
"hSpacing": p.h_spacing,
"vSpacing": p.v_spacing,
"sizeTileToArt": p.size_tile_to_art,
"moveTileWithArt": p.move_tile_with_art,
"overlap": {"h": if p.overlap.right_in_front { "right" } else { "left" }, "v": if p.overlap.bottom_in_front { "bottom" } else { "top" }},
"copies": p.copies,
"dimCopies": p.dim_copies,
+12 -6
View File
@@ -257,7 +257,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
let (natural, warnings) = (s.frame, s.warnings.clone());
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
}
let a = pdf_art(src.name, &src.bytes, src.path, page, opts, !crop, cmd)?;
let a = pdf_art(src.name, (&src.bytes, None), src.path, page, opts, !crop, cmd)?;
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
}
@@ -308,10 +308,12 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
/// art; read with `opts.password`. Only the page placed is read. A .ai saved without PDF
/// compatibility is read from its editing data, as it opens, and placed as an EPS is: the art on
/// artboard `page`, its linked images found from its folder. So is page 1 of a .ai or PDF whose
/// PDF part can't be read.
/// PDF part can't be read. `opened`, when given, is the PDF `bytes` already read
/// ([`vectorcraft_pdf::open_pdf`] with `opts`' password): its pages are read without reading the
/// file again.
pub(crate) fn pdf_art(
src: &str,
bytes: &[u8],
(bytes, opened): (&[u8], Option<&vectorcraft_pdf::Opened>),
path: Option<&str>,
page: usize,
opts: fileio::LoadOptions,
@@ -321,11 +323,15 @@ pub(crate) fn pdf_art(
let name = fileio::file_name(src);
let index = page.checked_sub(1).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))?;
let o = fileio::LoadOptions { crop: if bounding { CropTo::Crop } else { opts.crop }, ..opts };
let mut read = fileio::page_document(bytes, index, &o);
let page_document = |i: usize| match opened {
Some(opened) => fileio::opened_page_document(bytes, opened, i, &o),
None => fileio::page_document(bytes, i, &o),
};
let mut read = page_document(index);
// Such a file can have more artboards than placeholder pages.
if page > 1
&& read.is_err()
&& let Err(e) = fileio::page_document(bytes, 0, &o)
&& let Err(e) = page_document(0)
&& fileio::is_placeholder(&e)
{
read = Err(e);
@@ -351,7 +357,7 @@ pub(crate) fn pdf_art(
let clip = (!bounding).then_some(board);
let frame = clip.or_else(|| shown_bounds(&symbols, &nodes));
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
Ok(document::DocumentArt { doc, nodes, clip, frame, warnings })
Ok(document::DocumentArt { doc, nodes, clip, frame, warnings, editing_data: alone })
}
/// The bounds of what `nodes` draw, as an export measures art
+48 -13
View File
@@ -100,6 +100,9 @@ pub(crate) struct DocumentArt {
/// What is shown: the artboard, or the art's bounds.
pub frame: Rect,
pub warnings: Vec<String>,
/// Whether a PDF or `.ai` page was read from the file's editing data (its page only a
/// placeholder, or its PDF part unreadable) instead of from the page.
pub editing_data: bool,
}
/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when
@@ -114,6 +117,21 @@ pub(crate) fn document_art(
bounding: bool,
page_box: PageBox,
cmd: &str,
) -> Result<DocumentArt> {
opened_document_art(name, (bytes, None), path, page, bounding, page_box, cmd)
}
/// [`document_art`] of `bytes` and, for a PDF already read, `opened`: the PDF `bytes` as
/// [`vectorcraft_pdf::open_pdf`] read it without a password, so its page is read without reading
/// the file again.
fn opened_document_art(
name: &str,
(bytes, opened): (&[u8], Option<&vectorcraft_pdf::Opened>),
path: Option<&str>,
page: u32,
bounding: bool,
page_box: PageBox,
cmd: &str,
) -> Result<DocumentArt> {
let file = fileio::file_name(name);
let pdf = is_pdf(name, bytes);
@@ -125,7 +143,7 @@ pub(crate) fn document_art(
}
if pdf {
let o = fileio::LoadOptions { crop: crop_to(page_box), ..Default::default() };
return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd);
return super::pdf_art(name, (bytes, opened), path, page as usize, o, bounding, cmd);
}
let mut l = fileio::load(name, bytes)?;
// Its linked images show their files; its placed documents stay as they are, unless their
@@ -145,7 +163,7 @@ pub(crate) fn document_art(
let nodes = super::on_artboard(&symbols, &layers, board);
let frame = if bounding { super::shown_bounds(&symbols, &nodes) } else { Some(board) };
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?;
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings, editing_data: false })
}
/// The most placed documents read inside one another while their links are resolved.
@@ -223,6 +241,8 @@ pub(crate) struct Source {
pub warnings: Vec<String>,
/// The colour mode the file's art was read in (a PDF page painted mostly in CMYK reads in CMYK).
pub color_mode: ColorMode,
/// [`DocumentArt::editing_data`].
pub editing_data: bool,
}
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
@@ -232,33 +252,48 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo
read_source(name, bytes, None, path, (page, bounding, page_box), cmd)
}
/// [`source`] of the file whose bytes `bytes` holds, kept by the blob without a copy of its own:
/// several pages of one file read this way share one copy of it.
/// A PDF (or PDF-compatible `.ai`) whose pages are read for placing linked, read once for them all.
#[derive(Clone, Copy)]
pub(crate) struct SharedFile<'a> {
/// The file's bytes, which every page's blob keeps without a copy of its own.
pub bytes: &'a Arc<Vec<u8>>,
/// The bytes' hash ([`hash_bytes`]).
pub hash: &'a str,
/// The bytes as [`vectorcraft_pdf::open_pdf`] read them, without a password.
pub opened: &'a vectorcraft_pdf::Opened,
}
/// [`source`] of page `page` of `file`: several pages of one file read this way share one copy of
/// it, and the file is hashed and read once for them all.
pub(crate) fn shared_source(
name: &str,
bytes: &Arc<Vec<u8>>,
file: SharedFile<'_>,
path: Option<&str>,
page: u32,
bounding: bool,
page_box: PageBox,
cmd: &str,
) -> Result<Source> {
read_source(name, bytes, Some(bytes), path, (page, bounding, page_box), cmd)
read_source(name, file.bytes, Some(file), path, (page, bounding, page_box), cmd)
}
/// [`source`], the blob keeping `shared` when given (else a copy of `bytes`); `shown` is the page
/// (or artboard), whether the art's bounds are shown, and the page's box.
/// [`source`], the blob keeping `shared`'s bytes when given (else a copy of `bytes`), its hash
/// used for the key (else `bytes` hashed) and its pages read from what was read of it; `shown` is
/// the page (or artboard), whether the art's bounds are shown, and the page's box.
fn read_source(
name: &str,
bytes: &[u8],
shared: Option<&Arc<Vec<u8>>>,
shared: Option<SharedFile<'_>>,
path: Option<&str>,
shown: (u32, bool, PageBox),
cmd: &str,
) -> Result<Source> {
let (page, bounding, page_box) = shown;
let hash = shared.map_or_else(|| hash_bytes(bytes), |f| f.hash.to_string());
let opened = shared.map(|f| f.opened);
let shared = shared.map(|f| f.bytes);
// The same document placed the same way is read once (placing it again is free).
let key = key_for(&hash_bytes(bytes), page, bounding, page_box);
let key = key_for(&hash, page, bounding, page_box);
// Resolving its links depends on where the file is and on what the caller may read, so a hit
// needs the same path, and nothing is shared with or from a confined (automation) caller.
let confined = crate::file_access::current().is_some();
@@ -269,7 +304,7 @@ fn read_source(
}
return Ok(s);
}
let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?;
let a = opened_document_art(name, (bytes, opened), path, page, bounding, page_box, cmd)?;
let bytes = shared.cloned().unwrap_or_else(|| Arc::new(bytes.to_vec()));
let mut blob = ImageBlob { mime: mime(name, &bytes).into(), bytes, proxy: None };
let shown = PlacedDocument {
@@ -284,10 +319,10 @@ fn read_source(
};
let art = group(a.nodes, a.clip);
blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new);
let color_mode = a.doc.color_mode;
let (color_mode, editing_data) = (a.doc.color_mode, a.editing_data);
// Drawing it needn't read it again.
vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame));
let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode };
let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings, color_mode, editing_data };
if !confined {
let mut m = lock_memo();
if m.len() >= MEMO_SIZE {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2008,6 +2008,38 @@ fn glyph_form_is_one_character_attribute_saved_only_when_set() {
assert!(runs(&s).iter().all(|r| r.style.glyph_form == GlyphForm::Default));
}
/// A glyph choice (`text.setGlyphAlternate`) would keep drawing its own glyph over a glyph form,
/// so setting a form other than "default" takes it away, in `text.setRangeStyle` and in
/// `text.setFormat`; "default" (Revert To Default Forms) and calls without `glyphForm` keep it.
#[test]
fn a_glyph_form_takes_away_the_characters_glyph_choice() {
use vectorcraft_doc::{GlyphChoice, GlyphForm};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "亜唖"})).unwrap()["id"].as_u64().unwrap();
let choice = GlyphChoice { ch: '唖', gid: 42, font: "Test-Regular".into(), version: "1".into() };
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).map(|n| &mut n.kind) {
vectorcraft_text::edit::style_range(&mut t.runs, 3, 6, |st| st.glyph_alternate = Some(choice.clone()));
}
let state = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.glyph_form, r.style.glyph_alternate.is_some())).collect::<Vec<_>>(),
_ => panic!("text"),
};
s.execute("text.setRangeStyle", &json!({"id": id, "size": 30})).unwrap();
assert_eq!(state(&s), [("亜".into(), GlyphForm::Default, false), ("唖".into(), GlyphForm::Default, true)], "no glyphForm: kept");
s.execute("text.setRangeStyle", &json!({"id": id, "glyphForm": "default"})).unwrap();
assert_eq!(state(&s)[1], ("唖".into(), GlyphForm::Default, true), "default: kept");
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "glyphForm": "jis78"})).unwrap();
assert_eq!(state(&s), [("亜".into(), GlyphForm::Default, false), ("唖".into(), GlyphForm::Jis78, false)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(state(&s)[1], ("唖".into(), GlyphForm::Default, true), "one undo step brings both back");
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"glyphForm": "default"})).unwrap();
assert!(state(&s).iter().any(|r| r.2), "default: kept");
s.execute("text.setFormat", &json!({"glyphForm": "traditional"})).unwrap();
assert!(state(&s).iter().all(|r| r.1 == GlyphForm::Traditional && !r.2), "{:?}", state(&s));
}
/// No Break is a character attribute, saved only when on.
#[test]
fn no_break_is_a_character_attribute_saved_only_when_on() {
+6
View File
@@ -399,6 +399,12 @@ impl ScriptScope {
Self { roots: std::fs::canonicalize(dir).ok().into_iter().collect() }
}
/// The scope of a script run from a file in the folder whose real path, with its links already
/// followed, is `real`: that folder, which is not looked up again.
pub(crate) fn of_real_folder(real: PathBuf) -> Self {
Self { roots: vec![real] }
}
/// Is `path` refused by its name alone: a `..` component, a NUL, a Windows device name or
/// stream, or a computer on the network?
fn refused_by_name(path: &Path) -> bool {
+6 -4
View File
@@ -295,12 +295,14 @@ fn display(path: &Path) -> Arc<str> {
impl Files {
/// The files of a run of the script at `path` (`None` for a script given as text), with the
/// `JSINCLUDE` folders `jsinclude`.
pub(crate) fn new(path: Option<&str>, jsinclude: Vec<PathBuf>) -> Self {
let real = path.and_then(|p| std::fs::canonicalize(p).ok());
/// `JSINCLUDE` folders `jsinclude`. `checked` is the script's real path when the read of its text
/// found it, which the run's folder then comes from; without it, `path` is looked up.
pub(crate) fn new(path: Option<&str>, checked: Option<PathBuf>, jsinclude: Vec<PathBuf>) -> Self {
let scope_of_checked = checked.as_deref().and_then(Path::parent).map(|d| ScriptScope::of_real_folder(d.to_path_buf()));
let real = checked.or_else(|| path.and_then(|p| std::fs::canonicalize(p).ok()));
let dir = real.as_deref().and_then(folder_of);
Self {
scope: dir.as_deref().map(ScriptScope::of_folder).unwrap_or_default(),
scope: scope_of_checked.or_else(|| dir.as_deref().map(ScriptScope::of_folder)).unwrap_or_default(),
jsinclude,
include_path: Vec::new(),
files: 0,
+4 -1
View File
@@ -163,6 +163,9 @@ pub(crate) struct Request {
pub name_given: bool,
/// The file the script was read from, which its included files are read beside.
pub file: Option<String>,
/// The real path of `file` as the read of `source` found it, which the included files are then
/// read beside instead of looking `file` up again.
pub checked_real: Option<std::path::PathBuf>,
pub source: String,
pub args: Map<String, Value>,
pub fuel: u64,
@@ -1814,7 +1817,7 @@ impl Session {
self.script_high_water.retain(|uid, _| open.contains(uid));
self.script_stop.clear();
self.script_report = None;
let mut files = include::Files::new(request.file.as_deref(), include::Files::jsinclude_env());
let mut files = include::Files::new(request.file.as_deref(), request.checked_real, include::Files::jsinclude_env());
let expanded = files.expand_script(&request.source);
let name = match &expanded {
Ok(include::Expanded { name: Some(name), .. }) if !request.name_given => name.clone(),
+94
View File
@@ -612,6 +612,100 @@ fn eraser_size_follows_the_pen_pressure() {
assert!(err.to_string().contains("sizeMode"), "{err}");
}
/// Eraser Tool Options' Angle, Roundness and Size variation menus: a value on Rotation, Bearing,
/// Tilt or Pressure takes what the pen read along the drag, Random stays within its Variation, a
/// drag without the reading erases at the value itself, an open path is cut where the varied tip
/// covers it, and an unknown mode or a variation out of its range is refused with nothing erased.
#[test]
fn the_eraser_tip_varies_with_its_modes() {
// Which of `probes` a level drag through the middle of a 400 by 200 rectangle erases, each of
// its points `[x, 100]` followed by `pen` ([pressure, tiltX, tiltY, rotation]).
let erased = |tip: serde_json::Value, pen: serde_json::Value, probes: &[(f64, f64)]| -> Vec<bool> {
let mut s = session();
rect(&mut s, 0.0, 0.0, 400.0, 200.0);
let points: Vec<_> = (0..=20)
.map(|i| {
let mut p = vec![json!(100.0 + f64::from(i) * 10.0), json!(100.0)];
p.extend(pen.as_array().cloned().unwrap_or_default());
serde_json::Value::Array(p)
})
.collect();
s.execute("path.eraseRegion", &with(&json!({"points": points, "size": 20}), tip.clone())).unwrap();
let filled = |x: f64, y: f64| {
paths(&s).iter().any(|(_, pd)| vectorcraft_geom::hit::fill_contains(&pd.to_bezpath(), FillRule::NonZero, Point::new(x, y)))
};
assert!(filled(200.0, 150.0), "{tip} {pen}: the rest of the rectangle stays");
probes.iter().map(|&(x, y)| !filled(x, y)).collect()
};
let none = json!(null);
// A 20 by 2 pt tip lying level erases a band 2 pt tall; standing upright it erases one 20 pt tall.
let level = json!({"roundness": 10});
let probes = [(200.0, 100.5), (200.0, 108.0)];
assert_eq!(erased(level.clone(), json!([]), &probes), [true, false], "Fixed");
let on_rotation = with(&level, json!({"angleMode": "rotation", "angleVariation": 90}));
// The barrel turned half way round reads the most, which turns the tip upright.
assert_eq!(erased(on_rotation.clone(), json!([none, none, none, 180]), &probes), [true, true], "Rotation");
assert_eq!(erased(on_rotation.clone(), json!([]), &probes), [true, false], "Rotation without a reading");
// Leaning to the left (a bearing of 180°) reads the most too.
let on_bearing = with(&level, json!({"angleMode": "bearing", "angleVariation": 90}));
assert_eq!(erased(on_bearing.clone(), json!([none, -45, 0, none]), &probes), [true, true], "Bearing");
assert_eq!(erased(on_bearing, json!([none, 45, 0, none]), &probes), [true, false], "Bearing at 0°");
// A round tip: upright the pen gives 28 pt, nearly flat about 12 pt.
let on_tilt = json!({"sizeMode": "tilt", "sizeVariation": 8});
let wide = [(200.0, 105.0), (200.0, 113.0)];
assert_eq!(erased(on_tilt.clone(), json!([none, 0, 0, none]), &wide), [true, true], "Tilt upright");
assert_eq!(erased(on_tilt, json!([none, 89, 0, none]), &wide), [true, false], "Tilt flat");
// Roundness from 10 % less 90 (kept to 1 %) at the lightest pressure to 100 % at the heaviest.
let on_pressure = with(&level, json!({"roundnessMode": "pressure", "roundnessVariation": 90}));
assert_eq!(erased(on_pressure.clone(), json!([1.0]), &probes), [true, true], "Pressure at the heaviest");
// At the lightest the tip is 0.2 pt across, narrower than the 2 pt band Fixed erases.
assert_eq!(erased(on_pressure, json!([0.0]), &[(200.0, 100.0), (200.0, 100.5)]), [true, false], "Pressure at the lightest");
// Random: one size for the drag, from 16 to 24 pt.
let random = json!({"sizeMode": "random", "sizeVariation": 4});
assert_eq!(erased(random, json!([]), &[(200.0, 107.5), (200.0, 112.5)]), [true, false], "Random");
// A press on a line with the tip turned upright by its rotation cuts 5 pt of it, not 20.
let left_of_line = |pen: serde_json::Value| {
let mut s = session();
line(&mut s, 300.0, 150.0, 500.0, 150.0);
let mut point = vec![json!(400), json!(150)];
point.extend(pen.as_array().cloned().unwrap_or_default());
let tip = json!({"points": [point], "size": 20, "roundness": 25, "angleMode": "rotation", "angleVariation": 90});
s.execute("path.eraseRegion", &tip).unwrap();
paths(&s).iter().map(|p| p.1.length()).sum::<f64>()
};
let upright = left_of_line(json!([none, none, none, 180]));
assert!((upright - 195.0).abs() < 0.3, "the upright tip left {upright} pt of the line");
let level_tip = left_of_line(json!([]));
assert!((level_tip - 180.0).abs() < 0.3, "the level tip left {level_tip} pt of the line");
// A 1500 pt tip on Tilt erases 1500 pt across with a mouse, as it does on Fixed.
let mut s = session();
rect(&mut s, 0.0, 0.0, 4000.0, 4000.0);
let big = json!({"points": [[1000, 2000], [3000, 2000]], "size": 1500, "sizeMode": "tilt", "sizeVariation": 10});
s.execute("path.eraseRegion", &big).unwrap();
let filled =
|x: f64, y: f64| paths(&s).iter().any(|(_, pd)| vectorcraft_geom::hit::fill_contains(&pd.to_bezpath(), FillRule::NonZero, Point::new(x, y)));
assert_eq!((filled(2000.0, 2740.0), filled(2000.0, 2760.0)), (false, true), "a 1500 pt tip");
// A Size Variation kept while Size is Fixed leaves the size alone.
let kept = with(&on_rotation, json!({"sizeMode": "fixed", "sizeVariation": 1000000}));
assert_eq!(erased(kept, json!([]), &probes), [true, false], "Size on Fixed with a Variation");
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let stroke = json!({"points": [[-10, 50], [110, 50]]});
let modes = "\"fixed\", \"random\", \"pressure\", \"tilt\", \"bearing\" or \"rotation\"";
for (extra, want) in [
(json!({"angleMode": "wheel"}), format!("angleMode is one of {modes}, not `wheel`")),
(json!({"roundnessMode": 3}), format!("roundnessMode is one of {modes}, not 3")),
(json!({"sizeMode": "stylusWheel"}), format!("sizeMode is one of {modes}, not `stylusWheel`")),
(json!({"angleVariation": 181}), "angleVariation must be at most 180".to_string()),
(json!({"roundnessVariation": 101}), "roundnessVariation must be at most 100".to_string()),
(json!({"roundnessVariation": -1}), "roundnessVariation must be 0 or more".to_string()),
] {
refuses_with(&mut s, "path.eraseRegion", &with(&stroke, extra), &want);
}
refuses_a_number_given_as_text(&mut s, "path.eraseRegion", &stroke, "angleVariation");
}
/// A Blob Brush stroke merges with the topmost matching path it touches and with the touched
/// matching paths next to it in stacking order in the same group or layer; matching paths in other
/// layers or groups, or below an object of another kind, are left alone.
+106
View File
@@ -492,6 +492,111 @@ fn options_set_tile_type_spacing_and_overlap() {
assert!(s.execute("pattern.options", &json!({"name": "Dots", "width": -1})).is_err());
}
/// Move Tile with Art: in pattern editing mode, a move of all of the tile art moves the tile by the
/// same offset, through `object.move`, `object.nudge` and `object.transform`. With the option off
/// (a new pattern's default), a move of part of the art, a rotation or a copy, the tile stays.
/// Undo puts the tile back, and Done keeps the moved tile.
#[test]
fn move_tile_with_art_moves_the_tile_when_all_of_the_art_moves() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 12.0, 0.0, 6.0, 6.0);
sel(&mut s, &[a, b]);
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
let tile = |s: &Session| doc(s).pattern("P").unwrap().tile;
let start = tile(&s);
let art: Vec<NodeId> = s.doc().unwrap().selection.objects.to_vec();
assert_eq!(art.len(), 2);
assert!(!doc(&s).pattern("P").unwrap().move_tile_with_art, "off for a new pattern");
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
assert_eq!(tile(&s), start, "with the option off the tile stays");
s.execute("object.move", &json!({"dx": -5, "dy": 0})).unwrap();
s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap();
assert_eq!(s.execute("pattern.list", &json!({})).unwrap()["patterns"][0]["moveTileWithArt"], true);
s.execute("object.move", &json!({"dx": 3, "dy": 4})).unwrap();
assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(3.0, 4.0));
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, -1, 2]})).unwrap();
assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(2.0, 6.0));
let k = s.prefs.keyboard_increment;
s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap();
assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(2.0 + k, 6.0));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(tile(&s), start + vectorcraft_geom::Vec2::new(3.0, 4.0), "undo puts the tile back");
let moved = tile(&s);
sel(&mut s, &art[..1]);
s.execute("object.move", &json!({"dx": 7, "dy": 0})).unwrap();
assert_eq!(tile(&s), moved, "a move of part of the art leaves the tile");
sel(&mut s, &art);
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
assert_eq!(tile(&s), moved, "a rotation leaves the tile");
s.execute("object.move", &json!({"dx": 1, "dy": 1, "copy": true})).unwrap();
assert_eq!(tile(&s), moved, "a copy leaves the tile");
s.execute("object.pattern.done", &json!({})).unwrap();
assert_eq!(tile(&s), moved, "Done keeps the moved tile");
assert!(doc(&s).pattern("P").unwrap().move_tile_with_art);
}
/// Move Tile with Art counts a group in the tile art as moved when each of its members moved, one
/// by one selected.
#[test]
fn move_tile_with_art_counts_a_group_whose_members_all_moved() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 12.0, 0.0, 6.0, 6.0);
sel(&mut s, &[a, b]);
s.execute("object.group", &json!({})).unwrap();
s.execute("object.pattern.make", &json!({"name": "G"})).unwrap();
s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap();
let start = doc(&s).pattern("G").unwrap().tile;
let layer = doc(&s).pattern_edit.as_ref().unwrap().layer;
let group = doc(&s).node(layer).unwrap().children().unwrap()[0].clone();
let members: Vec<NodeId> = group.children().unwrap().iter().map(|c| c.id).collect();
assert_eq!(members.len(), 2);
sel(&mut s, &members[..1]);
s.execute("object.move", &json!({"dx": 4, "dy": 0})).unwrap();
assert_eq!(doc(&s).pattern("G").unwrap().tile, start, "one member of two");
sel(&mut s, &members);
s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap();
assert_eq!(doc(&s).pattern("G").unwrap().tile, start + vectorcraft_geom::Vec2::new(0.0, 5.0), "every member");
}
/// Move Tile with Art moves the tile as far as the art moved: when the art is aligned to the pixel
/// grid and a move snaps it to the grid, the tile moves by the snapped distance.
#[test]
fn move_tile_with_art_follows_art_that_snaps_to_the_pixel_grid() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[a]);
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
s.execute("pattern.options", &json!({"moveTileWithArt": true})).unwrap();
let art = s.doc().unwrap().selection.objects[0];
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(art).unwrap().extras_mut().pixel_aligned = true;
let start = doc(&s).pattern("P").unwrap().tile;
let origin = |s: &Session| doc(s).node(art).unwrap().geometric_bounds().unwrap().origin();
let before = origin(&s);
s.execute("object.move", &json!({"dx": 0.3, "dy": 0})).unwrap();
let moved = origin(&s) - before;
assert!((moved.x - 0.3).abs() > 1e-6, "the art snapped to the grid: {moved:?}");
assert_eq!(doc(&s).pattern("P").unwrap().tile, start + moved, "the tile moved as far");
}
/// Pattern Options that change nothing record no undo step.
#[test]
fn options_that_change_nothing_record_no_undo_step() {
let mut s = session();
make_dots(&mut s);
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
let before = steps(&s);
s.execute("pattern.options", &json!({"name": "Dots", "moveTileWithArt": false, "copies": 5})).unwrap();
assert_eq!(steps(&s), before);
s.execute("pattern.options", &json!({"name": "Dots", "moveTileWithArt": true})).unwrap();
assert_eq!(steps(&s), before + 1);
}
#[test]
fn options_rename_updates_swatch_and_paints() {
let mut s = session();
@@ -1625,6 +1730,7 @@ fn pattern_commands_refuse_a_param_of_the_wrong_type() {
("pattern.options", options(json!({"copies": "5"})), number("copies", json!("5"))),
("pattern.options", options(json!({"dimCopies": "50"})), number("dimCopies", json!("50"))),
("pattern.options", options(json!({"sizeTileToArt": "true"})), switch("sizeTileToArt")),
("pattern.options", options(json!({"moveTileWithArt": "yes"})), switch("moveTileWithArt")),
("pattern.options", options(json!({"showTileEdge": 1})), switch("showTileEdge")),
("pattern.options", options(json!({"showSwatchBounds": "no"})), switch("showSwatchBounds")),
("pattern.options", options(json!({"overlap": "right"})), "`overlap` must be an object, not \"right\"".into()),
@@ -666,6 +666,26 @@ fn linked_pages_share_one_copy_of_the_file_and_open_in_the_pages_color_mode() {
assert_eq!(s.doc().unwrap().doc.color_mode, vectorcraft_doc::ColorMode::Rgb, "colorMode wins");
}
#[test]
fn linked_pages_read_the_file_once_and_a_blank_page_keeps_its_crop_box() {
use vectorcraft_testkit::pdf::{PdfPage, pdf};
let dir = Folder::new("open-pdf-link-pages-blank");
let src = dir.file("blank.pdf");
let art = || PdfPage::new(100.0, 50.0, "1 0 0 rg 10 10 20 20 re f");
write(&src, &pdf(&[art(), PdfPage::new(80.0, 40.0, ""), art()], None));
let mut s = Session::new();
s.execute("document.open", &json!({"path": src, "linkPages": true, "cropTo": "bounding"})).unwrap();
let d = &s.doc().unwrap().doc;
let boards: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
assert_eq!(boards.len(), 3, "{boards:?}");
assert_eq!((boards[0].width(), boards[0].height()), (20.0, 20.0), "the art's bounds");
assert_eq!((boards[1].width(), boards[1].height()), (80.0, 40.0), "the blank page's crop box");
// The pages are read from the one copy every page's blob keeps: a page that read the file
// again would count here.
let blob = d.images.values().next().map(|b| b.bytes.clone()).unwrap();
assert_eq!(vectorcraft_pdf::reads_of(&blob), 0, "the pages are read from the file read once");
}
#[test]
fn link_pages_is_refused_without_a_path_for_other_files_and_with_a_password_or_outlines() {
let dir = Folder::new("open-pdf-link-pages-refused");
+65
View File
@@ -300,6 +300,71 @@ fn an_installed_script_is_read_as_script_run_reads_a_file() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_installed_run_runs_the_text_its_own_read_checked() {
let dir = folder("installed-run");
let root = dir.join("Scripts");
let script = touch(&root, "draw.jsx");
std::fs::write(&script, "#include \"lib.jsxinc\"\nvc.log(x);").unwrap();
std::fs::write(root.join("lib.jsxinc"), "var x = 1;").unwrap();
// A folder in the Scripts folder that is a link: script.run by path alone follows it.
let elsewhere = dir.join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
std::fs::write(elsewhere.join("other.jsx"), "vc.log(2);").unwrap();
link_folder(&elsewhere, &root.join("linked"));
let linked = root.join("linked").join("other.jsx").to_string_lossy().into_owned();
let mut s = Session::new();
s.attach_scripts_dir(root.clone());
let path = script.to_string_lossy().into_owned();
let r = s.execute("script.run", &json!({"path": path, "installed": true})).unwrap();
assert_eq!(r["log"], json!([1]));
assert_eq!(s.execute("script.run", &json!({"path": linked})).unwrap()["log"], json!([2]));
// With `installed`, the run's own read is the check: a script behind a link is not run.
let e = s.execute("script.run", &json!({"path": linked, "installed": true})).unwrap_err();
assert_eq!(e.to_string(), cmd::scripts::NOT_LISTED);
let outside = touch(&dir, "outside.js");
let e = s.execute("script.run", &json!({"path": outside.to_string_lossy(), "installed": true})).unwrap_err();
assert_eq!(e.to_string(), cmd::scripts::NOT_LISTED);
// `installed` is true or false, and takes a path.
assert!(
s.execute("script.run", &json!({"path": path, "installed": "yes"})).unwrap_err().to_string().contains("`installed` must be true or false")
);
assert!(s.execute("script.run", &json!({"source": "1", "installed": true})).unwrap_err().to_string().contains("`installed` takes a `path`"));
assert_eq!(s.execute("script.run", &json!({"source": "vc.log(3);", "installed": false})).unwrap()["log"], json!([3]));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_installed_run_reads_its_includes_in_the_folder_its_read_found() {
let dir = folder("installed-scope");
let checked = dir.join("Scripts").join("tools");
let swapped = dir.join("swapped");
for (folder, x) in [(&checked, 1), (&swapped, 2)] {
std::fs::create_dir_all(folder).unwrap();
std::fs::write(folder.join("draw.jsx"), "").unwrap();
std::fs::write(folder.join("lib.jsxinc"), format!("var x = {x};")).unwrap();
}
// The path now leads through a link to another folder, as after a link was put in place of the
// folder once the script's text was read.
let link = dir.join("link");
link_folder(&swapped, &link);
let mut s = Session::new();
let request = script::Request {
name: "draw.jsx".into(),
name_given: false,
file: Some(link.join("draw.jsx").to_string_lossy().into_owned()),
checked_real: Some(std::fs::canonicalize(checked.join("draw.jsx")).unwrap()),
source: "#include \"lib.jsxinc\"\nvc.log(x);".into(),
args: Default::default(),
fuel: script::DEFAULT_FUEL,
locale: "en_US".into(),
answers: Vec::new(),
};
s.script_start(request).unwrap();
assert_eq!(s.script_run_to_end().unwrap()["log"], json!([1]));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() {
let dir = folder("roots");
+6 -3
View File
@@ -2,8 +2,9 @@
//!
//! The editing copy of a file keeps the characters of its text objects, with their fonts, sizes,
//! colours and where they sit, in one text document of its own: between `%AI11_BeginTextDocument`
//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object
//! in a layer is only a stub naming its story in it (`/StoryIndex`).
//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys (names in files
//! saved for version 11, and for keys newer than the version a file is saved for: see [`names`]).
//! A text object in a layer is only a stub naming its story in it (`/StoryIndex`).
//!
//! The format isn't documented. Most of what is read here was worked out from the files of the
//! project's users, used locally and never committed, by comparing it with the type their pages
@@ -32,6 +33,8 @@ use vectorcraft_doc::{
};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
mod names;
/// Longest story read (bytes).
pub(crate) const MAX_STORY: usize = 1 << 20;
/// Biggest text document read (bytes, decoded).
@@ -631,7 +634,7 @@ impl Texts {
if bytes.len() > MAX_BYTES {
return None;
}
let doc = read(&bytes)?;
let doc = names::numbered(read(&bytes)?);
let fonts = doc
.get("0")?
.get("1")?
+355
View File
@@ -0,0 +1,355 @@
//! The keys of a text document written by name.
//!
//! Files saved for version 11 write every key of the text document by its name
//! (`/DocumentResources << /FontSet << /Resources [ ...`) and a typed node's kind under
//! `/StreamTag`, with the long names of the layout's strikes (`/FrameStrike`, `/LineStrike`). Later
//! versions write each key as its number (`/0 << /1 << /0 [ ...`, the kind under `/99`, `/F`,
//! `/L`), except the keys that are newer than the version the file is saved for, which they still
//! write by name (`/ParagraphDirection`, `/VersionString`). A key's number depends on the
//! dictionary it is in (`Leading` is 5 in a character style and 11 in a line), so the names are
//! read with the dictionary each one sits in, from the root down.
//!
//! A public reader of the format lists the names and numbers of the document's resources and
//! objects, the character and paragraph features, a story's model and view, a frame's data, the
//! line and glyph strikes, a font record's name, type and axes, and a color's type and values:
//! <https://github.com/Zignixx/firepixel/blob/60e3d5517df3106674b3208eb71e65471b4daec4/crates/fp-fwpng/src/ate_keys.rs>.
//! The other names here (the sets' lists, a resource and its kind, a font record's version, the
//! styles' name, parent and features, a Kinsoku set's name and data, a frame's point, curve, first
//! baseline and path options, the runs and their kerning, a strike's kind and origin, the tab stops
//! and the paints) are VectorCraft's own reading of the format, matched to the numbers the reader
//! already uses.
//!
//! Only the keys the text document's reader looks at, and the dictionaries on the way to them, are
//! given numbers; any other key keeps its name and is skipped as before. A key that the dictionary
//! also has as a number keeps the number's value.
use std::collections::BTreeMap;
use super::Val;
/// The kind of a dictionary of the text document, which gives its keys' numbers.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
Root,
Resources,
Objects,
FontSet,
FontEntry,
Font,
FontRecord,
KinsokuSet,
KinsokuEntry,
Kinsoku,
CharSheetSet,
CharSheetEntry,
CharSheet,
ParaSheetSet,
ParaSheetEntry,
ParaSheet,
FrameSet,
FrameEntry,
Frame,
Bezier,
FrameData,
FirstBaseline,
PathOptions,
TextObject,
Model,
ParaRuns,
ParaRun,
ParaRunData,
CharRuns,
CharRun,
CharRunData,
KernRuns,
KernRun,
KernRunData,
View,
FrameRef,
Strike,
Transform,
CharFeatures,
ParaFeatures,
TabStops,
TabStop,
Paint,
Color,
/// A dictionary none of whose keys are read: its keys stay as they are.
Other,
}
/// A key of a dictionary: its name, its number, and the kind of the dictionaries its value holds
/// (the value itself, or the elements of a list).
type Key = (&'static str, &'static str, Kind);
/// The keys of a set of resources (`FontSet`, `StyleSheetSet`, ...), whose list holds `entry`.
const fn set(entry: Kind) -> [Key; 2] {
[("Resources", "0", entry), ("DisplayList", "1", Kind::Other)]
}
const FONT_SET: [Key; 2] = set(Kind::FontEntry);
const KINSOKU_SET: [Key; 2] = set(Kind::KinsokuEntry);
const CHAR_SHEET_SET: [Key; 2] = set(Kind::CharSheetEntry);
const PARA_SHEET_SET: [Key; 2] = set(Kind::ParaSheetEntry);
const FRAME_SET: [Key; 2] = set(Kind::FrameEntry);
/// The character features the reader looks at.
const CHAR_FEATURES: [Key; 20] = [
("Font", "0", Kind::Other),
("FontSize", "1", Kind::Other),
("AutoLeading", "4", Kind::Other),
("Leading", "5", Kind::Other),
("HorizontalScale", "6", Kind::Other),
("VerticalScale", "7", Kind::Other),
("Tracking", "8", Kind::Other),
("BaselineShift", "9", Kind::Other),
("FontCaps", "12", Kind::Other),
("BaselineDirection", "35", Kind::Other),
("LeftAki", "49", Kind::Other),
("RightAki", "50", Kind::Other),
("FillColor", "53", Kind::Paint),
("StrokeColor", "54", Kind::Paint),
("FillFlag", "56", Kind::Other),
("StrokeFlag", "57", Kind::Other),
("LineCap", "61", Kind::Other),
("LineJoin", "62", Kind::Other),
("LineWidth", "63", Kind::Other),
("MiterLimit", "64", Kind::Other),
];
/// The paragraph features the reader looks at.
const PARA_FEATURES: [Key; 22] = [
("Justification", "0", Kind::Other),
("FirstLineIndent", "1", Kind::Other),
("StartIndent", "2", Kind::Other),
("EndIndent", "3", Kind::Other),
("SpaceBefore", "4", Kind::Other),
("SpaceAfter", "5", Kind::Other),
("AutoLeading", "7", Kind::Other),
("AutoHyphenate", "9", Kind::Other),
("HyphenatedWordSize", "10", Kind::Other),
("PreHyphen", "11", Kind::Other),
("PostHyphen", "12", Kind::Other),
("ConsecutiveHyphens", "13", Kind::Other),
("Zone", "14", Kind::Other),
("HyphenateCapitalized", "15", Kind::Other),
("KeepTogether", "23", Kind::Other),
("BurasagariType", "24", Kind::Other),
("KinsokuOrder", "25", Kind::Other),
("Kinsoku", "27", Kind::Other),
("TabStops", "30", Kind::TabStops),
("DefaultTabWidth", "31", Kind::Other),
("DefaultStyle", "32", Kind::Other),
("ParagraphDirection", "33", Kind::Other),
];
/// The keys of a strike of the layout. A glyph strike has no children; its own keys start at 5. Its
/// glyph data (key 21) has no name here: the public list above does not name it, and the files
/// checked do not write it by name.
const STRIKE: [Key; 5] = [
("StreamTag", "99", Kind::Other),
("Transform", "0", Kind::Transform),
("Bounds", "1", Kind::Other),
("ChildProcession", "5", Kind::Other),
("Children", "6", Kind::Strike),
];
const GLYPH_STRIKE: [Key; 3] = [("StreamTag", "99", Kind::Other), ("Transform", "0", Kind::Transform), ("Bounds", "1", Kind::Other)];
/// The long names of the strikes' kinds, with the short ones later versions write.
const STRIKE_KINDS: [(&str, &str); 6] =
[("PathSelectGroupCharacter", "PC"), ("FrameStrike", "F"), ("RowColStrike", "R"), ("LineStrike", "L"), ("Segment", "S"), ("GlyphStrike", "G")];
impl Kind {
/// The keys of a dictionary of this kind; `tag` is the kind of typed node it is, for a strike.
fn keys(self, tag: Option<&str>) -> &'static [Key] {
use Kind as K;
match self {
K::Root => &[("DocumentResources", "0", K::Resources), ("DocumentObjects", "1", K::Objects)],
K::Resources => &[
("FontSet", "1", K::FontSet),
("KinsokuSet", "4", K::KinsokuSet),
("StyleSheetSet", "5", K::CharSheetSet),
("ParagraphSheetSet", "6", K::ParaSheetSet),
("TextFrameSet", "8", K::FrameSet),
],
K::Objects => &[
("DocumentSettings", "0", K::Other),
("TextObjects", "1", K::TextObject),
("OriginalNormalStyleFeatures", "2", K::CharFeatures),
("OriginalNormalParagraphFeatures", "3", K::ParaFeatures),
],
K::FontSet => &FONT_SET,
K::FontEntry => &[("Resource", "0", K::Font)],
K::Font => &[("StreamTag", "99", K::Other), ("Identifier", "0", K::FontRecord)],
K::FontRecord => &[("Name", "0", K::Other), ("Type", "2", K::Other), ("MMAxis", "4", K::Other), ("VersionString", "5", K::Other)],
K::KinsokuSet => &KINSOKU_SET,
K::KinsokuEntry => &[("Resource", "0", K::Kinsoku)],
K::Kinsoku => &[("Name", "0", K::Other), ("Data", "5", K::Other)],
K::CharSheetSet => &CHAR_SHEET_SET,
K::CharSheetEntry => &[("Resource", "0", K::CharSheet)],
K::CharSheet => &[("Name", "0", K::Other), ("Parent", "5", K::Other), ("Features", "6", K::CharFeatures)],
K::ParaSheetSet => &PARA_SHEET_SET,
K::ParaSheetEntry => &[("Resource", "0", K::ParaSheet)],
K::ParaSheet => &[("Name", "0", K::Other), ("Features", "5", K::ParaFeatures), ("Parent", "6", K::Other)],
K::FrameSet => &FRAME_SET,
K::FrameEntry => &[("Resource", "0", K::Frame)],
K::Frame => &[("Point", "0", K::Other), ("Bezier", "1", K::Bezier), ("Data", "2", K::FrameData)],
K::Bezier => &[("Points", "0", K::Other)],
K::FrameData => &[
("Type", "0", K::Other),
("LineOrientation", "1", K::Other),
("FrameMatrix", "2", K::Other),
("RowCount", "3", K::Other),
("ColumnCount", "4", K::Other),
("TextOnPathTRange", "6", K::Other),
("RowGutter", "7", K::Other),
("ColumnGutter", "8", K::Other),
("FirstBaselineAlignment", "10", K::FirstBaseline),
("PathData", "11", K::PathOptions),
],
K::FirstBaseline => &[("Flag", "0", K::Other), ("Min", "1", K::Other)],
K::PathOptions => &[("Flip", "0", K::Other), ("Effect", "1", K::Other), ("Alignment", "2", K::Other), ("CS3Behavior", "6", K::Other)],
K::TextObject => &[("Model", "0", K::Model), ("View", "1", K::View)],
K::Model => &[("Text", "0", K::Other), ("ParagraphRun", "5", K::ParaRuns), ("StyleRun", "6", K::CharRuns), ("KernRun", "8", K::KernRuns)],
K::ParaRuns => &[("RunArray", "0", K::ParaRun)],
K::ParaRun => &[("RunData", "0", K::ParaRunData), ("Length", "1", K::Other)],
K::ParaRunData => &[("ParagraphSheet", "0", K::ParaSheet)],
K::CharRuns => &[("RunArray", "0", K::CharRun)],
K::CharRun => &[("RunData", "0", K::CharRunData), ("Length", "1", K::Other)],
K::CharRunData => &[("StyleSheet", "0", K::CharSheet)],
K::KernRuns => &[("RunArray", "0", K::KernRun)],
K::KernRun => &[("RunData", "0", K::KernRunData), ("Length", "1", K::Other)],
K::KernRunData => &[("ManualKern", "0", K::Other)],
K::View => &[("Frames", "0", K::FrameRef), ("Strikes", "2", K::Strike)],
K::FrameRef => &[("Resource", "0", K::Other)],
K::Strike if matches!(tag, Some("GlyphStrike" | "G")) => &GLYPH_STRIKE,
K::Strike => &STRIKE,
K::Transform => &[("Origin", "0", K::Other)],
K::CharFeatures => &CHAR_FEATURES,
K::ParaFeatures => &PARA_FEATURES,
K::TabStops => &[("TabStops", "0", K::TabStop)],
K::TabStop => &[("TabAdvance", "0", K::Other), ("TabType", "1", K::Other)],
K::Paint => &[("StreamTag", "99", K::Other), ("Color", "0", K::Color), ("CAIKnownStyleID", "5", K::Other)],
K::Color => &[("Type", "0", K::Other), ("Values", "1", K::Other)],
K::Other => &[],
}
}
}
/// The text document `doc` (its top-level keys) with the keys it writes by name given their
/// numbers, as later versions write them.
pub(super) fn numbered(doc: Val) -> Val {
value(doc, Kind::Root)
}
/// `v`, whose dictionaries are of kind `kind`, with its keys numbered.
fn value(v: Val, kind: Kind) -> Val {
match v {
Val::Dict(d) if kind != Kind::Other => Val::Dict(dict(d, kind)),
Val::List(l) if kind != Kind::Other => Val::List(l.into_iter().map(|x| value(x, kind)).collect()),
v => v,
}
}
/// A run's style written by name as the number `n` of a paragraph style (`kind` `ParaSheet`) or a
/// character style (`CharSheet`) of the document: a style with nothing of its own, based on that
/// one.
fn based_on(kind: Kind, n: f64) -> Option<Val> {
let parent = match kind {
Kind::ParaSheet => "6",
Kind::CharSheet => "5",
_ => return None,
};
Some(Val::Dict(BTreeMap::from([(parent.to_string(), Val::Num(n))])))
}
/// The entries of a dictionary of kind `kind` with their keys numbered.
fn dict(d: BTreeMap<String, Val>, kind: Kind) -> BTreeMap<String, Val> {
let tag = match d.get("StreamTag").or_else(|| d.get("99")) {
Some(Val::Name(n)) => Some(n.clone()),
_ => None,
};
let keys = kind.keys(tag.as_deref());
let mut out = BTreeMap::new();
let mut named = vec![];
for (k, v) in d {
match keys.iter().find(|(name, number, _)| *name == k || *number == k) {
Some((name, number, child)) => {
let v = match (*number, v) {
("99", Val::Name(n)) => Val::Name(STRIKE_KINDS.iter().find(|(long, _)| *long == n).map_or(n, |(_, short)| short.to_string())),
(_, Val::Num(n)) if k == *name => based_on(*child, n).unwrap_or(Val::Num(n)),
(_, v) => value(v, *child),
};
if k == *name {
named.push((number.to_string(), v));
} else {
out.insert(k, v);
}
}
None => {
out.insert(k, v);
}
}
}
for (k, v) in named {
out.entry(k).or_insert(v);
}
out
}
#[cfg(test)]
mod tests {
use super::super::read;
use super::*;
fn parse(s: &str) -> Val {
read(s.as_bytes()).unwrap_or_else(|| panic!("{s}"))
}
#[test]
fn named_keys_take_the_numbers_of_the_dictionary_they_are_in() {
// `Leading` is 5 in a character style and 11 in a line; `Name` is 0 in a style and in a font
// record.
let named = parse(
"/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Serif) \
/VersionString (Version 2) >> >> >> ] >> /StyleSheetSet << /Resources [ << /Resource << /Name (Red) /Parent 0 \
/Features << /Leading 18.0 /FillColor << /StreamTag /SimplePaint /Color << /Type 1 /Values [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> >> \
/DocumentObjects << /TextObjects [ << /Model << /Text (Hi) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet 1 >> /Length 2 >> ] >> \
/StyleRun << /RunArray [ << /RunData << /StyleSheet << /Parent 0 /Features << /FontSize 9.0 >> >> >> /Length 2 >> ] >> >> \
/View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /FrameStrike /Transform << /Origin [ 1.0 2.0 ] >> \
/Children [ << /StreamTag /LineStrike /Leading 14.4 /Children [ << /StreamTag /GlyphStrike /Glyphs [ 1 2 ] >> ] >> ] >> ] >> >> ] >>",
);
let numbered_form = parse(
"/0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 (Serif) /5 (Version 2) >> >> >> ] >> /5 << /0 [ << /0 << /0 (Red) /5 0 \
/6 << /5 18.0 /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> >> \
/1 << /1 [ << /0 << /0 (Hi) /5 << /0 [ << /0 << /0 << /6 1 >> >> /1 2 >> ] >> \
/6 << /0 [ << /0 << /0 << /5 0 /6 << /1 9.0 >> >> >> /1 2 >> ] >> >> \
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /F /0 << /0 [ 1.0 2.0 ] >> \
/6 [ << /99 /L /Leading 14.4 /6 [ << /99 /G /Glyphs [ 1 2 ] >> ] >> ] >> ] >> >> ] >>",
);
// A character style's `Leading` is its key 5; a line's and a glyph's keys the reader doesn't
// look at keep their names.
assert_eq!(numbered(named), numbered_form);
}
#[test]
fn a_number_wins_over_the_same_key_by_name() {
let doc = numbered(parse("/1 << /3 << /33 1 /ParagraphDirection 0 /Justification 2 >> >>"));
assert_eq!(doc, parse("/1 << /3 << /33 1 /0 2 >> >>"));
}
#[test]
fn keys_of_dictionaries_no_reader_looks_at_keep_their_names() {
let doc = numbered(parse("/DocumentObjects << /DocumentSettings << /Name (x) /Leading 3 >> /Elsewhere << /Name (y) >> >>"));
assert_eq!(doc, parse("/1 << /0 << /Name (x) /Leading 3 >> /Elsewhere << /Name (y) >> >>"));
}
#[test]
fn a_numbered_document_stays_as_it_is() {
let s = "/0 << /1 << /0 [ << /0 << /99 /CoolTypeFont /0 << /0 (Serif) >> >> >> ] >> >> \
/1 << /1 [ << /0 << /0 (Hi) /5 << /0 [ << /0 << /0 1 >> /1 2 >> ] >> /6 << /0 [ << /0 << /0 0 >> /1 2 >> ] >> >> \
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 1.0 2.0 ] >> >> ] >> ] >> >> ] /2 << /1 12.0 >> >>";
// A run's style written as a number under its key's number stays a number.
assert_eq!(numbered(parse(s)), parse(s));
}
}
+100
View File
@@ -3169,6 +3169,29 @@ fn text_document(stories: &[Story<'_>]) -> String {
)
}
/// [`text_document`] with its keys written by name, as files saved for version 11 write them: a
/// typed node's kind under `/StreamTag`, the strikes' kinds by their long names.
fn named_text_document(stories: &[Story<'_>]) -> String {
let story = |(text, align, anchor, _t, start): &Story<'_>| {
let n = text.chars().count();
format!(
"<< /Model << /Text ({text}) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet << /Name () /Features << /Justification {align} >> /Parent 0 >> >> /Length {n} >> ] >> \
/StyleRun << /RunArray [ << /RunData << /StyleSheet << /Name () /Parent 0 /Features << /Font 0 /FontSize 14.0 /FillColor << /StreamTag /CAITextPaint /Color << /Type 2 /Values [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /Length {n} >> ] >> >> \
/View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /PathSelectGroupCharacter /Children [ << /StreamTag /FrameStrike /Transform << /Origin [ {} {} ] >> \
/Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /LineStrike /Children [ \
<< /StreamTag /Segment /Transform << /Origin [ {start} 0.0 ] >> /Mapping << /CharacterCount {n} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>",
anchor.0, anchor.1
)
};
let frame = |(_, _, _, t, _): &Story<'_>| format!("<< /Resource << /Point [ 0 0 ] /Data << /FrameMatrix [ 1 0 0 1 {} {} ] >> >> >>", t.0, t.1);
format!(
"/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Helvetica) >> >> >> ] >> \
/TextFrameSet << /Resources [ {} ] >> >>\n/DocumentObjects << /TextObjects [ {} ] /OriginalNormalStyleFeatures << /FontSize 12.0 >> >>\n",
stories.iter().map(frame).collect::<Vec<_>>().join(" "),
stories.iter().map(story).collect::<Vec<_>>().join(" ")
)
}
/// The editing copy of a 100 × 100 file with `layers` and the text `document`; its template box is
/// centred at (50, 50).
fn native_with_text(layers: &str, document: &str) -> String {
@@ -3319,6 +3342,8 @@ fn type_from_the_text_document_takes_the_version_its_font_record_keeps() {
// A record whose name differs from the copies' in punctuation alone still picks its version.
assert_eq!(version("/0 (Vertest-Ink-Regular) /5 (1.1)"), "1.1");
assert_eq!(version("/0 (Vertest-Ink-Regular)"), "1.0");
// Files saved for versions older than the key write it by name among the numbered ones.
assert_eq!(version("/0 (VertestInk-Regular) /VersionString (1.1)"), "1.1");
}
/// A story with no runs, and a run or paragraph that names no style, is set in `[Normal Character
@@ -3728,6 +3753,81 @@ fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document
assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings);
}
#[test]
fn a_text_document_with_named_keys_gives_type_off_the_page_too() {
// Files saved for version 11 write the text document's keys by name. Story 1 is off the page:
// only the text document has it, and story 0 stays in its own text object.
let stories = [("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Away\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)];
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &named_text_document(&stories));
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
let r = import(&eps(page, &art)).unwrap();
let texts = text_of(&r.document.layers[0]);
assert_eq!(texts.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Hi there", "Away"], "{:?}", r.warnings);
let x = |t: &vectorcraft_doc::TextObject| t.xf.as_coeffs()[4];
assert!((x(texts[0]) - 58.5).abs() < 1e-6 && (x(texts[1]) - 158.5).abs() < 1e-6, "{:?} {:?}", texts[0].xf, texts[1].xf);
}
#[test]
fn a_text_document_with_named_keys_reads_as_the_same_one_with_numbers() {
// Type on a hidden layer comes only from the text document: its styles, paints, frames and
// layout read the same whichever way its keys are written.
let stories = [("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Mid\r", 2, (8191.5, 8191.5), (0.0, 0.0), -10.0)];
let spare = |doc: &str| {
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts: Vec<vectorcraft_doc::TextObject> = text_of(&r.document.layers[1]).into_iter().cloned().collect();
(texts, r.warnings)
};
let (numbered, _) = spare(&text_document(&stories));
let (named, warnings) = spare(&named_text_document(&stories));
assert_eq!(numbered.len(), 2);
assert_eq!(named, numbered, "{warnings:?}");
assert_eq!(named[1].para.justify, vectorcraft_doc::Justify::Center);
let magenta = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0));
assert_eq!(named[0].runs[0].style.fill, magenta);
}
#[test]
fn a_text_document_with_named_keys_takes_a_runs_style_by_its_number() {
// Files saved for version 11 name a run's paragraph and character styles by their numbers in the
// document's style sets, and give point type a curve of four equal points. Styles 1 center the
// paragraph and paint the type magenta; paragraph style 2 sets its direction right to left.
let magenta = "<< /StreamTag /CAITextPaint /Color << /Type 2 /Values [ 1.0 0.0 1.0 0.0 0.0 ] >> >>";
let story = |text: &str, para: u8, chars: u8, x: f64| {
let n = text.chars().count();
format!(
"<< /Model << /Text ({text}) /ParagraphRun << /RunArray [ << /RunData << /ParagraphSheet {para} >> /Length {n} >> ] >> \
/StyleRun << /RunArray [ << /RunData << /StyleSheet {chars} >> /Length {n} >> ] >> >> \
/View << /Frames [ << /Resource 0 >> ] /Strikes [ << /StreamTag /PathSelectGroupCharacter /Children [ << /StreamTag /FrameStrike \
/Transform << /Origin [ {x} 8180.0 ] >> /Children [ << /StreamTag /RowColStrike /Children [ << /StreamTag /RowColStrike /Children [ \
<< /StreamTag /LineStrike /Children [ << /StreamTag /Segment /Transform << /Origin [ 0.0 0.0 ] >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>"
)
};
let doc = format!(
"/DocumentResources << /FontSet << /Resources [ << /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (Helvetica) >> >> >> ] >> \
/StyleSheetSet << /Resources [ << /Resource << /Name (Normal) /Features << /Font 0 /FontSize 14.0 >> >> >> \
<< /Resource << /Name (Magenta) /Parent 0 /Features << /FillColor {magenta} >> >> >> ] >> \
/ParagraphSheetSet << /Resources [ << /Resource << /Name (Normal) /Features << /Justification 0 >> >> >> \
<< /Resource << /Name (Centered) /Parent 0 /Features << /Justification 2 >> >> >> \
<< /Resource << /Name (Leftward) /Parent 0 /Features << /ParagraphDirection 1 >> >> >> ] >> \
/TextFrameSet << /Resources [ << /Resource << /Bezier << /Points [ 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 8191.5 ] >> \
/Data << /Type 0 /FrameMatrix [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\
/DocumentObjects << /TextObjects [ {} {} ] /OriginalNormalStyleFeatures << /FontSize 12.0 >> >>\n",
story("Mid\r", 1, 1, 8200.0),
story("Back\r", 2, 0, 8300.0)
);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts = text_of(&r.document.layers[1]);
assert_eq!(texts.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Mid", "Back"], "{:?}", r.warnings);
assert_eq!(texts[0].para.justify, vectorcraft_doc::Justify::Center);
let magenta = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0));
assert_eq!(texts[0].runs[0].style.fill, magenta);
assert_eq!(texts[1].para.direction, Some(vectorcraft_doc::ParaDirection::RightToLeft));
assert_eq!(texts[1].para.justify, vectorcraft_doc::Justify::Right);
assert_ne!(texts[1].runs[0].style.fill, magenta);
}
#[test]
fn a_text_object_written_as_comments_is_read_like_any_other() {
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
+1 -1
View File
@@ -1737,7 +1737,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.runInstalledScript",
"Run Script",
"",
"{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (the script is checked again, then script.run runs it by its path, so its #include lines and $.evalFile read beside it), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again",
"{path: a script's path as script.listInstalled gives it} run a script of the Scripts folder, as its File › Scripts item does (script.run with installed reads the file once, checks it again and runs that text, so its #include lines and $.evalFile read in the folder that read found), without the warning Other Script… shows; a file that is no longer a .js or .jsx file in the folder or a subfolder at most 4 levels below it, or that is or lies behind a link or is hidden, is not run, and the menu's listing is read again",
),
(
"ui.checkScript",
+6 -3
View File
@@ -139,6 +139,7 @@ impl Exporter<'_> {
let doc = self.doc;
let out = self.out.clone();
let profiles = !out.standard.cmyk_only();
let to_srgb = out.image_srgb_intent().map(|intent| crate::images::ToSrgb { intent, sixteen_bit: self.sixteen_bit });
let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| {
let cmyk_image = blob.is_cmyk();
if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk {
@@ -148,12 +149,14 @@ impl Exporter<'_> {
let icc = out.cmyk_image_icc(blob);
crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None, icc.as_deref())
} else {
crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles)
crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles, to_srgb)
};
if let Some(w) = r.warning {
let img = r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles));
// An image left out gets the warning below instead.
for w in [r.warning, r.note].into_iter().flatten().filter(|_| img.is_some()) {
self.warn(w);
}
r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit, profiles))
img
});
if img.is_none() {
self.warn(UNDECODED);
+179 -19
View File
@@ -6,6 +6,13 @@
//! monochrome (greyscale that is only black and white). An image already as asked (not resampled,
//! a JPEG kept as JPEG or another kept lossless) is embedded as it is; the others are decoded,
//! resampled and encoded again. JPEG needs opaque pixels: images with transparency stay lossless.
//! A gray or RGB image that carries a profile the Output settings convert from ([`ToSrgb`]) is
//! always decoded: its pixels are converted to sRGB and written without the profile, at 16 bits a
//! sample when it has them and is otherwise kept as it is; a JPEG kept as JPEG whose samples the
//! conversion leaves unchanged (an sRGB profile) keeps its JPEG data. A black-and-white image whose
//! converted levels are grey is compressed as greyscale. A colour image the Output settings also
//! convert to their destination ([`Pixels`]) goes from its profile to sRGB, then to the
//! destination; a grey one keeps its profile there, as grey pixels are not converted.
//!
//! CMYK images ([`ImageBlob::cmyk`]) stay CMYK: a CMYK JPEG already as asked is embedded as it is
//! when its numbers are kept; the others' ink amounts are resampled, converted when the Output
@@ -22,6 +29,7 @@ use image::imageops::FilterType;
use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader};
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
use vectorcraft_color::cms::{IccProfile, Intent};
use vectorcraft_doc::ImageBlob;
use vectorcraft_doc::cmyk::{Inks, jpeg_components};
use vectorcraft_doc::resample::{Resample, premultiply, resized, unpremultiply};
@@ -306,6 +314,100 @@ pub(crate) struct Recoded {
pub image: Option<Image>,
/// Set when the image isn't compressed as asked.
pub warning: Option<&'static str>,
/// Set when the image's colors are converted to the destination without its ICC profile,
/// which can't be read.
pub note: Option<&'static str>,
}
/// How [`recode`] converts a gray or RGB image that carries an ICC profile to sRGB (Convert to
/// Destination, [`crate::output::ColorOut::image_srgb_intent`]): the destination when it is sRGB,
/// else the step before the image's [`Pixels`] conversion.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ToSrgb {
/// The rendering intent of the conversion.
pub intent: Intent,
/// 16-bit samples may be written (PDF 1.5 and later).
pub sixteen_bit: bool,
}
/// The ICC profile image `bytes` carries ([`source_icc`]), when it is a well-formed profile of
/// colour space `space` (`GRAY` or `RGB `, [`valid_icc`]): `None` without one, `Some(None)` when
/// the CMS can't read it.
fn source_profile(bytes: &[u8], space: [u8; 4]) -> Option<Option<IccProfile>> {
let icc = source_icc(bytes).filter(|icc| valid_icc(icc, space))?;
Some(IccProfile::from_bytes(None, &icc).ok())
}
const UNCONVERTED: &str = "an image's ICC profile couldn't be read: the image keeps it and is not converted to sRGB";
const CONVERTED_AS_SRGB: &str = "an image's ICC profile couldn't be read: its colors are converted to the destination as sRGB";
const UNDECODED: &str = "an image couldn't be decoded and is not converted to sRGB";
/// `img` converted from ICC profile `p` to sRGB with `intent`: at 16 bits a sample when it has
/// more than 8, else at 8, with its alpha; and whether any sample changed. A grey image stays grey, each level as the sRGB grey it converts to. `None` when the
/// pixels can't be converted.
fn into_srgb(img: DynamicImage, p: &IccProfile, intent: Intent) -> Option<(DynamicImage, bool)> {
let kind = img.color();
let deep = kind.bits_per_pixel() > 8 * u16::from(kind.channel_count());
let to8 = |v: f32| v.round() as u8;
let to16 = |v: f32| v.round() as u16;
Some(match (kind.has_color(), deep) {
(true, true) => {
let mut s = img.into_rgba16();
let changed = convert_samples(&mut s, 3, p, intent, 65_535.0, to16)?;
(DynamicImage::ImageRgba16(s), changed)
}
(true, false) => {
let mut s = img.into_rgba8();
let changed = convert_samples(&mut s, 3, p, intent, 255.0, to8)?;
(DynamicImage::ImageRgba8(s), changed)
}
(false, true) => {
let mut s = img.into_luma_alpha16();
let changed = convert_samples(&mut s, 1, p, intent, 65_535.0, to16)?;
(DynamicImage::ImageLumaA16(s), changed)
}
(false, false) => {
let mut s = img.into_luma_alpha8();
let changed = convert_samples(&mut s, 1, p, intent, 255.0, to8)?;
(DynamicImage::ImageLumaA8(s), changed)
}
})
}
/// Convert the `n` colour samples (1: grey, 3: RGB) of each pixel of `samples` (followed by an
/// alpha sample each, which is left as it is) from ICC profile `p` to sRGB with `intent`, a run of
/// pixels at a time; `max` is a sample's largest value, `back` turns a value of 0 to `max` into a
/// sample. Grey pixels take the mean of the sRGB values. Whether any sample changed; `None` when
/// the transform fails.
fn convert_samples<T: Copy + PartialEq + Into<f32>>(
samples: &mut [T],
n: usize,
p: &IccProfile,
intent: Intent,
max: f32,
back: impl Fn(f32) -> T,
) -> Option<bool> {
const RUN: usize = 4096;
let mut src = Vec::with_capacity(RUN * n);
let mut dst = vec![0.0f32; RUN * 3];
let mut changed = false;
for run in samples.chunks_mut(RUN * (n + 1)) {
src.clear();
src.extend(run.chunks_exact(n + 1).flat_map(|px| px.iter().take(n).map(|v| (*v).into() / max)));
let out = dst.get_mut(..src.len() / n * 3)?;
p.to_srgb_pixels(&src, out, intent)?;
for (px, rgb) in run.chunks_exact_mut(n + 1).zip(out.as_chunks::<3>().0) {
let grey = [rgb.iter().sum::<f32>() / 3.0];
let values: &[f32] = if n == 1 { &grey } else { rgb };
for (v, c) in px.iter_mut().zip(values) {
let new = back(c * max);
changed |= new != *v;
*v = new;
}
}
}
Some(changed)
}
/// Is `img` (a grey image) only black and white?
@@ -372,8 +474,11 @@ fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> Dynamic
/// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask:
/// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours
/// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile
/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces).
/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is.
/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces);
/// with `to_srgb`, pixels in a gray or RGB profile other than sRGB are converted from it to sRGB
/// instead ([`ToSrgb`]), and colour ones then by `convert`; grey ones that `convert` leaves as
/// they are keep their profile. `interpolate` lets viewers smooth it. An image that can't be
/// decoded is kept as it is, with a warning when its pixels were to be converted to sRGB.
pub(crate) fn recode(
bytes: &[u8],
size: (f64, f64),
@@ -381,39 +486,94 @@ pub(crate) fn recode(
interpolate: bool,
convert: Option<&Pixels>,
profiles: bool,
to_srgb: Option<ToSrgb>,
) -> Recoded {
let keep = Recoded { image: None, warning: None };
let keep = Recoded { image: None, warning: None, note: None };
let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep };
let jpeg = reader.format() == Some(ImageFormat::Jpeg);
let Ok(mut decoder) = reader.into_decoder() else { return keep };
let dims = decoder.dimensions();
use ExtendedColorType as T;
let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16);
// Pixels in a profile they are converted from are decoded, whatever the Compression settings.
// Grey pixels, which `convert` leaves as they are, keep their profile when it converts.
let to_srgb = to_srgb.filter(|_| !(grey && convert.is_some()));
// Converted to the destination as sRGB, as before, when the profile can't be read.
let mut note = None;
let source = match to_srgb.and_then(|t| Some((source_profile(bytes, if grey { *b"GRAY" } else { *b"RGB " })?, t))) {
Some((Some(p), t)) => Some((p, t)),
Some((None, _)) if convert.is_some() => {
note = Some(CONVERTED_AS_SRGB);
None
}
Some((None, _)) => return Recoded { image: None, warning: Some(UNCONVERTED), note: None },
None => None,
};
// Pixels that can't be decoded are kept as they are, with a warning when they were to be
// converted.
let keep = if source.is_some() { Recoded { image: None, warning: Some(UNDECODED), note: None } } else { keep };
// What the header declares is decoded within the image allowance, not whatever it asks for.
if decoder.set_limits(limits()).is_err() {
return keep;
}
let dims = decoder.dimensions();
use ExtendedColorType as T;
let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16);
let (kind, pixels) = if grey {
let (mut kind, pixels) = if grey {
// Only the pixels tell monochrome images from greyscale ones: decoded unless both keep it.
let (g, m) = (rule(c, Kind::Gray), rule(c, Kind::Mono));
if kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead {
return Recoded { image: None, warning: g.instead };
if source.is_none() && kept(&g, dims, size, jpeg) && kept(&m, dims, size, jpeg) && g.instead == m.instead {
return Recoded { image: None, warning: g.instead, note: None };
}
let Ok(img) = DynamicImage::from_decoder(decoder) else { return keep };
(if bilevel(&img) { Kind::Mono } else { Kind::Gray }, Some(img))
} else {
(Kind::Color, None)
};
let rule = rule(c, kind);
let mut rule = rule(c, kind);
let convert = convert.filter(|_| kind == Kind::Color);
if kept(&rule, dims, size, jpeg) && convert.is_none() {
return Recoded { image: None, warning: rule.instead };
if kept(&rule, dims, size, jpeg) && convert.is_none() && source.is_none() {
return Recoded { image: None, warning: rule.instead, note: None };
}
let Some(mut img) = pixels.or_else(|| decoded(bytes)) else { return keep };
if let Some((p, t)) = &source {
let srgb = match into_srgb(img, p, t.intent) {
Some(s) => Some(s),
None if convert.is_some() => {
// Converted to the destination as sRGB: decoded again (a colour image), as the
// conversion took the pixels.
note = Some(CONVERTED_AS_SRGB);
None
}
None => return Recoded { image: None, warning: Some(UNCONVERTED), note: None },
};
img = match srgb {
Some((srgb, changed)) => {
// The converted levels of a black-and-white image may be grey.
if kind == Kind::Mono && !bilevel(&srgb) {
(kind, rule) = (Kind::Gray, self::rule(c, Kind::Gray));
}
// Written as it is only when nothing converts it further.
let as_is = kept(&rule, dims, size, jpeg) && convert.is_none();
// A JPEG kept as JPEG whose samples the conversion leaves unchanged (an sRGB
// profile): its JPEG data, without the profile.
if !changed && as_is && jpeg {
let image = Image::from_jpeg_with_icc(bytes.to_vec().into(), None, interpolate).ok();
return Recoded { image, warning: rule.instead, note: None };
}
// Not resampled and lossless: written at its own depth.
if as_is && jpeg_quality(&rule, jpeg).is_none() {
return Recoded { image: from_decoded(srgb, None, interpolate, t.sixteen_bit), warning: rule.instead, note: None };
}
srgb
}
None => {
let Some(img) = decoded(bytes) else { return keep };
img
}
};
}
let Some(img) = pixels.or_else(|| decoded(bytes)) else { return keep };
// Pixels that keep their colours keep the profile they are in; converted ones are in the
// destination's.
let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" };
let icc = (profiles && convert.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want));
let icc = (profiles && convert.is_none() && source.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want));
let rgb_icc = icc.is_some() && kind == Kind::Color;
let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8();
if kind == Kind::Mono {
@@ -441,7 +601,7 @@ pub(crate) fn recode(
to_jpeg(&samples, space, width, height, q, interpolate, icc.as_deref().map(Vec::as_slice))
});
let image = jpeg.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate, convert, icc));
Recoded { image, warning: rule.instead }
Recoded { image, warning: rule.instead, note }
}
/// A colorized image's coverage (one sample a pixel, `w` × `h`, see
@@ -492,16 +652,16 @@ pub(crate) fn recode_cmyk(
let image = (u64::from(w) * u64::from(h) <= MAX_EMBED_PIXELS)
.then(|| Image::from_jpeg_with_icc(blob.bytes.as_ref().clone().into(), icc.map(|i| i.to_vec().into()), interpolate).ok())
.flatten();
return Recoded { image, warning: rule.instead };
return Recoded { image, warning: rule.instead, note: None };
}
}
let Some(inks) = blob.cmyk() else {
return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }) };
return Recoded { image: None, warning: Some(if jpeg { CMYK_KEPT } else { CMYK_RGB }), note: None };
};
let (width, height) = sized(&rule, (inks.width, inks.height), size);
let (inks, alpha) = match resampled_inks(inks, alpha, &rule, (width, height)) {
Ok(r) => r,
Err(warning) => return Recoded { image: None, warning },
Err(warning) => return Recoded { image: None, warning, note: None },
};
let (samples, space) = match convert {
Some(p) => p.convert(&inks),
@@ -510,7 +670,7 @@ pub(crate) fn recode_cmyk(
// JPEG needs opaque pixels.
let image = jpeg_quality(&rule, jpeg).filter(|_| alpha.is_none()).and_then(|q| to_jpeg(&samples, space, width, height, q, interpolate, icc));
let image = image.or_else(|| lossless(samples, alpha, space, width, height, interpolate, icc));
Recoded { image, warning: rule.instead }
Recoded { image, warning: rule.instead, note: None }
}
/// Ink amounts (four bytes a pixel) and their alpha (one byte a pixel), if any.
+23 -4
View File
@@ -10,8 +10,12 @@
//! colours of the other model (and Lab ones), keeping the numbers of those already in it.
//! Grey stays grey. Images (and freeform gradients' images) are converted the same way, pixel
//! by pixel (CMYK images too: they keep their numbers unless converted, see [`CmykPixels`]);
//! printer's marks are not. The destination is a profile name; empty, the document's
//! profile for its colour mode.
//! printer's marks are not. With `destination` and an sRGB destination, a gray or RGB image
//! that carries an ICC profile is converted from it to sRGB with the same intent and written
//! without it ([`ColorOut::image_srgb_intent`]); with `destination` and a destination its
//! pixels are converted to (CMYK, another RGB space), a colour image is converted from its
//! profile to sRGB and then to the destination. Without conversion it keeps its profile. The
//! destination is a profile name; empty, the document's profile for its colour mode.
//! - Profiles ([`ProfileInclusion`]): with `all`, with `destination`, and with `taggedSource` on a
//! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK
//! with the CMYK profile in effect (the destination when converting to CMYK, else the
@@ -50,6 +54,8 @@ struct Dest {
keep: bool,
/// The destination is sRGB (what images are).
srgb: bool,
/// Convert to Destination was asked for (not Preserve Numbers, nor PDF/X-1a's own conversion).
destination: bool,
}
/// How an export writes colours (see the module documentation).
@@ -233,11 +239,12 @@ impl ColorOut {
};
let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?;
// PDF/X-1a converts without being asked: as Preserve Numbers does.
let keep = set.output.conversion != ColorConversion::Destination || cms::canonical_name(&own) == name;
let destination = set.output.conversion == ColorConversion::Destination;
let keep = !destination || cms::canonical_name(&own) == name;
if model == Model::Cmyk {
out.cmyk_profile = name.clone();
}
out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep });
out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep, destination });
}
out.tagged = matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4)
|| (!set.standard.cmyk_only()
@@ -338,6 +345,18 @@ impl ColorOut {
.as_ref()
}
/// The intent a gray or RGB image that carries an ICC profile is converted from it to sRGB
/// with ([`crate::images::recode`]): the colour settings' intent, with Convert to Destination
/// and an sRGB destination, also when the document's RGB space is sRGB, or a destination
/// colour images' pixels are then converted to ([`Self::pixels`]: CMYK, another RGB space).
/// `None` otherwise, Preserve Numbers and an RGB destination that is the document's own RGB
/// profile (which leaves RGB colours and RGB images as they are) included: such an image
/// keeps its profile, or its numbers are converted as sRGB ones.
pub(crate) fn image_srgb_intent(&self) -> Option<Intent> {
let d = self.dest.as_ref().filter(|d| d.destination)?;
((d.model == Model::Rgb && d.srgb) || self.pixels().is_some()).then_some(self.intent)
}
/// The CMYK profile CMYK image `blob` is tagged with ([`crate::images::cmyk_icc`]): the one it
/// carries, when its numbers are kept and the Profiles setting or the standard includes source
/// profiles. `None` otherwise, and in PDF/X-1a, whose images are untagged CMYK.
+11
View File
@@ -297,6 +297,17 @@ impl Opened {
bleed,
}
}
/// How far page `index`'s (0-based) bleed box reaches past its trim box, `[top, bottom, left,
/// right]` in points, as the page shows (rotated); `None` past the last page, and when the bleed
/// box doesn't hold the trim box.
pub fn page_bleed(&self, index: usize) -> Option<[f64; 4]> {
let page = self.pdf.pages().get(index)?;
let init = Affine::new(page.initial_transform(true).as_coeffs());
let (trim, bleed) = (init.transform_rect_bbox(page_box(page, CropTo::Trim)), init.transform_rect_bbox(page_box(page, CropTo::Bleed)));
let sides = [trim.y0 - bleed.y0, bleed.y1 - trim.y1, trim.x0 - bleed.x0, bleed.x1 - trim.x1];
sides.iter().all(|b| b.is_finite() && *b >= 0.0).then_some(sides)
}
}
/// A PDF's pages as an import on their trim boxes sizes them ([`Opened::trim_pages`]).
+428 -3
View File
@@ -266,7 +266,7 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() {
assert_eq!(crate::images::mask_alpha(&blob, 60_000, 60_000, false), None);
assert_eq!(crate::images::mask_alpha(&blob, 2, 2, false), None, "another size is not decoded at all");
let c: CompressionSettings = serde_json::from_value(json!({"gray": {"downsample": "bicubic", "ppi": 72, "abovePpi": 100}})).unwrap();
let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true);
let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true, None);
assert!(r.image.is_none(), "kept as it is");
}
@@ -529,9 +529,14 @@ fn tagged_tiffs_keep_their_icc_profile() {
);
}
}
// Converted pixels are in the destination's colours, not the image's profile.
// Converted pixels are in the destination's colours, not the image's profile, which the CMS
// can't read (it has no tags): they are converted as sRGB ones, with a warning.
for destination in [vectorcraft_color::cms::DISPLAY_P3, vectorcraft_color::cms::GENERIC_CMYK] {
let spaces = export(&tiff(&colour(), &rgb), json!({"output": {"conversion": "destination", "destination": destination}}));
let mut settings: PdfSettings = serde_json::from_value(json!({"output": {"conversion": "destination", "destination": destination}})).unwrap();
settings.compression.compress_text = false;
let r = export_with_report(&tiff(&colour(), &rgb), &PdfOptions { settings, ..Default::default() }).unwrap();
assert!(r.warnings.len() == 1 && r.warnings[0].contains("converted to the destination as sRGB"), "{destination}: {:?}", r.warnings);
let spaces = image_spaces(&r.bytes);
assert!(spaces.len() == 1 && spaces[0].icc.as_deref() != Some(rgb.as_slice()), "converted to {destination}: {spaces:?}");
}
// PDF/X-1a allows no ICC-based color spaces: a tagged gray TIFF (8 or 16 bits), PNG or JPEG is
@@ -555,3 +560,423 @@ fn tagged_tiffs_keep_their_icc_profile() {
}
}
}
/// A gray ICC profile (version 2.1, display class) with a D50 white point whose tone curve is `trc`:
/// one entry, a gamma (u8Fixed8), or two, the ends of a table. 192 bytes.
fn gray_profile(trc: &[u16]) -> Vec<u8> {
let mut icc = vec![0u8; 192];
icc[..4].copy_from_slice(&192u32.to_be_bytes());
icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]);
icc[12..16].copy_from_slice(b"mntr");
icc[16..20].copy_from_slice(b"GRAY");
icc[20..24].copy_from_slice(b"XYZ ");
icc[36..40].copy_from_slice(b"acsp");
let d50 = [0x0000_F6D6u32, 0x0001_0000, 0x0000_D32D];
for (i, v) in d50.iter().enumerate() {
icc[68 + i * 4..72 + i * 4].copy_from_slice(&v.to_be_bytes());
}
icc[128..132].copy_from_slice(&2u32.to_be_bytes());
let curve_len = 12 + 2 * trc.len() as u32;
for (i, (sig, at, len)) in [(b"kTRC", 156u32, curve_len), (b"wtpt", 172, 20)].into_iter().enumerate() {
let e = 132 + i * 12;
icc[e..e + 4].copy_from_slice(sig);
icc[e + 4..e + 8].copy_from_slice(&at.to_be_bytes());
icc[e + 8..e + 12].copy_from_slice(&len.to_be_bytes());
}
icc[156..160].copy_from_slice(b"curv");
icc[164..168].copy_from_slice(&(trc.len() as u32).to_be_bytes());
for (i, v) in trc.iter().enumerate() {
icc[168 + i * 2..170 + i * 2].copy_from_slice(&v.to_be_bytes());
}
icc[172..176].copy_from_slice(b"XYZ ");
for (i, v) in d50.iter().enumerate() {
icc[180 + i * 4..184 + i * 4].copy_from_slice(&v.to_be_bytes());
}
icc
}
/// The samples of `img` converted from ICC profile `icc` to sRGB with `intent`, as an image
/// XObject holds them: RGB (or grey, the mean of the sRGB values, when `img` is grey) at 8 bits a
/// sample, or at 16 big-endian with `deep`.
fn srgb_samples(img: &DynamicImage, icc: &[u8], intent: vectorcraft_color::cms::Intent, deep: bool) -> Vec<u8> {
let p = vectorcraft_color::cms::IccProfile::from_bytes(None, icc).unwrap();
let (max, n) = (if deep { 65_535.0 } else { 255.0 }, if img.color().has_color() { 3 } else { 1 });
let values: Vec<f32> = match (n, deep) {
(3, true) => img.to_rgb16().into_raw().into_iter().map(f32::from).collect(),
(3, false) => img.to_rgb8().into_raw().into_iter().map(f32::from).collect(),
(_, true) => img.to_luma16().into_raw().into_iter().map(f32::from).collect(),
_ => img.to_luma8().into_raw().into_iter().map(f32::from).collect(),
};
let mut out = vec![];
for px in values.chunks(n) {
let v: Vec<f32> = px.iter().map(|v| v / max).collect();
let rgb = p.to_srgb(&v, intent).unwrap();
let rgb = if n == 1 { vec![rgb.iter().sum::<f32>() / 3.0] } else { rgb.to_vec() };
for c in rgb {
let s = (c * max).round();
if deep { out.extend_from_slice(&(s as u16).to_be_bytes()) } else { out.push(s as u8) }
}
}
out
}
/// Do samples `a` and `b` (8 bits each, or 16 big-endian with `deep`) differ by at most `tolerance`?
fn samples_close(a: &[u8], b: &[u8], deep: bool, tolerance: i32) -> bool {
let values = |s: &[u8]| -> Vec<i32> {
if deep { s.chunks(2).map(|v| i32::from(u16::from_be_bytes([v[0], v[1]]))).collect() } else { s.iter().map(|v| i32::from(*v)).collect() }
};
let (a, b) = (values(a), values(b));
a.len() == b.len() && a.iter().zip(&b).all(|(a, b)| (a - b).abs() <= tolerance)
}
/// The image XObjects of `d` exported with `settings`, which add no warning, and the file.
fn export_images(d: &Document, settings: Value) -> (Vec<Written>, Vec<u8>) {
let mut settings: PdfSettings = serde_json::from_value(settings).unwrap();
settings.compression.compress_text = false;
let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
(image_spaces(&r.bytes), r.bytes)
}
/// With Convert to Destination and an sRGB destination, a gray or RGB image that carries an ICC
/// profile is converted from it to sRGB and written without it: an 8-bit TIFF, a 16-bit one (at
/// 16 bits a sample from PDF 1.5 on, else at 8), a PNG, a gray TIFF, a resampled image and a JPEG
/// kept as JPEG. Without conversion each keeps its profile. An image in an sRGB profile keeps its
/// samples (a JPEG its JPEG data), and a black-and-white image converted to grays stays gray.
#[test]
fn convert_to_srgb_converts_tagged_images_from_their_profile() {
use vectorcraft_color::cms::{DISPLAY_P3, Intent, SRGB, icc_bytes};
// Marked version 4.2: PDF 1.7 holds ICC profiles up to that version.
let mut p3 = icc_bytes(DISPLAY_P3).unwrap().to_vec();
p3[8..12].copy_from_slice(&[4, 0x20, 0, 0]);
// Gamma 1.8.
let gray = gray_profile(&[0x01CD]);
let to_srgb = |more: Value| {
let mut s = json!({"output": {"conversion": "destination", "destination": SRGB}});
s.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
s
};
let device = |space: &str, bits: u8, filter: &str| vec![Written { space: space.into(), icc: None, bits, filter: filter.into(), smask: false }];
let flate = "FlateDecode";
let deep = DynamicImage::ImageRgb16(image::ImageBuffer::from_fn(600, 600, |x, y| {
image::Rgb([((x * 109 + y) % 65_521) as u16, ((y * 257 + 3) ^ 0x5a) as u16, ((x * y) % 65_521) as u16])
}));
let cases = [
("RGB TIFF", colour(), tagged(&colour(), &p3, ImageFormat::Tiff), "image/tiff", &p3, json!({}), device("DeviceRGB", 8, flate), false),
("RGB PNG", colour(), tagged(&colour(), &p3, ImageFormat::Png), "image/png", &p3, json!({}), device("DeviceRGB", 8, flate), false),
(
"16-bit RGB TIFF",
deep.clone(),
tagged(&deep, &p3, ImageFormat::Tiff),
"image/tiff",
&p3,
json!({"compatibility": "1.7"}),
device("DeviceRGB", 16, flate),
true,
),
(
"16-bit RGB TIFF, PDF 1.4",
deep.clone(),
tagged(&deep, &p3, ImageFormat::Tiff),
"image/tiff",
&p3,
json!({"compatibility": "1.4"}),
device("DeviceRGB", 8, flate),
false,
),
(
"gray TIFF",
colour().grayscale(),
tagged(&colour().grayscale(), &gray, ImageFormat::Tiff),
"image/tiff",
&gray,
json!({}),
device("DeviceGray", 8, flate),
false,
),
];
for (what, img, bytes, mime, icc, more, want, sixteen) in cases {
let d = doc(bytes, mime);
let (spaces, pdf) = export_images(&d, to_srgb(more.clone()));
assert_eq!(spaces, want, "{what}");
let samples = image_samples(&pdf);
// 16-bit samples are converted, then reduced to 8 bits before PDF 1.5.
let deep_source = matches!(img, DynamicImage::ImageRgb16(_));
let want = srgb_samples(&img, icc, Intent::RelativeColorimetric, deep_source);
let want: Vec<u8> = if deep_source && !sixteen {
want.chunks(2).map(|v| (f32::from(u16::from_be_bytes([v[0], v[1]])) / 257.0).round() as u8).collect()
} else {
want
};
assert!(samples.len() == 1 && samples_close(&samples[0], &want, sixteen, if sixteen { 2 } else { 1 }), "{what}: converted samples");
let unconverted: Vec<u8> = match &img {
DynamicImage::ImageRgb16(s) if sixteen => s.iter().flat_map(|v| v.to_be_bytes()).collect(),
s if s.color().has_color() => s.to_rgb8().into_raw(),
s => s.to_luma8().into_raw(),
};
assert!(!samples_close(&samples[0], &unconverted, sixteen, 0), "{what}: the samples changed");
// Without conversion the image keeps its profile (a version 4 profile from PDF 1.5 on).
let mut plain = more;
plain["compatibility"] = json!("1.7");
let (spaces, _) = export_images(&d, plain);
assert!(
spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(icc.as_slice()),
"{what}, no conversion: {spaces:?}"
);
}
// Resampled, or kept as a JPEG: converted and written without the profile.
let resampled = to_srgb(json!({"compression": {"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}}));
let (spaces, _) = export_images(&doc(tagged(&colour(), &p3, ImageFormat::Tiff), "image/tiff"), resampled);
assert_eq!(spaces, device("DeviceRGB", 8, flate), "resampled");
let (spaces, _) = export_images(&doc(tagged(&colour(), &p3, ImageFormat::Jpeg), "image/jpeg"), to_srgb(json!({})));
assert_eq!(spaces, device("DeviceRGB", 8, "DCTDecode"), "JPEG");
// An image in an sRGB profile keeps its samples, and a JPEG its JPEG data, written without the
// profile; so does an image whose samples the conversion leaves unchanged (black in P3).
let srgb = icc_bytes(SRGB).unwrap().to_vec();
let (spaces, pdf) = export_images(&doc(tagged(&colour(), &srgb, ImageFormat::Png), "image/png"), to_srgb(json!({"compatibility": "1.7"})));
assert_eq!(spaces, device("DeviceRGB", 8, flate), "sRGB PNG");
assert!(image_samples(&pdf) == [colour().to_rgb8().into_raw()], "sRGB PNG: the samples unchanged");
let jpeg = tagged(&colour(), &srgb, ImageFormat::Jpeg);
let (spaces, pdf) = export_images(&doc(jpeg.clone(), "image/jpeg"), to_srgb(json!({"compatibility": "1.7"})));
assert_eq!(spaces, device("DeviceRGB", 8, "DCTDecode"), "sRGB JPEG");
assert!(image_data(&pdf) == [jpeg], "sRGB JPEG: the JPEG data unchanged");
let black = DynamicImage::ImageRgb8(image::RgbImage::new(600, 600));
let (spaces, _) = export_images(&doc(tagged(&black, &p3, ImageFormat::Png), "image/png"), to_srgb(json!({"compatibility": "1.7"})));
assert_eq!(spaces, device("DeviceRGB", 8, flate), "black P3 PNG");
// A black-and-white image whose profile makes black a gray is written in those grays, not
// thresholded back to black and white when it is resampled.
let bilevel =
DynamicImage::ImageLuma8(image::GrayImage::from_fn(600, 600, |x, y| image::Luma([if (x / 10 + y / 10) % 2 == 0 { 0 } else { 255 }])));
let lifted = gray_profile(&[0x3333, 0xFFFF]);
let resample = json!({"downsample": "bicubic", "ppi": 150, "abovePpi": 225});
let (spaces, pdf) = export_images(
&doc(tagged(&bilevel, &lifted, ImageFormat::Tiff), "image/tiff"),
to_srgb(json!({"compression": {"gray": resample.clone(), "mono": resample}})),
);
assert_eq!(spaces, device("DeviceGray", 8, flate), "lifted black and white");
let samples = image_samples(&pdf);
assert!(samples.len() == 1 && samples[0].iter().all(|v| *v > 0) && samples[0].iter().any(|v| *v > 200), "lifted black and white: grays kept");
// A well-formed profile the CMS can't read (no tags) is kept, with a warning.
let unread = icc_profile(b"RGB ", 600);
let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap();
settings.compression.compress_text = false;
let r = export_with_report(&doc(tagged(&colour(), &unread, ImageFormat::Tiff), "image/tiff"), &PdfOptions { settings, ..Default::default() })
.unwrap();
assert!(r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
let spaces = image_spaces(&r.bytes);
assert!(spaces.len() == 1 && spaces[0].icc.as_deref() == Some(unread.as_slice()), "unread profile: {spaces:?}");
// A tagged image whose header declares more pixels than the decoding allowance is kept as it
// is, with a warning when it was to be converted.
let declared = |img: &DynamicImage, icc: &[u8]| {
let mut png = tagged(img, icc, ImageFormat::Png);
png[16..24].copy_from_slice(&[0, 0, 0x75, 0x30, 0, 0, 0x75, 0x30]);
let mut crc = flate2::Crc::new();
crc.update(&png[12..29]);
png[29..33].copy_from_slice(&crc.sum().to_be_bytes());
png
};
let c: CompressionSettings = serde_json::from_value(json!({})).unwrap();
let srgb_out = Some(crate::images::ToSrgb { intent: Intent::RelativeColorimetric, sixteen_bit: true });
for (what, png) in [("RGB", declared(&colour(), &p3)), ("gray", declared(&colour().grayscale(), &gray))] {
let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, srgb_out);
assert!(r.image.is_none() && r.warning.is_some_and(|w| w.contains("not converted to sRGB")), "{what}: {:?}", r.warning);
let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, None);
assert!(r.image.is_none() && r.warning.is_none(), "{what}, no conversion: {:?}", r.warning);
// Too large to embed as it is too: the export leaves it out, and says only that.
let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap();
settings.compression.compress_text = false;
let r = export_with_report(&doc(png, "image/png"), &PdfOptions { settings, ..Default::default() }).unwrap();
assert!(r.warnings.len() == 1 && r.warnings[0].contains("missing from the picture"), "{what}, exported: {:?}", r.warnings);
}
}
/// An RGB ICC profile (version 2.1, display class, XYZ connection space) of two lookup tables of
/// two grid points each: the colorimetric one (`A2B1`) maps device RGB linearly onto sRGB's
/// primaries adapted to D50, the perceptual one (`A2B0`) onto half of them. 436 bytes.
fn rgb_two_intents() -> Vec<u8> {
let mut icc = vec![0u8; 436];
icc[..4].copy_from_slice(&436u32.to_be_bytes());
icc[8..12].copy_from_slice(&[2, 0x10, 0, 0]);
icc[12..16].copy_from_slice(b"mntr");
icc[16..20].copy_from_slice(b"RGB ");
icc[20..24].copy_from_slice(b"XYZ ");
icc[36..40].copy_from_slice(b"acsp");
let d50 = [0x0000_F6D6u32, 0x0001_0000, 0x0000_D32D];
for (i, v) in d50.iter().enumerate() {
icc[68 + i * 4..72 + i * 4].copy_from_slice(&v.to_be_bytes());
}
icc[128..132].copy_from_slice(&3u32.to_be_bytes());
for (i, (sig, at, len)) in [(b"A2B0", 168u32, 124u32), (b"A2B1", 292, 124), (b"wtpt", 416, 20)].into_iter().enumerate() {
let e = 132 + i * 12;
icc[e..e + 4].copy_from_slice(sig);
icc[e + 4..e + 8].copy_from_slice(&at.to_be_bytes());
icc[e + 8..e + 12].copy_from_slice(&len.to_be_bytes());
}
let primaries = [[0.4361f32, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]];
for (at, scale) in [(168usize, 0.5f32), (292, 1.0)] {
let mut lut = vec![];
lut.extend_from_slice(b"mft2");
lut.extend_from_slice(&[0, 0, 0, 0, 3, 3, 2, 0]);
// The identity matrix, as s15Fixed16 numbers.
for v in [1u32, 0, 0, 0, 1, 0, 0, 0, 1] {
lut.extend_from_slice(&(v << 16).to_be_bytes());
}
lut.extend_from_slice(&2u16.to_be_bytes());
lut.extend_from_slice(&2u16.to_be_bytes());
for _ in 0..3 {
lut.extend_from_slice(&[0, 0, 0xFF, 0xFF]);
}
for corner in 0..8u32 {
let on = [corner >> 2 & 1, corner >> 1 & 1, corner & 1].map(|b| b as f32);
let xyz = primaries.iter().zip(on).fold([0.0f32; 3], |sum, (p, o)| [sum[0] + o * p[0], sum[1] + o * p[1], sum[2] + o * p[2]]);
for v in xyz {
lut.extend_from_slice(&((v * scale * 32_768.0).round() as u16).to_be_bytes());
}
}
for _ in 0..3 {
lut.extend_from_slice(&[0, 0, 0xFF, 0xFF]);
}
icc[at..at + 124].copy_from_slice(&lut);
}
icc[416..420].copy_from_slice(b"XYZ ");
for (i, v) in d50.iter().enumerate() {
icc[424 + i * 4..428 + i * 4].copy_from_slice(&v.to_be_bytes());
}
icc
}
/// The conversion of a tagged image to an sRGB destination uses the colour settings' intent: an
/// RGB profile with a perceptual table and a colorimetric one converts with the table the intent
/// picks.
#[test]
fn convert_to_srgb_converts_images_with_the_color_settings_intent() {
use vectorcraft_color::cms::{self, ColorSettings, Intent, SRGB};
let icc = rgb_two_intents();
let img = colour();
let d = doc(tagged(&img, &icc, ImageFormat::Tiff), "image/tiff");
let settings = json!({"output": {"conversion": "destination", "destination": SRGB}});
let mut seen = vec![];
for intent in [Intent::RelativeColorimetric, Intent::Perceptual] {
let active = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, ..ColorSettings::default() }).unwrap());
let samples = image_samples(&cms::with_active(active, || export_images(&d, settings.clone())).1);
let want = srgb_samples(&img, &icc, intent, false);
assert!(samples.len() == 1 && samples_close(&samples[0], &want, false, 1), "{intent:?}: converted samples");
seen.push(samples[0].clone());
}
assert!(seen[0] != seen[1], "the two intents give different samples");
}
/// A 600 × 600 image of sixteen blocks of colour, saturated ones (the primaries and secondaries)
/// among them.
fn colour_blocks() -> DynamicImage {
const LEVELS: [u8; 4] = [0, 90, 200, 255];
image::RgbImage::from_fn(600, 600, |x, y| {
let (i, j) = ((x / 150) as usize, (y / 150) as usize);
image::Rgb([LEVELS[i], LEVELS[j], LEVELS[(i + 2 * j) % 4]])
})
.into()
}
/// The samples (8 bits each) of `img`'s pixels as `convert` gives each one's: each distinct
/// colour converts once.
fn converted_samples(img: &DynamicImage, convert: impl Fn([u8; 3]) -> Vec<u8>) -> Vec<u8> {
let mut seen: std::collections::HashMap<[u8; 3], Vec<u8>> = std::collections::HashMap::new();
let mut out = vec![];
for p in img.to_rgb8().pixels() {
out.extend_from_slice(seen.entry(p.0).or_insert_with(|| convert(p.0)));
}
out
}
/// The largest difference between samples `a` and `b` (8 bits each) and where it is: (difference,
/// sample index, the two samples), or the two lengths when they differ.
fn max_diff(a: &[u8], b: &[u8]) -> String {
if a.len() != b.len() {
return format!("lengths {} and {}", a.len(), b.len());
}
let worst = a.iter().zip(b).enumerate().max_by_key(|(_, (a, b))| a.abs_diff(**b));
worst.map_or_else(String::new, |(i, (x, y))| format!("largest difference {} at sample {i}: {x} vs {y}", x.abs_diff(*y)))
}
/// With Convert to Destination and a CMYK destination, or an RGB one other than sRGB, a colour
/// image that carries an ICC profile is converted from that profile (to sRGB, then to the
/// destination) rather than from its numbers read as sRGB ones; an untagged image is converted
/// from sRGB as before. A gray image keeps its profile (gray stays gray), and an RGB profile the
/// CMS can't read leaves the numbers converted as sRGB ones, with a warning. An RGB destination
/// other than sRGB that is the document's own RGB profile leaves RGB images as they are, so the image keeps its
/// profile.
#[test]
fn convert_to_destination_converts_tagged_images_from_their_profile() {
use vectorcraft_color::cms::{self, ColorSettings, DISPLAY_P3, GENERIC_CMYK, Intent, WIDE_GAMUT_RGB, icc_bytes};
let intent = Intent::RelativeColorimetric;
let active = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, ..ColorSettings::default() }).unwrap());
let mut p3 = icc_bytes(DISPLAY_P3).unwrap().to_vec();
p3[8..12].copy_from_slice(&[4, 0x20, 0, 0]);
let p3_profile = cms::IccProfile::from_bytes(None, &p3).unwrap();
// A P3 pixel's sRGB samples.
let from_p3 = |rgb: [u8; 3]| p3_profile.to_srgb(&rgb.map(|v| f32::from(v) / 255.0), intent).unwrap().map(|v| (v * 255.0).round() as u8);
let img = colour_blocks();
let tagged_png = doc(tagged(&img, &p3, ImageFormat::Png), "image/png");
let untagged_png = doc(encoded(img.clone(), ImageFormat::Png), "image/png");
let device = |space: &str| vec![Written { space: space.into(), icc: None, bits: 8, filter: "FlateDecode".into(), smask: false }];
// Each destination: the space images are written in, and how an sRGB pixel's samples convert
// to it (the tables the export builds from the destination's color settings).
let cmyk = cms::Cms::new(&ColorSettings { intent, cmyk: GENERIC_CMYK.into(), ..ColorSettings::default() }).unwrap();
let wide = cms::Cms::new(&ColorSettings { intent, rgb: WIDE_GAMUT_RGB.into(), ..ColorSettings::default() }).unwrap();
let cmy = cms::ProofLut::build(|rgb| {
let v = cmyk.srgb_to_cmyk(rgb, intent);
[v[0], v[1], v[2]]
});
let black = cms::ProofLut::build(|rgb| [cmyk.srgb_to_cmyk(rgb, intent)[3], 0.0, 0.0]);
let wide_lut = cms::ProofLut::build(|rgb| wide.srgb_to_rgb(rgb));
let to_cmyk = |rgb: [u8; 3]| [cmy.apply8(rgb).as_slice(), &black.apply8(rgb)[..1]].concat();
let to_wide = |rgb: [u8; 3]| wide_lut.apply8(rgb).to_vec();
type ToDest<'a> = &'a dyn Fn([u8; 3]) -> Vec<u8>;
let destinations: [(&str, &str, ToDest<'_>); 2] = [(GENERIC_CMYK, "DeviceCMYK", &to_cmyk), (WIDE_GAMUT_RGB, "DeviceRGB", &to_wide)];
for (name, space, to_dest) in destinations {
let settings = json!({"output": {"conversion": "destination", "destination": name}});
let export = |d: &Document| cms::with_active(active.clone(), || export_images(d, settings.clone()));
let (spaces, pdf) = export(&tagged_png);
assert_eq!(spaces, device(space), "{name}: tagged");
let samples = image_samples(&pdf);
let want = converted_samples(&img, |rgb| to_dest(from_p3(rgb)));
let as_srgb = converted_samples(&img, to_dest);
assert!(
samples.len() == 1 && samples_close(&samples[0], &want, false, 1),
"{name}: converted from the profile, {}",
max_diff(&samples[0], &want)
);
assert!(!samples_close(&samples[0], &as_srgb, false, 8), "{name}: not converted as sRGB");
// An untagged image is converted from sRGB, as before.
let (spaces, pdf) = export(&untagged_png);
assert_eq!(spaces, device(space), "{name}: untagged");
let samples = image_samples(&pdf);
assert!(
samples.len() == 1 && samples_close(&samples[0], &as_srgb, false, 1),
"{name}: untagged converted as sRGB, {}",
max_diff(&samples[0], &as_srgb)
);
}
// A destination that is the document's own RGB profile leaves RGB images as they are: the
// image keeps its profile, as untagged images keep their numbers.
let own = std::sync::Arc::new(cms::Cms::new(&ColorSettings { intent, rgb: WIDE_GAMUT_RGB.into(), ..ColorSettings::default() }).unwrap());
let settings = json!({"compatibility": "1.7", "output": {"conversion": "destination", "destination": WIDE_GAMUT_RGB}});
let (spaces, _) = cms::with_active(own, || export_images(&tagged_png, settings));
assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(p3.as_slice()), "own RGB profile: {spaces:?}");
// A gray image keeps its gray profile in a CMYK destination.
let gray = gray_profile(&[0x01CD]);
let settings = json!({"output": {"conversion": "destination", "destination": GENERIC_CMYK}});
let gray_doc = doc(tagged(&colour().grayscale(), &gray, ImageFormat::Tiff), "image/tiff");
let (spaces, _) = cms::with_active(active.clone(), || export_images(&gray_doc, settings.clone()));
assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.as_deref() == Some(gray.as_slice()), "gray: {spaces:?}");
// A profile the CMS can't read: converted as sRGB, with a warning.
let unread = icc_profile(b"RGB ", 600);
let mut s: PdfSettings = serde_json::from_value(settings).unwrap();
s.compression.compress_text = false;
let unread_doc = doc(tagged(&img, &unread, ImageFormat::Png), "image/png");
let r = cms::with_active(active, || export_with_report(&unread_doc, &PdfOptions { settings: s, ..Default::default() })).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("converted to the destination as sRGB")), "{:?}", r.warnings);
assert_eq!(image_spaces(&r.bytes), device("DeviceCMYK"), "unread profile");
let as_srgb = converted_samples(&img, to_cmyk);
assert!(image_samples(&r.bytes).first().is_some_and(|s| samples_close(s, &as_srgb, false, 1)), "unread profile: converted as sRGB");
}
+2
View File
@@ -28,6 +28,8 @@ serde_json = { workspace = true }
# SVGZ (gzip, pure-Rust backend: builds for wasm).
flate2 = { workspace = true }
image = { workspace = true }
# Image data URLs decoded as usvg decodes them, within a byte limit (MIT OR Apache-2.0; already used by usvg).
data-url = "0.3.2"
# Colour / transform / length parsing for the <text> fallback (MIT OR Apache-2.0; already used by usvg).
svgtypes = "0.16"
simplecss = "0.2.2"
+25 -4
View File
@@ -168,8 +168,8 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
if spacing != 0.0 {
p.push(("letter-spacing", len(spacing / 1000.0 * st.size * st.v_scale / 100.0)));
}
if st.kerning.is_some() {
// Manual kerning replaces the font's pair kerning.
if st.kerning.is_some() || st.glyph_form.is_width() {
// Manual kerning replaces the font's pair kerning, and a monospaced width form turns it off.
p.push(("font-kerning", "none".into()));
}
let mut v: Vec<String> = st
@@ -190,8 +190,9 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
.filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric()))
.map(|(tag, value)| format!("\"{tag}\" {value}"))
.collect();
// Proportional Metrics: `palt` (SVG writes vertical type as outlines).
if st.proportional_metrics {
// Proportional Metrics: `palt` (SVG writes vertical type as outlines), not with a monospaced
// width form, which sets the characters on a fixed width.
if st.proportional_metrics && !st.glyph_form.is_width() {
v.push("\"palt\" 1".into());
}
// The East Asian glyph form: its one feature.
@@ -876,4 +877,24 @@ mod tests {
assert_eq!(css_string("w\r\"</style>"), r#""w\d \"\3c /style\3e ""#);
assert_eq!(css_string("wght"), "\"wght\"");
}
/// A monospaced width form sets the characters on a fixed width: Proportional Metrics' `palt`
/// is not written with it and the font's pair kerning is turned off, and with the other forms
/// `palt` is written and kerning stays.
#[test]
fn width_forms_leave_out_proportional_metrics() {
use vectorcraft_doc::GlyphForm;
let prop = |form, key: &str| {
let st = CharStyle { proportional_metrics: true, glyph_form: form, ..CharStyle::default() };
type_props(&st, &|v| format!("{v}px")).into_iter().find(|(k, _)| *k == key).map(|(_, v)| v)
};
assert_eq!(prop(GlyphForm::Default, "font-feature-settings").as_deref(), Some("\"palt\" 1"));
assert_eq!(prop(GlyphForm::Jis78, "font-feature-settings").as_deref(), Some("\"palt\" 1, \"jp78\" 1"));
assert_eq!(prop(GlyphForm::Jis78, "font-kerning"), None);
for form in [GlyphForm::FullWidth, GlyphForm::HalfWidth, GlyphForm::ThirdWidth, GlyphForm::QuarterWidth] {
let tag = std::str::from_utf8(form.feature().unwrap()).unwrap();
assert_eq!(prop(form, "font-feature-settings"), Some(format!("\"{tag}\" 1")), "{form:?}");
assert_eq!(prop(form, "font-kerning").as_deref(), Some("none"), "{form:?}");
}
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ use text::TextSlots;
pub use depth::MAX_ELEMENT_DEPTH;
pub(crate) use depth::xml_too_deep;
pub use files::{MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT};
pub use files::{MAX_DATA_URL_BYTES, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT};
pub(crate) use fx::BLEND;
/// Most objects an imported SVG may expand to: a guard against `<use>` and nested-image
@@ -205,7 +205,7 @@ pub(crate) fn import_finding(
symbol_suffixes: HashMap::new(),
pattern_suffixes: HashMap::new(),
blends: fx::blends(&xml, &scan.markers),
filter_sources: fx::sources(&xml, &scan.markers),
filter_sources: fx::sources(&xml, &scan.markers, |h| files.data_href(h)),
files,
non_scaling: found.non_scaling,
data: found.data,
+388 -33
View File
@@ -11,6 +11,12 @@
//! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag)
//! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as
//! it is shown.
//!
//! usvg decodes a data URL each time it converts the element holding it, a `<use>` copy
//! included, before the resolver sees it. So before usvg reads an SVG (the imported one, and
//! each SVG image), each `<image>` and `<feImage>` href that is a data URL is changed to a key,
//! and the resolver decodes the URL once, the first time a key comes, within what the data URLs
//! may decode to together ([`MAX_DATA_URL_BYTES`]); every later use of it gets the same image.
use std::collections::HashMap;
use std::path::Path;
@@ -34,6 +40,13 @@ pub const MAX_NESTED_SVG_READS: usize = 1024;
/// Most bytes of nested SVG image text an imported SVG may read, decoded or not, all reads together.
pub const MAX_NESTED_SVG_TEXT: u64 = 512 << 20;
/// Most bytes the image data URLs an imported SVG shows may decode to, those in its SVG images
/// included, all together; each URL is decoded once, however often it is shown.
pub const MAX_DATA_URL_BYTES: u64 = 512 << 20;
/// What a data URL's key starts with, the URL's number following it.
const DATA_URL_KEY: &str = "vectorcraft-data-url-";
/// The files an SVG's images link to.
#[derive(Default)]
pub(in crate::import) struct Files {
@@ -74,6 +87,12 @@ pub(in crate::import) struct Files {
/// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got
/// (kept here too, so the address stays its own).
cmyk: Mutex<HashMap<usize, (Bytes, Bytes)>>,
/// The data URLs changed to keys before usvg read them.
data: Mutex<DataUrls>,
/// The bytes the image data URLs decoded to so far, whether or not they are images.
data_bytes: AtomicU64,
/// Images were left out for their data URLs decoding past [`MAX_DATA_URL_BYTES`].
too_much_data: AtomicBool,
/// What usvg makes of the SVG and of the SVG images read so far, all together ([`budget`]):
/// elements, bytes of copied style values and selector tests.
spent: Mutex<budget::Cost>,
@@ -82,6 +101,34 @@ pub(in crate::import) struct Files {
refused: Mutex<Option<SvgError>>,
}
/// The hrefs changed to keys, by number: an href's key is [`DATA_URL_KEY`] and its number.
#[derive(Default)]
struct DataUrls {
/// The number of each href.
numbers: HashMap<Arc<str>, usize>,
/// Each href: a data URL, then what it decoded to, or a link.
urls: Vec<DataUrl>,
/// Each href as it was written, for the filters kept as written ([`Files::data_href`]).
hrefs: Vec<Arc<str>>,
}
enum DataUrl {
Encoded(Arc<str>),
Decoded(Decoded),
/// A link that starts as a key does, looked up as it is.
Link(Arc<str>),
}
/// What a data URL decoded to, as usvg gets it.
#[derive(Clone)]
enum Decoded {
Raster(RasterKind, Bytes),
/// SVG text (unpacked when it was SVGZ), read where it is shown ([`Files::svg`]).
Svg(Arc<str>),
/// Not an image VectorCraft reads, or left out.
Nothing,
}
/// Encoded image bytes, shared.
type Bytes = Arc<Vec<u8>>;
@@ -254,7 +301,7 @@ fn key(bytes: &Arc<Vec<u8>>) -> usize {
impl Files {
/// Read the files the `<image>` elements of `xml` (the text `svg`) link to. Placeholders
/// stretch to the image's box (`preserveAspectRatio="none"`), which the file fills once
/// relinked.
/// relinked. The data URLs of its images are changed to keys ([`Files::take_data_urls`]).
pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec<String>) -> Self {
let mut files = Files::default();
let mut reading = Reading {
@@ -269,9 +316,41 @@ impl Files {
measured: budget::Cost::default(),
};
files.sources = files.links_of(svg, xml, opts.folder, None, edits, &mut reading);
files.take_data_urls(xml, edits);
files
}
/// Change each href of the `<image>` and `<feImage>` elements of `xml` that usvg would decode
/// as a data URL to that URL's key, numbering the URLs not seen before: usvg gives a key to
/// the resolver as it is ([`Files::data_url`]). An href that starts as a key does, a link,
/// gets a key too, which the resolver reads as that link.
fn take_data_urls(&self, xml: &roxmltree::Document, edits: &mut Edits) {
let Ok(mut data) = self.data.lock() else { return };
let hrefs = xml
.descendants()
.filter(|n| n.is_element() && matches!(n.tag_name().name(), "image" | "feImage"))
.flat_map(|n| n.attributes())
.filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink")));
for a in hrefs {
let url = data_url::DataUrl::process(a.value()).is_ok();
if !url && !a.value().starts_with(DATA_URL_KEY) {
continue;
}
let n = match data.numbers.get(a.value()) {
Some(n) => *n,
None => {
let href: Arc<str> = Arc::from(a.value());
let n = data.urls.len();
data.numbers.insert(href.clone(), n);
data.hrefs.push(href.clone());
data.urls.push(if url { DataUrl::Encoded(href) } else { DataUrl::Link(href) });
n
}
};
edits.set_value(a, &format!("{DATA_URL_KEY}{n}"));
}
}
/// What usvg gets for each href the `<image>` elements of `xml` (the text `svg`) link to,
/// relative paths taken from `folder`. `inside` is how deep the SVG images `xml` shows nest in
/// it and the SVGs around it, measured when an SVG file is read (`None` for `xml` itself).
@@ -507,6 +586,9 @@ impl Files {
MAX_NESTED_SVG_TEXT >> 20
));
}
if self.too_much_data.load(Ordering::Relaxed) {
notes.push(format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20));
}
notes
}
@@ -537,47 +619,145 @@ impl Files {
}
}
/// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs as usvg
/// reads them, other hrefs only from the files read. SVG images are read here
/// ([`Files::svg_data`], [`Files::svg`]), never by usvg's own resolver, which caps neither how
/// deep they nest nor what they unpack to.
/// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs by their
/// keys ([`Files::data_url`]), and those usvg decoded itself as it reads them, within
/// [`MAX_DATA_URL_BYTES`]; other hrefs only from the files read. SVG images are read here
/// ([`Files::svg`]), never by usvg's own resolver, which caps neither how deep they nest nor
/// what they unpack to.
pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> {
self.depth.store(height, Ordering::Relaxed);
ImageHrefResolver {
// By type as usvg reads them, and TIFF; one without a type (`text/plain`) by its
// bytes: one of those rasters, else SVG. A raster as [`shown`] gives it.
resolve_data: Box::new(move |mime, bytes, opts| {
use image::ImageFormat as F;
let format = match mime {
"image/jpg" | "image/jpeg" => F::Jpeg,
"image/png" => F::Png,
"image/gif" => F::Gif,
"image/webp" => F::WebP,
"image/tiff" => F::Tiff,
"image/svg+xml" => return self.svg_data(&bytes, opts),
"text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) {
Some(format) => format,
None => return self.svg_data(&bytes, opts),
},
_ => return None,
};
let (kind, bytes) = self.keep(shown(bytes, format)?);
Some(kind(bytes))
let spent = self.data_bytes.load(Ordering::Relaxed).saturating_add(bytes.len() as u64);
if spent > MAX_DATA_URL_BYTES {
return leave_out(&self.too_much_data, true);
}
self.data_bytes.store(spent, Ordering::Relaxed);
self.show(self.decoded(mime, bytes), opts)
}),
resolve_string: Box::new(move |h, opts| match self.find(h)? {
Source::Raster(kind, bytes) => Some(kind(bytes)),
Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts),
resolve_string: Box::new(move |h, opts| {
let link = match self.data_number(h).map(|n| self.data_url(n, opts)) {
Some(Ok(image)) => return image,
Some(Err(link)) => link,
None => Arc::from(h),
};
match self.find(&link)? {
Source::Raster(kind, bytes) => Some(kind(bytes)),
Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts),
}
}),
}
}
/// The SVG image of data `bytes` (SVG or SVGZ), as [`Files::svg`] reads it. Unpacked here,
/// within what the SVGZ images have left, so usvg unpacks nothing itself; past it, `None`,
/// noted.
fn svg_data(&self, bytes: &[u8], opts: &usvg::Options) -> Option<ImageKind> {
/// The number of the href whose key is `h`.
fn data_number(&self, h: &str) -> Option<usize> {
let n: usize = h.strip_prefix(DATA_URL_KEY)?.parse().ok()?;
let known = n < self.data.lock().ok()?.urls.len();
(known && h.len() == DATA_URL_KEY.len() + n.to_string().len()).then_some(n)
}
/// The href whose key is `h`, as it was written: a filter kept as an effect holds its
/// `<feImage>` hrefs as written, not their keys ([`super::fx::sources`]).
pub(in crate::import) fn data_href(&self, h: &str) -> Option<Arc<str>> {
let n = self.data_number(h)?;
self.data.lock().ok()?.hrefs.get(n).cloned()
}
/// The image of data URL `n`: decoded the first time ([`Files::decode`]), then kept; the link
/// when href `n` is one.
fn data_url(&self, n: usize, opts: &usvg::Options) -> Result<Option<ImageKind>, Arc<str>> {
// The lock is let go before the image is shown: an SVG image takes it again.
let state = match self.data.lock().ok().as_deref().and_then(|d| d.urls.get(n)) {
Some(DataUrl::Decoded(decoded)) => Ok(decoded.clone()),
Some(DataUrl::Encoded(url)) => Err(url.clone()),
Some(DataUrl::Link(link)) => return Err(link.clone()),
None => return Ok(None),
};
let url = match state {
Ok(decoded) => return Ok(self.show(decoded, opts)),
Err(url) => url,
};
let decoded = self.decode(&url);
if let Ok(mut data) = self.data.lock()
&& let Some(slot) = data.urls.get_mut(n)
{
*slot = DataUrl::Decoded(decoded.clone());
}
Ok(self.show(decoded, opts))
}
/// What data URL `url` decodes to, as usvg decodes it, in what is left of
/// [`MAX_DATA_URL_BYTES`]: decoding stops where it would go past, the image left out, noted.
fn decode(&self, url: &str) -> Decoded {
let Ok(parsed) = data_url::DataUrl::process(url) else { return Decoded::Nothing };
let spent = self.data_bytes.load(Ordering::Relaxed);
let room = MAX_DATA_URL_BYTES.saturating_sub(spent);
let mut body = Vec::new();
let decoded = parsed.decode(|bytes| {
if (body.len() as u64).saturating_add(bytes.len() as u64) > room {
return Err(());
}
body.extend_from_slice(bytes);
Ok(())
});
self.data_bytes.store(spent.saturating_add(body.len() as u64), Ordering::Relaxed);
match decoded {
Ok(_) => {}
Err(data_url::forgiving_base64::DecodeError::WriteError(())) => {
self.too_much_data.store(true, Ordering::Relaxed);
return Decoded::Nothing;
}
Err(_) => return Decoded::Nothing,
}
let mime = format!("{}/{}", parsed.mime_type().type_, parsed.mime_type().subtype);
self.decoded(&mime, Arc::new(body))
}
/// What usvg gets for data `bytes` of type `mime`: by type as usvg reads them, and TIFF; one
/// without a type (`text/plain`) by its bytes: one of those rasters, else SVG. A raster as
/// [`shown`] gives it; SVG or SVGZ as [`Files::svg_text`] gives it.
fn decoded(&self, mime: &str, bytes: Bytes) -> Decoded {
use image::ImageFormat as F;
let format = match mime {
"image/jpg" | "image/jpeg" => F::Jpeg,
"image/png" => F::Png,
"image/gif" => F::Gif,
"image/webp" => F::WebP,
"image/tiff" => F::Tiff,
"image/svg+xml" => return self.svg_text(&bytes),
"text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) {
Some(format) => format,
None => return self.svg_text(&bytes),
},
_ => return Decoded::Nothing,
};
match shown(bytes, format) {
Some(shown) => {
let (kind, bytes) = self.keep(shown);
Decoded::Raster(kind, bytes)
}
None => Decoded::Nothing,
}
}
/// The text of SVG image data `bytes` (SVG or SVGZ). Unpacked here, within what the SVGZ
/// images have left, so usvg unpacks nothing itself; past it, nothing, noted.
fn svg_text(&self, bytes: &[u8]) -> Decoded {
match crate::text_within(bytes, &self.unpacked) {
Ok(text) => self.svg(&text, None, opts),
Err(e) => leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))),
Ok(text) => Decoded::Svg(Arc::from(text.as_ref())),
Err(e) => {
leave_out(&self.too_big, matches!(e, SvgError::TooBig(_)));
Decoded::Nothing
}
}
}
/// The image usvg gets for `decoded`: an SVG image read here, where it is shown.
fn show(&self, decoded: Decoded, opts: &usvg::Options) -> Option<ImageKind> {
match decoded {
Decoded::Raster(kind, bytes) => Some(kind(bytes)),
Decoded::Svg(text) => self.svg(&text, None, opts),
Decoded::Nothing => None,
}
}
@@ -641,6 +821,9 @@ impl Files {
// usvg's parser would overflow on it, as on the SVG holding it (`import`).
super::convolve_matrices_ok(&xml).ok()?;
self.set_spent(spent.add(cost));
let mut edits = Edits::default();
self.take_data_urls(&xml, &mut edits);
let keyed = edits.apply(text);
self.depth.store(used + height, Ordering::Relaxed);
let within = self.within.lock().ok().map(|mut w| w.push(file)).is_some();
// usvg's `Tree::from_data_nested`, except that hrefs to files go to the resolver.
@@ -659,7 +842,7 @@ impl Files {
},
..usvg::Options::default()
};
let tree = usvg::Tree::from_str(text, &nested);
let tree = usvg::Tree::from_str(&keyed, &nested);
if within && let Ok(mut w) = self.within.lock() {
w.pop();
}
@@ -1096,6 +1279,178 @@ mod tests {
assert_eq!(files.left_out().len(), 2, "{:?}", files.left_out());
}
/// A base64 data URL of `bytes` of type `mime`.
fn data(mime: &str, bytes: &[u8]) -> String {
format!("data:{mime};base64,{}", crate::base64_encode(bytes))
}
/// A PNG of `w` × 1.
fn png_of(w: u32) -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::new(w, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// SVG text of 2 × 2 holding `body`.
fn doc(body: &str) -> String {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2" height="2">{body}</svg>"#)
}
/// `svg` read as the import reads it, its data URLs changed to keys first, `spent` bytes of
/// [`MAX_DATA_URL_BYTES`] already decoded: the files, and the PNG bytes of the images usvg
/// made, filters' included, in order.
fn read_keyed(svg: &str, spent: u64) -> (Files, Vec<Arc<Vec<u8>>>) {
let xml = super::super::parse(svg).unwrap();
let mut edits = Edits::default();
let files = Files::read(svg, &xml, &ImportOptions::default(), &mut edits, &mut vec![]);
files.data_bytes.store(spent, Ordering::Relaxed);
let text = edits.apply(svg).into_owned();
let tree = {
let height = depth::height(&xml, MAX_ELEMENT_DEPTH).unwrap();
let opts = usvg::Options { image_href_resolver: files.resolver(height), ..usvg::Options::default() };
usvg::Tree::from_str(&text, &opts).unwrap()
};
// Filters' images and SVG images' trees are subroots.
fn pngs(g: &usvg::Group, out: &mut Vec<Arc<Vec<u8>>>) {
for n in g.children() {
match n {
usvg::Node::Group(g) => pngs(g, out),
usvg::Node::Image(im) => {
if let ImageKind::PNG(b) = im.kind() {
out.push(b.clone());
}
}
_ => {}
}
n.subroots(|g| pngs(g, out));
}
}
let mut out = vec![];
pngs(tree.root(), &mut out);
(files, out)
}
/// A data URL shown by many `<use>` elements is decoded once, and every use gets the same
/// image, where usvg decoded it again for each.
#[test]
fn a_data_url_is_decoded_once_however_often_it_is_used() {
let png = png();
let uses = r##"<use href="#p"/>"##.repeat(64);
let svg = doc(&format!(r#"<defs><image id="p" href="{}" width="1" height="1"/></defs>{uses}"#, data("image/png", &png)));
let (files, shown) = read_keyed(&svg, 0);
assert_eq!(shown.len(), 64);
assert!(shown.iter().all(|b| Arc::ptr_eq(b, &shown[0]) && **b == png));
assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64);
assert!(files.left_out().is_empty(), "{:?}", files.left_out());
}
/// The data URLs inside an SVG image get keys too: each is decoded once, together with the
/// SVG image's own URL.
#[test]
fn data_urls_in_svg_images_are_decoded_once() {
let png = png();
let uses = r##"<use href="#p"/>"##.repeat(16);
let inner = doc(&format!(r#"<defs><image id="p" href="{}" width="1" height="1"/></defs>{uses}"#, data("image/png", &png)));
let svg = doc(&format!(r#"<image href="{}" width="2" height="2"/>"#, data("image/svg+xml", inner.as_bytes())));
let (files, shown) = read_keyed(&svg, 0);
assert_eq!(shown.len(), 16);
assert_eq!(files.data_bytes.load(Ordering::Relaxed), (inner.len() + png.len()) as u64);
}
/// An `<feImage>`'s data URL gets a key too.
#[test]
fn fe_image_data_urls_are_decoded_once() {
let png = png();
let rects: String = (1..=8).map(|i| format!(r#"<rect width="{i}" height="1" filter="url(#f)"/>"#)).collect();
let svg = doc(&format!(r#"<filter id="f"><feImage href="{}"/></filter>{rects}"#, data("image/png", &png)));
let (files, shown) = read_keyed(&svg, 0);
assert_eq!(shown.len(), 8);
assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64);
}
/// Both hrefs get keys, and usvg still takes the plain `href` over `xlink:href`, in either
/// order: only its URL is decoded.
#[test]
fn the_plain_href_still_wins_over_xlink_href() {
let (one, two) = (png_of(1), png_of(2));
let (one_url, two_url) = (data("image/png", &one), data("image/png", &two));
for image in [
format!(r#"<image href="{one_url}" xlink:href="{two_url}" width="1" height="1"/>"#),
format!(r#"<image xlink:href="{two_url}" href="{one_url}" width="1" height="1"/>"#),
] {
let (files, shown) = read_keyed(&doc(&image), 0);
assert_eq!(shown.len(), 1);
assert_eq!(*shown[0], one);
assert_eq!(files.data_bytes.load(Ordering::Relaxed), one.len() as u64);
}
}
/// An image whose data URL is written in an entity, and so in every element the entity
/// makes, gets a key too.
#[test]
fn data_urls_written_in_entities_are_decoded_once() {
let png = png();
let uses = r##"<use href="#p"/>"##.repeat(16);
let svg = format!(
r#"<!DOCTYPE svg [<!ENTITY img "<image id='p' href='{}' width='1' height='1'/>">]>{}"#,
data("image/png", &png),
doc(&format!("<defs>&img;</defs>{uses}"))
);
let (files, shown) = read_keyed(&svg, 0);
assert_eq!(shown.len(), 16);
assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64);
}
/// The data URLs decode to at most [`MAX_DATA_URL_BYTES`] together: a URL that fits what is
/// left is decoded, and decoding stops where a URL would go past it, that image left out and
/// noted.
#[test]
fn data_urls_past_the_limit_are_left_out() {
let (small, big) = (png(), png_of(64));
assert!(small.len() < big.len());
let svg = doc(&format!(
r#"<image href="{}" width="1" height="1"/><image href="{}" width="1" height="1"/>"#,
data("image/png", &small),
data("image/png", &big)
));
let spent = MAX_DATA_URL_BYTES - (small.len() + big.len()) as u64 + 1;
let (files, shown) = read_keyed(&svg, spent);
assert_eq!(shown.len(), 1);
assert_eq!(*shown[0], small);
assert!(files.data_bytes.load(Ordering::Relaxed) <= MAX_DATA_URL_BYTES);
assert_eq!(files.left_out(), [format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20)]);
// A data URL usvg decodes itself counts too.
let files = Files::default();
files.data_bytes.store(MAX_DATA_URL_BYTES, Ordering::Relaxed);
let resolver = files.resolver(1);
assert!((resolver.resolve_data)("image/png", Arc::new(small.clone()), &usvg::Options::default()).is_none());
drop(resolver);
assert_eq!(files.left_out().len(), 1);
}
/// A link that looks like a key, spelled plainly or with character references, in the
/// imported SVG or in an SVG image, stays that link: it never shows a data URL's image.
#[test]
fn links_that_look_like_keys_stay_links() {
let png = png();
let url = data("image/png", &png);
let inner = doc(&format!(r#"<image href="{DATA_URL_KEY}0" width="1" height="1"/>"#));
let svg = doc(&format!(
r#"<image href="{url}" width="1" height="1"/><image href="{DATA_URL_KEY}0" width="1" height="1"/><image href="&#118;{}0" width="1" height="1"/><image href="{}" width="2" height="2"/>"#,
&DATA_URL_KEY[1..],
data("image/svg+xml", inner.as_bytes())
));
let (files, shown) = read_keyed(&svg, 0);
// The data URL's image, then the placeholder of the missing file, twice; the SVG image's
// link names no file there, so it shows nothing.
assert_eq!(shown.len(), 3);
assert_eq!(*shown[0], png);
assert!(shown[1..].iter().all(|b| **b != png && files.links.get(&key(b)).is_some_and(|l| l.missing)));
assert_eq!(files.data_number(&format!("{DATA_URL_KEY}00")), None);
assert_eq!(files.data_number(&format!("{DATA_URL_KEY}+0")), None);
assert_eq!(files.data_number(&format!("{DATA_URL_KEY}9")), None);
}
/// The nested SVG images read, decoded or not, take at most [`MAX_NESTED_SVG_TEXT`] bytes of
/// text together: an image whose text goes past it is left out and noted. A broken image's
/// text counts too, and its decoded place is given back.
+32 -3
View File
@@ -61,8 +61,10 @@ impl Source {
/// Each `<filter>` element with an id, as written, by id, with the document's style sheets read
/// once for all of them. A source is checked and read by usvg only when art uses it, once.
/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]).
pub(super) fn sources(xml: &roxmltree::Document, markers: &str) -> HashMap<String, Source> {
/// `markers` is the text with its data URLs left out ([`super::depth::prescan`]). `href` gives the
/// href, as written, of each key the prepass wrote in place of an `<feImage>` href
/// ([`super::files::Files::data_href`]): the source holds that href.
pub(super) fn sources(xml: &roxmltree::Document, markers: &str, href: impl Fn(&str) -> Option<Arc<str>>) -> HashMap<String, Source> {
if !markers.contains("<filter") && !markers.contains(":filter") {
return HashMap::new();
}
@@ -74,13 +76,40 @@ pub(super) fn sources(xml: &roxmltree::Document, markers: &str) -> HashMap<Strin
.filter_map(|(order, n)| {
let id = n.attribute("id")?.to_string();
let primitives = n.children().filter(roxmltree::Node::is_element).map(|p| p.tag_name().name().to_string()).collect();
let xml = vectorcraft_effects::svg_filter::element_xml(n, text)?;
let xml = hrefs_as_written(n, vectorcraft_effects::svg_filter::element_xml(n, text)?, &href);
let styled = vectorcraft_effects::svg_filter::depends_on_styles(n, &styles);
Some((id, Source { xml, primitives, order, styled, ..Default::default() }))
})
.collect()
}
/// `xml`, the text of filter `n` ([`vectorcraft_effects::svg_filter::element_xml`]), with each
/// `<feImage>` href that `href` gives a written href for put back as written, XML-escaped.
fn hrefs_as_written(n: roxmltree::Node, mut xml: String, href: &impl Fn(&str) -> Option<Arc<str>>) -> String {
// `element_xml` adds namespace declarations to the start tag only, ahead of every child.
let (start, end) = (n.range().start, n.range().end);
let Some(shift) = xml.len().checked_sub(end - start) else { return xml };
let mut written: Vec<_> = n
.descendants()
.filter(|p| p.is_element() && p.tag_name().name() == "feImage")
.flat_map(|p| p.attributes())
.filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink")))
.filter_map(|a| {
let r = a.range_value();
// A value written through an entity lies outside the element's text: it stays.
(r.start > start && r.end < end).then_some(())?;
Some((r.start - start + shift..r.end - start + shift, a.value(), href(a.value())?))
})
.collect();
written.sort_by_key(|(r, ..)| std::cmp::Reverse(r.start));
for (r, key, h) in written {
if xml.get(r.clone()) == Some(key) {
xml.replace_range(r, &crate::xml_escape(&h));
}
}
xml
}
/// The element name of primitive `k` (`feGaussianBlur`).
fn element_name(k: &Kind) -> String {
let debug = format!("{k:?}");
+8 -2
View File
@@ -95,6 +95,11 @@ impl Edits {
}
}
/// Set the value of attribute `a` to `value` (XML-escaped already).
pub(crate) fn set_value(&mut self, a: roxmltree::Attribute, value: &str) {
self.list.push((a.range_value(), value.to_string()));
}
/// Add the declaration `decl` (XML-escaped already) to the `style` of element `n` (in `svg`),
/// after the ones already there (so it wins over them and over style sheet rules).
fn style(&mut self, svg: &str, n: XNode, decl: &str) {
@@ -163,7 +168,8 @@ fn named_made_up_ids(xml: &roxmltree::Document) -> HashSet<usize> {
/// * so do `<circle>` and `<ellipse>` elements, recorded: usvg makes their arcs into a varying
/// number of curves, and they come back as the ellipse VectorCraft draws (not in a file whose
/// style sheet has an attribute selector);
/// * linked files are read ([`files`]);
/// * linked files are read, and the data URLs of `<image>` and `<feImage>` elements are each
/// changed to a key that the resolver decodes once ([`files`]);
/// * percentage lengths in a `<symbol>` with a `viewBox` become numbers ([`symbol_units`]);
/// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]).
///
@@ -190,7 +196,7 @@ pub(super) fn prepass<'s>(
&& !events
&& !foreign_attrs
&& !ellipses
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "class", "groupmode", "script"]
&& !["<a", "display", "<use", ":use", "<image", ":image", "feImage", vector_effect::NON_SCALING, "data-", "class", "groupmode", "script"]
.iter()
.any(|t| markers.contains(t))
{
+4 -1
View File
@@ -533,7 +533,10 @@ pub fn svg_filters(svg: &str, room: usize, taken: impl Fn(&str) -> bool) -> Resu
}
pub use glyph::glyph_art;
pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, MAX_NODES};
pub use import::{
MAX_DATA_URL_BYTES, MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT,
MAX_NODES,
};
/// Import an SVG document reading the files it links to as `opts` says, also returning warnings.
pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
+15
View File
@@ -949,6 +949,21 @@ fn nested_svg_images_past_count_cap_are_left_out() {
assert!(warnings.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images"))));
}
/// A data URL shown by `<use>` elements, in the SVG and in an SVG image of it, imports as one
/// image shown at each use.
#[test]
fn data_url_images_shown_by_uses_import_at_each_use() {
let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==";
let image = format!(r#"<image id="p" href="data:image/png;base64,{png}" width="4" height="4"/>"#);
let uses = r##"<use href="#p" x="5"/>"##.repeat(3);
let inner = format!(r#"<svg {SVG_NS} width="20" height="10"><defs>{image}</defs>{uses}</svg>"#);
let (d, w) = import_with_report(&svg(&format!("<defs>{image}</defs>{uses}{}", image_of("image/svg+xml", &inner)))).unwrap();
assert!(w.is_empty(), "{w:?}");
let found = images(&d);
assert_eq!(found.len(), 6);
assert!(found.iter().all(|(_, im)| im.key == found[0].1.key && (im.width, im.height) == (1, 1)));
}
/// Nested SVG images that can't be read: text that isn't XML, and an SVG without a size.
const BROKEN_SVG_IMAGES: [&str; 2] =
[r#"<svg xmlns="http://www.w3.org/2000/svg" width="10"#, r#"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"/>"#];
+49 -3
View File
@@ -295,7 +295,8 @@ impl OtFeatures {
let own = self.with_tags(listed.iter().map(String::as_str));
let s = &own;
let mut v = Vec::with_capacity(8);
if st.kerning.is_some() {
// A monospaced width form turns the font's pair kerning off as well.
if st.kerning.is_some() || st.glyph_form.is_width() {
v.push(f(b"kern", false));
}
let liga = s.ligatures && (!ligatures_suppressed_by(st.tracking) || explicit_ligatures(listed));
@@ -342,15 +343,20 @@ impl OtFeatures {
// Kerning for the glyphs `palt` makes proportional (OpenType `apkn`), with automatic
// kerning only.
v.push(f(b"apkn", st.kerning.is_none()));
} else if st.glyph_form.is_width() {
// Listed as off, not left out: in runs shaped together with proportional text, a
// feature left out is set back to the shaper's default, which for `apkn` is on.
v.push(f(b"apkn", false));
}
v
}
/// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in
/// horizontal type; vertical type takes proportional heights instead
/// horizontal type, except with a monospaced width form ([`vectorcraft_doc::GlyphForm::is_width`]),
/// which sets the characters on a fixed width; vertical type takes proportional heights instead
/// ([`Self::proportional_vertical`]).
pub(crate) fn proportional(&self, st: &CharStyle) -> bool {
st.proportional_metrics && !self.vertical
st.proportional_metrics && !self.vertical && !st.glyph_form.is_width()
}
/// Does text in style `st` take proportional heights (`vpal`)? Proportional Metrics in
@@ -630,4 +636,44 @@ mod tests {
assert!(serbian.iter().zip(&russian).all(|(s, r)| s != r), "every letter in its Serbian form");
assert_eq!(outlines("sr-Latn"), serbian);
}
/// A monospaced width form (`fwid`, `hwid`, `twid`, `qwid`) and Proportional Metrics' `palt`
/// are mutually exclusive: with a width form `palt` is left out and the font's pair kerning
/// (`kern`, and `apkn` for `palt`'s glyphs) is turned off, with automatic or manual kerning,
/// and with the other forms they stay.
/// <https://learn.microsoft.com/en-us/typography/opentype/spec/features_fj#tag-fwid>
#[test]
fn width_forms_turn_off_proportional_metrics() {
use vectorcraft_doc::GlyphForm;
let tags = |form, kerning, value| {
let st = CharStyle { proportional_metrics: true, glyph_form: form, kerning, ..CharStyle::default() };
OtFeatures::default().resolve(&st).iter().filter(|f| f.value == value).map(|f| f.tag.to_string()).collect::<Vec<_>>()
};
for form in [GlyphForm::Default, GlyphForm::Traditional, GlyphForm::Jis78] {
assert!(tags(form, None, 1).contains(&"palt".to_string()), "{form:?}: {:?}", tags(form, None, 1));
assert!(!tags(form, None, 0).contains(&"kern".to_string()), "{form:?}: {:?}", tags(form, None, 0));
}
for form in [GlyphForm::FullWidth, GlyphForm::HalfWidth, GlyphForm::ThirdWidth, GlyphForm::QuarterWidth] {
for kerning in [None, Some(0.0)] {
let on = tags(form, kerning, 1);
assert!(!on.contains(&"palt".to_string()) && !on.contains(&"apkn".to_string()), "{form:?}: {on:?}");
assert!(on.iter().any(|t| t.as_bytes() == form.feature().unwrap()), "{form:?}: {on:?}");
// Listed as off, so that runs shaped together with proportional text keep them off.
let off = tags(form, kerning, 0);
assert!(off.contains(&"kern".to_string()) && off.contains(&"apkn".to_string()), "{form:?} {kerning:?}: {off:?}");
}
}
}
/// A face's `GPOS` features are found apart from its `GSUB` ones: Source Sans 3 kerns with
/// `GPOS` and makes ligatures with `GSUB`.
#[test]
fn positioning_and_substitution_features_are_found_in_their_own_tables() {
let face = FontDb::global().face("Source Sans 3", "Regular").unwrap();
assert!(face.has_positioning_feature(*b"kern"));
assert!(!face.has_substitution_feature(*b"kern"));
assert!(face.has_substitution_feature(*b"liga"));
assert!(!face.has_positioning_feature(*b"liga"));
assert!(!face.has_positioning_feature(*b"fwid"));
}
}
+20 -7
View File
@@ -158,6 +158,17 @@ fn ligature_glyphs(t: &LigatureSubstFormat1<'_>, room: usize) -> Vec<u32> {
out
}
/// Does `features` (a `GSUB` or `GPOS` feature list) have feature `tag` with at least one lookup?
/// At most [`MAX_FEATURES`] feature records are read.
fn has_feature(features: &skrifa::raw::tables::layout::FeatureList<'_>, tag: [u8; 4]) -> bool {
features
.feature_records()
.iter()
.take(MAX_FEATURES)
.filter(|r| r.feature_tag().to_be_bytes() == tag)
.any(|r| r.feature(features.offset_data()).is_ok_and(|f| !f.lookup_list_indices().is_empty()))
}
impl GsubIndex {
/// Read `face`'s `GSUB` table. Empty when the face has none or it cannot be read; glyph ids past
/// the face's glyph count are left out; the caps above bound what a damaged table makes it read.
@@ -309,13 +320,15 @@ impl FontFace {
/// cannot be read.
pub fn has_substitution_feature(&self, tag: [u8; 4]) -> bool {
let Some(font) = self.skrifa() else { return false };
let Some(features) = font.gsub().ok().and_then(|g| g.feature_list().ok()) else { return false };
features
.feature_records()
.iter()
.take(MAX_FEATURES)
.filter(|r| r.feature_tag().to_be_bytes() == tag)
.any(|r| r.feature(features.offset_data()).is_ok_and(|f| !f.lookup_list_indices().is_empty()))
font.gsub().ok().and_then(|g| g.feature_list().ok()).is_some_and(|features| has_feature(&features, tag))
}
/// Does the face's `GPOS` table have feature `tag` with at least one lookup? At most
/// [`MAX_FEATURES`] feature records are read; false when the face has no `GPOS` table or it
/// cannot be read.
pub fn has_positioning_feature(&self, tag: [u8; 4]) -> bool {
let Some(font) = self.skrifa() else { return false };
font.gpos().ok().and_then(|g| g.feature_list().ok()).is_some_and(|features| has_feature(&features, tag))
}
/// What the features `aalt`, `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99`, `hist`,
+2 -1
View File
@@ -1545,7 +1545,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
char_level.set(false);
*chars = crate::harakat::own_chars(raw, &by_char, range.end, text, |gid| classes.get(skrifa::GlyphId::new(gid)) == 3);
}
if feats.proportional(st) {
// Runs shaped together may differ in their glyph form, and a width form turns `palt` off.
if feats.proportional(st) || runs.get(source_runs.clone()).unwrap_or_default().iter().any(|(_, s)| feats.proportional(s)) {
// `palt` is positioning only: the same glyphs set on their full widths tell which
// advances it changed (kerning and the rest change both alike).
let full = shape(&with_more(&|s| feats.resolve_fixed_width(s)), along);
+29 -1
View File
@@ -14,7 +14,7 @@ use vectorcraft_geom::{Affine, Anchor, AnchorKind, BezPath, PathData, Point, Sub
use vectorcraft_pathops as po;
use vectorcraft_pathops::{Cap, Join};
use crate::{Action, Tool, ToolContext};
use crate::{Action, PenPose, Tool, ToolContext};
pub use anchor::AnchorTool;
pub use curvature::{
@@ -87,6 +87,34 @@ pub fn pressure_size(size: f64, variation: f64, pressure: f64) -> f64 {
/// The narrowest a Blob Brush or Eraser stroke gets, in points (their Size field's minimum).
pub const MIN_BRUSH_SIZE: f64 = 0.1;
/// A pen's tilt as a lean on the page (each tilt's tangent) and its angle from upright in degrees,
/// as Pointer Events' tiltX and tiltY convert to an altitude and an azimuth.
fn pen_lean(tilt: [f32; 2]) -> ([f64; 2], f64) {
let [x, y] = tilt.map(|d| f64::from(d).to_radians().tan());
([x, y], x.hypot(y).atan().to_degrees())
}
/// Which way pose `q` leans, in degrees counter-clockwise on the page from leaning to the right,
/// when it leans 1° or more from upright (`path.eraseRegion` keeps the bearing before a pen
/// nearer upright than that).
pub(crate) fn pen_bearing(q: PenPose) -> Option<f64> {
let ([x, y], from_upright) = pen_lean(q.tilt?);
(from_upright >= 1.0).then(|| (-y).atan2(x).to_degrees())
}
/// The 0..1 that each of the Eraser's variation modes Pressure, Tilt, Bearing and Rotation reads
/// from a pen at `pressure` (0..1) held as `pose`, leaning toward `bearing` (degrees, from
/// [`pen_bearing`]): 0 gives the value less its variation and 1 the value plus it, as
/// `path.eraseRegion` reads a stroke's points. Tilt reads 1 upright and 0 lying flat; Bearing and
/// Rotation read ½ plus the angle over 360°, the rotation turned counter-clockwise. What the pen
/// does not give reads ½, the value itself.
pub(crate) fn pen_controls(pressure: f64, pose: Option<PenPose>, bearing: Option<f64>) -> [f64; 4] {
let tilt = pose.and_then(|q| q.tilt).map_or(0.5, |t| 1.0 - pen_lean(t).1 / 90.0);
let rotation = pose.and_then(|q| q.rotation).map_or(0.5, |r| 0.5 + vectorcraft_geom::normalize_deg(-f64::from(r)) / 360.0);
let bearing = bearing.map_or(0.5, |b| 0.5 + b / 360.0);
[pressure, tilt, bearing, rotation].map(|c| if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 })
}
/// Create a drawing tool by id (None = not one of ours).
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
+147 -32
View File
@@ -12,7 +12,10 @@
//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes, and
//! the pen's tilt and barrel rotation where the pen reads them (`[x, y, pressure, tiltX, tiltY,
//! rotation]` points, `null` for what a point lacks) for brushes that vary with them. The Blob
//! Brush and Eraser also record pressure when their Size varies with Pressure. The other tools
//! Brush and Eraser also record pressure when their Size varies with Pressure, and the Eraser
//! records the pressure, tilt and rotation that its Angle, Roundness and Size variations follow;
//! while it erases, its tip outline at the pointer takes the angle, roundness and size the pen
//! gives there. The other tools
//! collect the drag polyline (shown as an overlay) and run one command on release. With Shift
//! held, the Blob Brush's points stay on the horizontal or
//! vertical line through the press point. Alt held at the press, or before the Blob Brush's drag
@@ -29,6 +32,7 @@ use std::cell::Cell;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use vectorcraft_brush::{Calligraphic, Variation};
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, ParamCurve, PathData, PathSeg, Point, Rect, Vec2, shapes};
use vectorcraft_pathops as po;
@@ -88,10 +92,11 @@ pub struct GestureTool {
previewed: usize,
id: &'static str,
points: Vec<Point>,
/// The pen pressure of each of `points` (the Paintbrush, and the Blob Brush and the Eraser
/// with `size_pressure`).
/// The pen pressure of each of `points` (the Paintbrush, the Blob Brush with Size on Pressure,
/// and the Eraser with any of its variations on Pressure).
pressures: Vec<f32>,
/// The pen's tilt and rotation at each of `points` (the Paintbrush only).
/// The pen's tilt and rotation at each of `points` (the Paintbrush, and the Eraser with any of
/// its variations on Tilt, Bearing or Rotation).
poses: Vec<Option<PenPose>>,
active: bool,
began: bool,
@@ -117,16 +122,22 @@ pub struct GestureTool {
pub fidelity: f64,
/// Brush / eraser diameter in points (the Eraser's across its tip's long axis).
pub size: f64,
/// Blob Brush and Eraser Tool Options' Size set to Pressure (else Fixed): the tip is `size` −
/// `size_variation` wide at the lightest pen pressure and `size` + `size_variation` at the
/// heaviest.
pub size_pressure: bool,
/// Blob Brush and Eraser Tool Options' Size mode: Fixed or Pressure for the Blob Brush (with
/// Pressure the tip is `size` − `size_variation` wide at the lightest pen pressure and `size` +
/// `size_variation` at the heaviest), and any [`Variation`] for the Eraser.
pub size_mode: Variation,
/// Blob Brush and Eraser Tool Options' Size Variation, in points.
pub size_variation: f64,
/// The angle of the Blob Brush or Eraser tip's long axis, in degrees counter-clockwise from horizontal.
pub angle: f64,
/// The Blob Brush or Eraser tip's short axis in percent of its long one (100: round).
pub roundness: f64,
/// Eraser Tool Options' Angle mode and its Variation in degrees (0 to 180).
pub angle_mode: Variation,
pub angle_variation: f64,
/// Eraser Tool Options' Roundness mode and its Variation in percent (0 to 100).
pub roundness_mode: Variation,
pub roundness_variation: f64,
/// Fill new pencil/brush strokes.
pub fill: bool,
/// "Keep selected": select the path a Pencil, Paintbrush or Blob Brush stroke draws, continues
@@ -176,6 +187,9 @@ pub struct GestureTool {
tip_at: Option<Point>,
/// A pen pressed less than fully at a point of the stroke whose pressure is kept.
light: bool,
/// Which way the pen leaned at the last point of the stroke where it leaned 1° or more from
/// upright ([`super::pen_bearing`]), for the Eraser's tip outline.
bearing: Option<f64>,
/// The length of the stroke, in points.
length: f64,
/// The outline of the area a Blob Brush or Eraser drag covers, as far as it is built
@@ -829,10 +843,14 @@ impl GestureTool {
redraw: None,
fidelity,
size: 10.0,
size_pressure: false,
size_mode: Variation::Fixed,
size_variation: 0.0,
angle: 0.0,
roundness: 100.0,
angle_mode: Variation::Fixed,
angle_variation: 0.0,
roundness_mode: Variation::Fixed,
roundness_variation: 0.0,
fill: false,
keep_selected: id != "blobBrush",
// Off, as Pencil Tool Options ships it: Alt keeps drawing a closed path until it is on.
@@ -852,6 +870,7 @@ impl GestureTool {
pressed: Point::ZERO,
tip_at: None,
light: false,
bearing: None,
length: 0.0,
area: Cell::new(None),
area_pending: Cell::new(false),
@@ -908,15 +927,40 @@ impl GestureTool {
self.length += last.distance(p);
}
self.points.push(p);
if self.id == "paintbrush" || (self.varies_size() && self.size_pressure) {
let modes = self.tip_modes();
// The poses go out beside the pressures (`stroke_json`), so a stroke that keeps one keeps both.
let posed = self.id == "paintbrush" || modes.iter().any(|m| matches!(m, Variation::Tilt | Variation::Bearing | Variation::Rotation));
if posed || modes.contains(&Variation::Pressure) {
self.pressures.push(ev.pressure);
self.light |= ev.pressure < 1.0;
}
if self.id == "paintbrush" {
self.poses.push(ev.pen.map(PenPose::sanitized));
if posed {
let pose = ev.pen.map(PenPose::sanitized);
self.bearing = pose.and_then(super::pen_bearing).or(self.bearing);
self.poses.push(pose);
}
}
/// Give the Eraser's last stroke sample the pen's tilt and rotation of the event `ev`, a drag
/// within a pixel of that sample that adds none: a pen turned or tilted in place turns its tip
/// there, in the outline and in what the release erases. The sample keeps its pressure, which
/// the area outline of the drag was built with, and its pose when `ev` reads none.
fn refresh_last_pose(&mut self, ev: &PointerEvent) {
if self.id != "eraser" || self.poses.len() != self.points.len() || ev.pen.is_none() {
return;
}
let pose = ev.pen.map(PenPose::sanitized);
let Some(last) = self.poses.last_mut() else { return };
let had_bearing = last.and_then(super::pen_bearing).is_some();
*last = pose;
// The bearing is that of the last sample the pen leaned at, as `push` keeps it.
self.bearing = match pose.and_then(super::pen_bearing) {
Some(b) => Some(b),
None if had_bearing => self.poses.iter().rev().find_map(|q| q.and_then(super::pen_bearing)),
None => self.bearing,
};
}
/// Would a press with `m` held draw a straight Pencil line? Shift or Alt held, unless Alt lends
/// the Smooth tool: the rule `PointerKind::Down` sets `straight` by.
fn straight_at_press(&self, m: Mods) -> bool {
@@ -975,12 +1019,14 @@ impl GestureTool {
}
/// The Blob Brush's or the Eraser's tip at the pointer, before and while painting: its outline
/// `size` across at its angle and roundness, at the zoom, black on a white halo so it reads on
/// `size` across at its angle and roundness (the Eraser's as the pen varies them while it
/// erases, [`Self::tip_now`]), at the zoom, black on a white halo so it reads on
/// any art. Precise cursors show only the crosshair, and smoothing shows no tip.
fn tip_outline(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some(at) = self.tip_at.filter(|_| !self.smoothing && !cx.precise_cursors) else { return vec![] };
let tip = if self.id == "blobBrush" { blob_tip(self.angle, self.roundness) } else { eraser_tip(self.angle, self.roundness) };
let r = self.size / 2.0;
let [angle, roundness, size] = self.tip_now();
let tip = if self.id == "blobBrush" { blob_tip(angle, roundness) } else { eraser_tip(angle, roundness) };
let r = size / 2.0;
// A tolerance relative to the radius: four arcs at any size or zoom.
let circle = kurbo::Shape::to_path(&kurbo::Circle::new(Point::ORIGIN, r), 0.1 * r.max(1e-6));
let path = Affine::translate(at.to_vec2()) * tip * circle;
@@ -999,6 +1045,7 @@ impl GestureTool {
self.previewed = 0;
self.shift_line = None;
self.light = false;
self.bearing = None;
self.length = 0.0;
self.area.set(None);
self.area_pending.set(false);
@@ -1064,7 +1111,53 @@ impl GestureTool {
/// keeps its Size).
fn size_pressures(&self) -> Option<&[f32]> {
let pressed = self.pressures.len() == self.points.len() && self.light;
(self.varies_size() && self.size_pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice())
(self.tip_modes()[2] == Variation::Pressure && !self.smoothing && pressed).then_some(self.pressures.as_slice())
}
/// How the tip's angle, roundness and size vary: the Eraser's three modes, the Blob Brush's
/// Size mode (its angle and roundness stay fixed), and Fixed for the other tools. A mode whose
/// Variation is 0 is Fixed.
fn tip_modes(&self) -> [Variation; 3] {
let fixed_at_zero = |mode: Variation, v: f64| if v > 0.0 { mode } else { Variation::Fixed };
match self.id {
"eraser" => [
fixed_at_zero(self.angle_mode, self.angle_variation),
fixed_at_zero(self.roundness_mode, self.roundness_variation),
fixed_at_zero(self.size_mode, self.size_variation),
],
"blobBrush" => [Variation::Fixed, Variation::Fixed, fixed_at_zero(self.size_mode, self.size_variation)],
_ => [Variation::Fixed; 3],
}
}
/// The Eraser's angle, roundness and size where its tip outline shows: while it erases, each
/// value on a mode that follows the pen goes from the value less its Variation to the value
/// plus it as the pen reads now (the pressure once the pen has pressed less than fully, the
/// tilt and rotation of the last point, and the bearing of the last point the pen leaned 1° or
/// more at), as `path.eraseRegion` reads the stroke; before a press, and for Random, Fixed and a
/// pen that gives no reading, the values themselves. The Blob Brush's are its own values.
fn tip_now(&self) -> [f64; 3] {
let base = [self.angle, self.roundness, self.size];
if self.id != "eraser" || !self.active {
return base;
}
let pressure = if self.light { self.pressures.last().map_or(0.5, |p| f64::from(*p)) } else { 0.5 };
let controls = super::pen_controls(pressure, self.poses.last().copied().flatten(), self.bearing);
let variations = [self.angle_variation, self.roundness_variation, self.size_variation];
let mut out = base;
for (i, (v, mode)) in out.iter_mut().zip(self.tip_modes()).enumerate() {
let c = match mode {
Variation::Pressure => controls[0],
Variation::Tilt => controls[1],
Variation::Bearing => controls[2],
Variation::Rotation => controls[3],
Variation::Fixed | Variation::Random => continue,
};
// The size goes past a nib's 1296 pt, as `path.eraseRegion` sweeps it.
*v = if i == 2 { super::pressure_size(*v, variations[i], c) } else { Calligraphic::moved(i, *v, variations[i] * (2.0 * c - 1.0)) };
}
let [angle, roundness, size] = out;
[angle, roundness.clamp(1.0, 100.0), size.max(super::MIN_BRUSH_SIZE)]
}
/// The stroke's points: `[x, y, pressure, tiltX, tiltY, rotation]` when a pen read its tilt or
@@ -1254,7 +1347,7 @@ impl GestureTool {
json!({
"points": self.stroke_json(),
"size": self.size,
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
"sizeMode": self.size_mode.id(),
"sizeVariation": self.size_variation,
"fidelity": self.fidelity,
"angle": self.angle,
@@ -1268,10 +1361,14 @@ impl GestureTool {
json!({
"points": self.stroke_json(),
"size": self.size,
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
"sizeMode": self.size_mode.id(),
"sizeVariation": self.size_variation,
"angle": self.angle,
"roundness": self.roundness
"angleMode": self.angle_mode.id(),
"angleVariation": self.angle_variation,
"roundness": self.roundness,
"roundnessMode": self.roundness_mode.id(),
"roundnessVariation": self.roundness_variation
}),
)],
"knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))],
@@ -1385,6 +1482,9 @@ impl Tool for GestureTool {
let release_end = kept.unwrap_or(end);
self.near_start = self.release_closes_near_start(cx, release_end);
self.connects = self.connects_at(cx, release_end).is_some();
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
self.refresh_last_pose(ev);
}
return vec![];
}
self.push(p, ev);
@@ -1438,13 +1538,17 @@ impl Tool for GestureTool {
}
} else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
self.push(end, ev);
} else if self.grid_snaps(cx)
&& !self.smoothing
&& self.points.len() > 1
&& let Some(last) = self.points.last_mut()
{
// The last step was where the button went up: it moves where that snaps.
*last = end;
} else {
// A release within a pixel of the last step gives it the pen's pose there.
self.refresh_last_pose(ev);
if self.grid_snaps(cx)
&& !self.smoothing
&& self.points.len() > 1
&& let Some(last) = self.points.last_mut()
{
// The last step was where the button went up: it moves where that snaps.
*last = end;
}
}
let out = if cancel { vec![Action::Cancel] } else { self.finish(cx, ev.mods) };
self.active = false;
@@ -1666,7 +1770,7 @@ impl Tool for GestureTool {
json!({
"fidelity": self.fidelity,
"size": self.size,
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
"sizeMode": self.size_mode.id(),
"sizeVariation": self.size_variation,
"angle": self.angle,
"roundness": self.roundness,
@@ -1676,10 +1780,14 @@ impl Tool for GestureTool {
}
"eraser" => json!({
"size": self.size,
"sizeMode": if self.size_pressure { "pressure" } else { "fixed" },
"sizeMode": self.size_mode.id(),
"sizeVariation": self.size_variation,
"angle": self.angle,
"roundness": self.roundness
"angleMode": self.angle_mode.id(),
"angleVariation": self.angle_variation,
"roundness": self.roundness,
"roundnessMode": self.roundness_mode.id(),
"roundnessVariation": self.roundness_variation
}),
_ => Value::Null,
}
@@ -1688,12 +1796,19 @@ impl Tool for GestureTool {
match key {
"fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(super::min_fidelity(self.id), 100.0),
"size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0),
"sizeMode" if self.varies_size() => match v.as_str() {
Some("pressure") => self.size_pressure = true,
Some("fixed") => self.size_pressure = false,
// The Blob Brush's Size is Fixed or Pressure; the Eraser's Angle, Roundness and Size
// take any mode.
"sizeMode" if self.varies_size() => match v.as_str().and_then(Variation::parse) {
Some(m) if self.id == "eraser" || matches!(m, Variation::Fixed | Variation::Pressure) => self.size_mode = m,
_ => {}
},
"angleMode" if self.id == "eraser" => self.angle_mode = v.as_str().and_then(Variation::parse).unwrap_or(self.angle_mode),
"roundnessMode" if self.id == "eraser" => self.roundness_mode = v.as_str().and_then(Variation::parse).unwrap_or(self.roundness_mode),
"sizeVariation" if self.varies_size() => self.size_variation = v.as_f64().unwrap_or(self.size_variation).clamp(0.0, 1000.0),
"angleVariation" if self.id == "eraser" => self.angle_variation = v.as_f64().unwrap_or(self.angle_variation).clamp(0.0, 180.0),
"roundnessVariation" if self.id == "eraser" => {
self.roundness_variation = v.as_f64().unwrap_or(self.roundness_variation).clamp(0.0, 100.0)
}
"angle" => self.angle = v.as_f64().unwrap_or(self.angle).clamp(-180.0, 180.0),
"roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(if self.id == "blobBrush" { 0.0 } else { 1.0 }, 100.0),
"fill" => self.fill = v.as_bool().unwrap_or(self.fill),
+167 -2
View File
@@ -527,10 +527,20 @@ fn blob_brush_sends_keep_selected_and_merge_only_with_selection() {
"{acts:?}"
);
}
// The Eraser keeps Size (Fixed by default), Angle and Roundness: a round tip by default.
// The Eraser keeps Size, Angle and Roundness, each Fixed by default: a round tip by default.
assert_eq!(
GestureTool::new("eraser").options(),
json!({"size": 10.0, "sizeMode": "fixed", "sizeVariation": 0.0, "angle": 0.0, "roundness": 100.0})
json!({
"size": 10.0,
"sizeMode": "fixed",
"sizeVariation": 0.0,
"angle": 0.0,
"angleMode": "fixed",
"angleVariation": 0.0,
"roundness": 100.0,
"roundnessMode": "fixed",
"roundnessVariation": 0.0
})
);
}
@@ -2768,6 +2778,161 @@ fn the_blob_brush_and_the_eraser_show_their_tip_at_the_pointer() {
}
}
/// Eraser Tool Options' Angle, Roundness and Size variation menus: the Eraser keeps each mode and
/// Variation, the release sends them with the pen's readings along the drag, and while it erases
/// its tip outline at the pointer takes the angle, roundness and size the pen gives there. The Blob
/// Brush's Size takes Fixed and Pressure only, and it has no Angle or Roundness menus.
#[test]
fn the_eraser_tip_follows_its_variation_modes() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
// The bounds of the tip outline, the last two overlays (its halo and its line).
let tip = |t: &GestureTool| match &t.overlays(&cx)[..] {
[.., Overlay::Path { path: halo, width: 3.0, .. }, Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => {
assert_eq!(halo, path);
vectorcraft_geom::Shape::bounding_box(path)
}
o => panic!("{o:?}"),
};
let near = |b: vectorcraft_geom::Rect, want: (f64, f64, f64, f64)| {
assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}");
};
let mut t = GestureTool::new("eraser");
assert_eq!(
(t.options()["angleMode"].clone(), t.options()["roundnessMode"].clone(), t.options()["sizeMode"].clone()),
(json!("fixed"), json!("fixed"), json!("fixed")),
"Fixed by default"
);
for (k, v) in [
("size", json!(20)),
("roundness", json!(25)),
("angleMode", json!("rotation")),
("angleVariation", json!(90)),
("roundnessMode", json!("random")),
("roundnessVariation", json!(10)),
("sizeMode", json!("tilt")),
("sizeVariation", json!(8)),
] {
t.set_option(k, &v);
}
let o = t.options();
assert_eq!(
[&o["angleMode"], &o["angleVariation"], &o["roundnessMode"], &o["roundnessVariation"], &o["sizeMode"], &o["sizeVariation"]],
[&json!("rotation"), &json!(90.0), &json!("random"), &json!(10.0), &json!("tilt"), &json!(8.0)]
);
// A mode it does not have leaves the mode as it was; a Variation is kept within its range.
t.set_option("angleMode", &json!("wheel"));
t.set_option("roundnessVariation", &json!(500));
assert_eq!((t.options()["angleMode"].clone(), t.options()["roundnessVariation"].clone()), (json!("rotation"), json!(100.0)));
t.set_option("roundnessVariation", &json!(10));
// Before a press the outline is the set tip: 20 by 5 pt, lying level.
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 100.0, 50.0));
near(tip(&t), (90.0, 47.5, 110.0, 52.5));
// Erasing with the barrel turned half way round and the pen upright, the tip stands upright and
// is 28 by 7 pt; Random leaves the roundness as set.
let pen = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: Some(180.0) });
t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
near(tip(&t), (196.5, 36.0, 203.5, 64.0));
let acts = t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
let Some(Action::Exec(c, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(c, "path.eraseRegion");
assert_eq!(
[&v["angleMode"], &v["angleVariation"], &v["roundnessMode"], &v["roundnessVariation"], &v["sizeMode"], &v["sizeVariation"]],
[&json!("rotation"), &json!(90.0), &json!("random"), &json!(10.0), &json!("tilt"), &json!(8.0)]
);
assert_eq!(
v["points"],
json!([[100.0, 50.0, 0.5, 0.0, 0.0, 180.0], [200.0, 50.0, 0.5, 0.0, 0.0, 180.0]]),
"the pen's readings go with the points"
);
// A mouse drag, which reads no tilt or rotation, keeps the set tip.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 50.0));
near(tip(&t), (190.0, 47.5, 210.0, 52.5));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 50.0));
// A point without a reading after one with it goes back to the set tip there, as the release
// erases it.
t.pointer(&cx, &PointerEvent { pen, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
near(tip(&t), (190.0, 47.5, 210.0, 52.5));
t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
// The Blob Brush's Size is Fixed or Pressure, and its Angle and Roundness have no menus.
let mut b = GestureTool::new("blobBrush");
b.set_option("sizeMode", &json!("tilt"));
b.set_option("angleMode", &json!("rotation"));
assert_eq!(b.options()["sizeMode"], json!("fixed"));
assert!(b.options().get("angleMode").is_none() && b.options().get("roundnessVariation").is_none());
b.set_option("sizeMode", &json!("pressure"));
assert_eq!(b.options()["sizeMode"], json!("pressure"));
}
/// The Eraser's tip outline follows a pen turned in place, which adds no stroke point, and the
/// release erases with that reading; and its size passes 1296 pt as `path.eraseRegion` sweeps it.
#[test]
fn the_eraser_tip_turns_in_place_and_passes_a_nib_size() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let tip = |t: &GestureTool| match &t.overlays(&cx)[..] {
[.., Overlay::Path { path, color: [0, 0, 0], width: 1.0, .. }] => vectorcraft_geom::Shape::bounding_box(path),
o => panic!("{o:?}"),
};
let near = |b: vectorcraft_geom::Rect, want: (f64, f64, f64, f64)| {
assert!([b.x0 - want.0, b.y0 - want.1, b.x1 - want.2, b.y1 - want.3].iter().all(|e| e.abs() < 0.01), "{b:?} vs {want:?}");
};
let mut t = GestureTool::new("eraser");
for (k, v) in [("size", json!(20)), ("roundness", json!(25)), ("angleMode", json!("rotation")), ("angleVariation", json!(90))] {
t.set_option(k, &v);
}
let level = Some(PenPose { tilt: None, rotation: Some(0.0) });
let turned = Some(PenPose { tilt: None, rotation: Some(180.0) });
t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
near(tip(&t), (190.0, 47.5, 210.0, 52.5));
// The barrel turns half way round with the pointer still: the tip stands upright.
t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
near(tip(&t), (197.5, 40.0, 202.5, 60.0));
let acts = t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]]));
// A release at the last point erases with the pose the pen has as it lifts.
t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
let acts = t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]]));
// A release there that reads no pose keeps the last one.
t.pointer(&cx, &PointerEvent { pen: level, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
t.pointer(&cx, &PointerEvent { pen: turned, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
let acts = t.pointer(&cx, &PointerEvent { pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(v["points"], json!([[100.0, 50.0, 0.5, null, null, 0.0], [200.0, 50.0, 0.5, null, null, 180.0]]));
// With Angle on Bearing, a pen that leaned only at the last point and stands up there again
// leaves no bearing: the tip lies level, as set.
t.set_option("angleMode", &json!("bearing"));
let upright = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: None });
let leaning = Some(PenPose { tilt: Some([0.0, 45.0]), rotation: None });
t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
t.pointer(&cx, &PointerEvent { pen: leaning, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
assert!(tip(&t).height() > 6.0, "{:?}", tip(&t));
t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
near(tip(&t), (190.0, 47.5, 210.0, 52.5));
t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Up, 200.0, 50.0) });
// Size 1000 with a Variation of 1000 on Tilt, the pen upright: a tip 2000 pt across.
let mut t = GestureTool::new("eraser");
for (k, v) in [("size", json!(1000)), ("sizeMode", json!("tilt")), ("sizeVariation", json!(1000))] {
t.set_option(k, &v);
}
let upright = Some(PenPose { tilt: Some([0.0, 0.0]), rotation: None });
t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Down, 100.0, 50.0) });
t.pointer(&cx, &PointerEvent { pen: upright, pressure: 0.5, ..PointerEvent::new(PointerKind::Drag, 200.0, 50.0) });
near(tip(&t), (-800.0, -950.0, 1200.0, 1050.0));
}
#[test]
fn smooth_needs_selection_and_brackets_resize() {
let (d, _) = doc_with_rect();
+2
View File
@@ -43,6 +43,8 @@ const ROWS: &[Row] = &[
(&["blobBrush"], None, &["fidelity", "keepSelected", "mergeOnlySelection"]),
// Eraser Tool Options' tip shape.
(&["blobBrush", "eraser"], None, &["angle", "roundness"]),
// Eraser Tool Options' Angle and Roundness variation menus and fields.
(&["eraser"], None, &["angleMode", "angleVariation", "roundnessMode", "roundnessVariation"]),
// Live Paint Selection Tool Options (`builder::LivePaintSelectionTool`).
(&["livePaintSelection"], None, &["selectFills", "selectStrokes", "color", "width"]),
// Live Paint Bucket Options (`builder::LivePaintBucketTool`).
+5 -3
View File
@@ -66,7 +66,7 @@ use overlays::selected_anchor;
use overlays::{HandleLook, blend_widgets_overlay, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay};
pub use panes::Panes;
pub(crate) use panes::{consolidate, pane, tile, tiled_area};
use raster::{changed_pixels, pixel_region, upload};
use raster::{changed_pixels, frame_ppp, pixel_region, upload};
pub(crate) use raster::{color_image, color_image_owned, render_job};
pub use selection_marks::SelectionMarks;
#[cfg(test)]
@@ -326,7 +326,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
// Artwork raster.
let ppp = ui.ctx().pixels_per_point();
let device_ppp = ui.ctx().pixels_per_point();
let max_side = ui.ctx().input(|i| i.max_texture_side);
let ppp = frame_ppp(rect.size(), device_ppp, max_side);
// Match Renderer::render_as before recording the cache extent or building its view.
let (w, h) = (
((rect.width() * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX)),
@@ -334,7 +336,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
);
// View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel
// is bigger than a screen pixel (below that the screen render already shows it).
let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten();
let pixel = (app.ui.view.pixel_preview && xf.zoom * device_ppp as f64 > 1.0).then(|| pixel_region(&xf, max_side)).flatten();
// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show
// their pixels as they rasterize, hard-edged.
let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps;
+3 -3
View File
@@ -5,8 +5,8 @@ use super::drag::{
tool_drag_moved, tool_frame_moves, tool_holds, tool_press_doc_id,
};
use super::view::{
HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn,
wheel, whole_pixels, zoom_about,
HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, pan_ppp, rotate_about, scroll_view, touch_gestures,
trackpad_turn, wheel, whole_pixels, zoom_about,
};
use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape, Stroke, Ui, VectorcraftApp, View, Xf, json};
@@ -367,7 +367,7 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2)
let ui_zoom = app.canvas_ui_zoom;
match d {
Drag::Pan { start, center, .. } => {
let ppp = ui.ctx().pixels_per_point();
let ppp = pan_ppp(app, ui);
if let Some(vm) = app.view_mut() {
vm.center = center;
pan_pixels(vm, (ppp, ui_zoom), whole_pixels(p - start, ppp));
+6 -3
View File
@@ -12,7 +12,7 @@ use super::outline::{c32, node_outline, stroke_path, too_many_anchors};
use super::view::fit_zoom;
use super::{
Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, backdrop, cache_key,
dim_on_canvas, frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers,
dim_on_canvas, frame_options, frame_ppp, frame_view, grid, guides_of, json, paint_paper, pixel_region, placed_key, plugin_key, pos2, rulers,
shows_artboards, textured_quad, vec2, view_rects,
};
use crate::doc_windows::Win;
@@ -339,10 +339,13 @@ fn draw_pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, cell: egui::Rect) -> boo
if let Some(look) = grid_look.filter(|_| grids_in_back) {
grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look);
}
// View › Pixel Preview, as the canvas draws it.
let max_side = ui.ctx().input(|i| i.max_texture_side);
let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf, max_side)).flatten();
// The pane's frame fits the largest texture side the display supports, as the canvas's does.
let ppp = frame_ppp(rect.size(), ppp, max_side);
let size = |l: f32| ((l * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX));
let (pw, ph) = (size(rect.width()), size(rect.height()));
// View › Pixel Preview, as the canvas draws it.
let pixel = (app.ui.view.pixel_preview && xf.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten();
let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps;
let plugins = plugin_key(&mut app.canvas.plugin_keys, &app.session, st);
let placed = placed_key(&mut app.canvas.placed_keys, &app.session, st);
+34
View File
@@ -323,6 +323,40 @@ fn other_panes_show_pixel_preview() {
assert!(x1 > x0 && y1 > y0);
}
/// A pane more device pixels across than the largest texture side the display supports renders
/// its document within that size, as the canvas does, and the frame covers the pane.
#[test]
fn other_panes_render_within_the_largest_texture_side() {
let mut app = app_with(2);
app.canvas.panes.worker_started = true; // every pane renders on this thread
let ctx = egui::Context::default();
ctx.set_pixels_per_point(2.0);
app.run("window.arrange.tile", json!({})).unwrap();
// The first frame sets the pixels a point, the second draws at them.
for _ in 0..2 {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))),
max_texture_side: Some(1024),
..Default::default()
};
ctx.run_ui(raw, |ui| app.canvas_area(ui)).textures_delta.clear();
}
let uid = (app.session.documents()[0].uid, 1);
let (key, tex) = app.canvas.panes.shown.get(&uid).map(|(k, t)| (k.clone(), t.size())).expect("the other document's pane rendered");
assert_eq!(tex[0].max(tex[1]), 1024, "the pane's frame is {tex:?} pixels");
let (docs, cells) = panes(&app);
let pane = docs.iter().zip(cells).find(|(i, _)| **i == 0).map(|(_, r)| r).unwrap();
let rect = view_rects(&app, pane, ctx.pixels_per_point()).1;
assert!(rect.width().max(rect.height()) * ctx.pixels_per_point() > 1024.0, "the pane is {rect:?} at {} pixels a point", ctx.pixels_per_point());
assert!(
(key.w as f32 / key.ppp - rect.width()).abs() < 1.0 && (key.h as f32 / key.ppp - rect.height()).abs() < 1.0,
"the frame covers the pane {rect:?}: {} × {} pixels at {} a point",
key.w,
key.h,
key.ppp
);
}
/// A pane's render key takes its document's [`CacheKey::placed`] value ([`placed_key`]), which a
/// bitmap made for a test running beside this one does not move: the document holds no images.
#[test]
+15 -3
View File
@@ -242,9 +242,20 @@ pub(super) fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: vectorc
}
}
/// The device pixels a point that the art's frame of a canvas `size` points across renders at:
/// the display's `ppp`, lowered when a side of the frame would be more than `max_side` pixels, the
/// largest texture side the display supports (egui's `max_texture_side`). The frame is then drawn
/// scaled up over the canvas, at a lower resolution.
pub(super) fn frame_ppp(size: egui::Vec2, ppp: f32, max_side: usize) -> f32 {
let longest = size.x.max(size.y) * ppp;
let max = max_side.clamp(1, usize::from(u16::MAX)) as f32;
if longest.round() <= max { ppp } else { ppp * (max / longest) }
}
/// Pixel Preview: the whole document pixels (x0, y0, x1, y1) under the canvas, rotated view
/// included. None when the view is degenerate.
pub(super) fn pixel_region(xf: &Xf) -> Option<[i64; 4]> {
/// included. None when the view is degenerate, or when a side of the region is more than
/// `max_side` pixels, the largest texture side the display supports.
pub(super) fn pixel_region(xf: &Xf, max_side: usize) -> Option<[i64; 4]> {
let r = xf.rect;
let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| xf.to_doc(p));
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
@@ -253,6 +264,7 @@ pub(super) fn pixel_region(xf: &Xf) -> Option<[i64; 4]> {
}
let (x0, y0, x1, y1) = (x0.floor(), y0.floor(), x1.ceil(), y1.ceil());
// A document pixel is bigger than a screen pixel here, so the region is about canvas-sized.
let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= 16384.0 && y1 - y0 <= 16384.0;
let max = max_side.min(16384) as f64;
let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= max && y1 - y0 <= max;
ok.then_some([x0 as i64, y0 as i64, x1 as i64, y1 as i64])
}
+107 -2
View File
@@ -2550,6 +2550,45 @@ fn an_edit_renders_only_its_part_of_the_canvas() {
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it");
}
/// A canvas more device pixels across than the largest texture side the display supports renders
/// its art at fewer pixels a point, within that size, and draws it over the whole canvas. Pixel
/// Preview's region of document pixels stays within it too.
#[test]
fn the_art_renders_within_the_largest_texture_side() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width":400,"height":300})).unwrap();
app.canvas.worker_started = true; // every frame on this thread, however long it takes
let ctx = egui::Context::default();
ctx.set_pixels_per_point(2.0);
// The first frame sets the pixels a point, the second draws at them.
for _ in 0..2 {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
max_texture_side: Some(1024),
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui));
out.textures_delta.clear();
}
let rect = app.canvas_rect.unwrap();
assert!(rect.width() * ctx.pixels_per_point() > 1024.0, "the canvas is {rect:?} at {} pixels a point", ctx.pixels_per_point());
let size = app.canvas.texture.as_ref().map(|t| t.size()).unwrap();
assert_eq!(size[0].max(size[1]), 1024, "the frame is {size:?} pixels");
let k = app.canvas.key.clone().unwrap();
assert!(
(k.w as f32 / k.ppp - rect.width()).abs() < 1.0 && (k.h as f32 / k.ppp - rect.height()).abs() < 1.0,
"the frame covers the canvas {rect:?}: {} × {} pixels at {} a point",
k.w,
k.h,
k.ppp
);
let xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 500.0)), zoom: 0.5, center: Point::new(0.0, 0.0), rot: 0.0 };
let [x0, _, x1, _] = pixel_region(&xf, 4096).expect("a region 2,000 document pixels wide");
assert_eq!(x1 - x0, 2000);
assert_eq!(pixel_region(&xf, 1024), None, "more than the display's largest texture");
}
/// A placed document's bitmap made in the background renders the canvas again only where the
/// objects showing its file are: both copies of the file are in the part rendered again.
#[test]
@@ -3425,7 +3464,8 @@ fn a_drag_back_to_its_start_renders_again_after_it() {
assert!(!app.canvas.moved);
}
/// Cache metadata, worker requests and view transforms use the renderer's bounded raster size.
/// Cache metadata, worker requests and view transforms use the renderer's bounded raster size,
/// on a display that reports a larger texture side than that.
/// Keep the worker controlled so huge virtual viewports never allocate huge test images.
#[test]
fn canvas_render_jobs_use_renderer_limited_dimensions() {
@@ -3436,7 +3476,11 @@ fn canvas_render_jobs_use_renderer_limited_dimensions() {
app.canvas.worker_started = true;
app.ui.view.rulers = false;
let frame = |app: &mut VectorcraftApp, size, ppp| {
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), ..Default::default() };
let mut input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)),
max_texture_side: Some(1 << 20),
..Default::default()
};
input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp);
let mut output = ctx.run_ui(input, |ui| show(app, ui));
output.textures_delta.clear();
@@ -5017,6 +5061,67 @@ fn scrolls_and_pans_move_the_view_by_whole_device_pixels() {
frame(&mut app, vec![primary(p0 + vec2(-5.55, 22.71), false)]);
}
/// On a canvas whose frame renders at fewer pixels a point than the display's ([`frame_ppp`]),
/// the wheel and the Hand tool move the view by whole pixels of that frame.
#[test]
fn scrolls_and_pans_move_a_lowered_frame_by_whole_pixels() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.canvas.worker_started = true; // every frame on this thread
let ctx = egui::Context::default();
ctx.set_pixels_per_point(2.0);
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let mut time = 0.0;
let mut frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
time += 0.016;
let input = egui::RawInput { time: Some(time), screen_rect: Some(screen), max_texture_side: Some(1024), events, ..Default::default() };
ctx.run_ui(input, |ui| show(app, ui)).textures_delta.clear();
};
frame(&mut app, vec![]);
app.view_mut().unwrap().center = Point::new(200.3, 150.7);
frame(&mut app, vec![]);
let ppp = app.canvas.key.as_ref().map(|k| k.ppp).unwrap();
assert!(ppp < 2.0, "the frame renders at {ppp} pixels a point");
// The pan of the canvas's render view, in pixels of its frame.
let pan = |app: &VectorcraftApp| {
let v = app.view().unwrap();
(Affine::scale(v.zoom * f64::from(ppp)) * Affine::translate(-v.center.to_vec2())).translation()
};
let whole = |by: vectorcraft_geom::Vec2| (by - vectorcraft_geom::Vec2::new(by.x.round(), by.y.round())).hypot() < 1e-6;
let wheel = |d: egui::Vec2| egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: d,
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
};
let rect = app.canvas_rect.unwrap();
frame(&mut app, vec![egui::Event::PointerMoved(rect.center())]);
let start = pan(&app);
for (k, d) in [vec2(0.37, 11.13), vec2(-12.71, 0.0), vec2(21.9, -3.83)].into_iter().enumerate() {
frame(&mut app, vec![wheel(d)]);
let by = pan(&app) - start;
assert!(whole(by), "scroll {k}: the view moved {by:?} pixels of the frame");
}
for k in 0..60 {
frame(&mut app, vec![]);
let by = pan(&app) - start;
assert!(whole(by), "frame {k} after the scrolls: the view moved {by:?} pixels of the frame");
}
assert!((pan(&app) - start).hypot() > 10.0, "the wheel moved the view");
app.select_tool("hand");
let p0 = rect.center() + vec2(0.3, 0.1);
frame(&mut app, vec![egui::Event::PointerMoved(p0)]);
let start = pan(&app);
frame(&mut app, vec![primary(p0, true)]);
for (k, d) in [vec2(13.37, -7.61), vec2(40.19, 3.33)].into_iter().enumerate() {
frame(&mut app, vec![egui::Event::PointerMoved(p0 + d)]);
let by = pan(&app) - start;
assert!(whole(by), "drag {k}: the view moved {by:?} pixels of the frame");
}
frame(&mut app, vec![primary(p0 + vec2(40.19, 3.33), false)]);
}
/// The part of a device pixel a scroll leaves is added to the next scroll of the same view, and
/// dropped when the view was zoomed, turned or moved in between, when the next scroll is in
/// another document, or when the device pixels a point changed.
@@ -10,10 +10,15 @@ use vectorcraft_engine::Session;
const WINDOW: egui::Vec2 = vec2(1200.0, 900.0);
/// One canvas frame in a `WINDOW` window at `native` device pixels a point, with the interface
/// zoomed by `ui_zoom`.
/// zoomed by `ui_zoom`, on a display whose textures are up to 8,192 pixels a side.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, (native, ui_zoom): (f32, f32), events: Vec<egui::Event>) {
ctx.set_zoom_factor(ui_zoom);
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, WINDOW / ui_zoom)), events, ..Default::default() };
let mut input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, WINDOW / ui_zoom)),
max_texture_side: Some(8192),
events,
..Default::default()
};
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native);
let mut out = ctx.run_ui(input, |ui| show(app, ui));
out.textures_delta.clear();
+14 -6
View File
@@ -139,10 +139,18 @@ pub(super) fn whole_pixels(d: egui::Vec2, ppp: f32) -> (f64, f64) {
((f64::from(d.x) * f64::from(ppp)).round(), (f64::from(d.y) * f64::from(ppp)).round())
}
/// The pixels a point that the canvas's art frame renders at ([`super::frame_ppp`]): the display's,
/// or fewer on a canvas wider than the largest texture side. Pans and scrolls move the view by
/// whole pixels of that frame.
pub(super) fn pan_ppp(app: &VectorcraftApp, ui: &Ui) -> f32 {
let ppp = ui.ctx().pixels_per_point();
app.canvas_rect.map_or(ppp, |r| super::frame_ppp(r.size(), ppp, ui.ctx().input(|i| i.max_texture_side)))
}
/// What the scrolls of the view have left to move ([`scroll_view`]).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct ScrollRest {
/// Device pixels along the screen's axes, at most half a pixel each.
/// Pixels of the canvas's frame along the screen's axes, at most half a pixel each.
pub(super) x: f64,
pub(super) y: f64,
/// The document, the view's center, zoom and rotation, and the device pixels a point that the
@@ -151,11 +159,11 @@ pub(super) struct ScrollRest {
}
/// Scroll the active view so that its content moves `by` screen points (the wheel, a trackpad, a
/// touch screen, a scroll bar), in whole device pixels: the part of a pixel left over is added to
/// the next scroll. It is dropped when the view changed in between (a zoom, a turn, Fit, a pan
/// of another kind, another document) or the device pixels a point did.
/// touch screen, a scroll bar), in whole pixels of the canvas's frame ([`pan_ppp`]): the part of a
/// pixel left over is added to the next scroll. It is dropped when the view changed in between (a
/// zoom, a turn, Fit, a pan of another kind, another document) or the pixels a point did.
pub(super) fn scroll_view(app: &mut VectorcraftApp, ui: &Ui, by: egui::Vec2) {
let ppp = ui.ctx().pixels_per_point();
let ppp = pan_ppp(app, ui);
let ui_zoom = app.canvas_ui_zoom;
let Some(doc) = app.session.active().map(|st| st.uid) else { return };
let Some(vm) = app.view_mut() else { return };
@@ -344,7 +352,7 @@ pub(super) fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, o
let hot = drag.is_some() || hover.is_some_and(|p| thumb.contains(p));
painter.rect_filled(thumb, CornerRadius::same(3), if hot { t.text_dim } else { t.text_disabled });
}
let ppp = ui.ctx().pixels_per_point();
let ppp = pan_ppp(app, ui);
if let Some((c, by)) = center
&& let Some(vm) = app.view_mut()
{
+1 -1
View File
@@ -573,7 +573,7 @@ fn dock(app: &mut VectorcraftApp, edge: &str) {
fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
let resp = crate::panels::menu_button(ui, "control", rect);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(160.0);
widgets::menu_min_width(ui, 160.0);
widgets::menu_scroll(ui, |ui| {
for group in CONTROL_BAR_GROUPS {
if widgets::menu_item(ui, group_label(group), true, group_on(app, group)) {
+1 -1
View File
@@ -1377,7 +1377,7 @@ pub(crate) fn add_menu_id(name: &str) -> egui::Id {
/// bottom bars, as other long menus do ([`widgets::menu_scroll`]).
fn add_menu(button: &egui::Response, t: &Tokens, bar: &CustomToolbar, actions: &mut Vec<Action>) {
egui::Popup::menu(button).id(add_menu_id(&bar.name)).show(|ui| {
ui.set_min_width(200.0);
widgets::menu_min_width(ui, 200.0);
widgets::menu_scroll(ui, |ui| {
let held: Vec<&'static str> = bar.slots.iter().flatten().filter_map(|id| tool_info(id).map(|t| t.id)).collect();
let heading = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).size(12.0).color(t.text_strong));
+1
View File
@@ -105,6 +105,7 @@ pub mod relink;
pub mod rename_artboards;
pub(crate) mod revolve;
pub(crate) mod revolve_gizmo;
pub(crate) mod revolve_lights;
pub mod safe_mode;
pub mod saturate;
pub mod save_brush_library;
+89 -28
View File
@@ -9,11 +9,16 @@
//! Brush), `closeWithin` and `editWithin` (screen pixels, 0 turns it off; Pencil and Paintbrush),
//! `angle` (degrees) and `roundness` (percent; Blob Brush and Eraser), `livePreview` (Pencil: the stroke shows
//! with its fill and stroke while it is drawn, or as a thin line until the release), `size`
//! (points; Blob Brush and Eraser), and `sizeMode` (`fixed` or `pressure`) and `sizeVariation`
//! (points; Blob Brush and Eraser: with Pressure the tip is Size − Variation wide at the lightest
//! pen pressure and Size + Variation at the heaviest).
//! (points; Blob Brush and Eraser), and `sizeMode` and `sizeVariation` (points; Blob Brush and
//! Eraser: with Pressure the tip is Size − Variation wide at the lightest pen pressure and Size +
//! Variation at the heaviest). The Blob Brush's `sizeMode` is `fixed` or `pressure`. The Eraser's
//! Angle, Roundness and Size each have a menu (`angleMode`, `roundnessMode`, `sizeMode`: `fixed`,
//! `random`, `pressure`, `tilt`, `bearing` or `rotation`) before the field and a Variation field
//! under it (`angleVariation` in degrees, `roundnessVariation` in percent, `sizeVariation`),
//! greyed while the menu is Fixed.
use serde_json::{Map, Value, json};
use vectorcraft_brush::Variation;
use super::document_setup::check;
use super::{DialogSpec, form, run_and_close};
@@ -30,10 +35,14 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_wid
pub const TOOLS: [&str; 5] = ["pencil", "paintbrush", "smooth", "blobBrush", "eraser"];
/// The options the dialog edits, in the order it shows them (a tool has those it reports).
const KEYS: [&str; 14] = [
const KEYS: [&str; 18] = [
"fidelity",
"angle",
"angleMode",
"angleVariation",
"roundness",
"roundnessMode",
"roundnessVariation",
"size",
"sizeMode",
"sizeVariation",
@@ -140,26 +149,58 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix:
});
}
/// Width of the Blob Brush's and the Eraser's Size mode menu.
/// Width of the Blob Brush's and the Eraser's variation menus.
const MODE_W: f32 = 96.0;
/// The Blob Brush's and the Eraser's Size rows: Size with its Fixed or Pressure menu before the
/// field, then the Variation that Pressure adds and takes (greyed while Size is Fixed).
fn blob_size(ui: &mut egui::Ui, d: &mut Dialog) {
const MODES: [&str; 2] = ["fixed", "pressure"];
let pressure = d.str("sizeMode") == "pressure";
let labels = ["Fixed", "Pressure"];
let label_w = label_w(ui) - MODE_W - ui.spacing().item_spacing.x;
let size = d.f64("size", 10.0);
widgets::label_row(ui, tl!("Size:"), label_w, |ui| {
if let Some(m) = widgets::dropdown(ui, "freehand-size-mode", labels[usize::from(pressure)], &labels, MODE_W).and_then(|i| MODES.get(i)) {
d.fields.insert("sizeMode".into(), json!(m));
/// A tip value with a variation menu: its field.
struct Varied<'a> {
/// The value's key; the menu's is `<key>Mode` and the Variation's `<key>Variation`.
key: &'a str,
label: &'a str,
unit: &'a str,
decimals: usize,
range: (f64, f64),
/// The value's default.
default: f64,
/// The Variation field's range.
variation: (f64, f64),
}
/// The rows of a tip value with a variation menu: the value with its menu of `modes` before the
/// field, then the Variation field the menu's modes other than Fixed take (greyed while the
/// menu is Fixed). The Blob Brush's Size offers Fixed and Pressure, and the Eraser's Angle,
/// Roundness and Size every mode.
fn varied(ui: &mut egui::Ui, d: &mut Dialog, v: &Varied, modes: &[Variation]) {
let (mode_key, var_key) = (format!("{}Mode", v.key), format!("{}Variation", v.key));
let mode = Variation::parse(&d.str(&mode_key)).filter(|m| modes.contains(m)).unwrap_or_default();
let labels: Vec<&str> = modes.iter().map(|m| m.label()).collect();
let menu_label_w = label_w(ui) - MODE_W - ui.spacing().item_spacing.x;
let value = d.f64(v.key, v.default);
widgets::label_row(ui, v.label, menu_label_w, |ui| {
if let Some(m) = widgets::dropdown(ui, ("freehand-mode", v.key), mode.label(), &labels, MODE_W).and_then(|i| modes.get(i)) {
d.fields.insert(mode_key.clone(), json!(m.id()));
}
if let Some(x) = widgets::range_field(ui, ("freehand-field", "size"), size, 0.1..=1000.0, "pt", 1, FIELD_W) {
d.fields.insert("size".into(), json!(x));
if let Some(x) = widgets::range_field(ui, ("freehand-field", v.key), value, v.range.0..=v.range.1, v.unit, v.decimals, FIELD_W) {
d.fields.insert(v.key.into(), json!(x));
}
});
ui.add_enabled_ui(pressure, |ui| number(ui, d, ("sizeVariation", tl!("Variation:")), "pt", 1, (0.0, 1000.0)));
ui.add_enabled_ui(mode != Variation::Fixed, |ui| {
let label_w = label_w(ui);
let x = d.f64(&var_key, 0.0);
widgets::label_row(ui, tl!("Variation:"), label_w, |ui| {
if let Some(x) =
widgets::range_field(ui, ("freehand-field", var_key.as_str()), x, v.variation.0..=v.variation.1, v.unit, v.decimals, FIELD_W)
{
d.fields.insert(var_key.clone(), json!(x));
}
});
});
}
/// The Blob Brush's and the Eraser's Size: [`varied`] in points.
fn size_rows(ui: &mut egui::Ui, d: &mut Dialog, modes: &[Variation]) {
let size = Varied { key: "size", label: tl!("Size:"), unit: "pt", decimals: 1, range: (0.1, 1000.0), default: 10.0, variation: (0.0, 1000.0) };
varied(ui, d, &size, modes);
}
/// The Fidelity rows: the label on a line of its own, then a logarithmic slider from the tool's
@@ -208,19 +249,39 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
fidelity(ui, d);
ui.add_space(10.0);
}
if d.fields.contains_key("angle") {
number(ui, d, ("angle", tl!("Angle:")), "°", 0, (-180.0, 180.0));
}
if d.fields.contains_key("roundness") {
let min = if d.str("tool") == "blobBrush" { 0.0 } else { 1.0 };
number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (min, 100.0));
let eraser = d.fields.contains_key("angleMode");
if eraser {
// The Eraser: Angle, Roundness and Size each with every variation mode.
let angle =
Varied { key: "angle", label: tl!("Angle:"), unit: "°", decimals: 0, range: (-180.0, 180.0), default: 0.0, variation: (0.0, 180.0) };
varied(ui, d, &angle, &Variation::ALL);
let roundness = Varied {
key: "roundness",
label: tl!("Roundness:"),
unit: "%",
decimals: 0,
range: (1.0, 100.0),
default: 100.0,
variation: (0.0, 100.0),
};
varied(ui, d, &roundness, &Variation::ALL);
size_rows(ui, d, &Variation::ALL);
} else {
if d.fields.contains_key("angle") {
number(ui, d, ("angle", tl!("Angle:")), "°", 0, (-180.0, 180.0));
}
if d.fields.contains_key("roundness") {
let min = if d.str("tool") == "blobBrush" { 0.0 } else { 1.0 };
number(ui, d, ("roundness", tl!("Roundness:")), "%", 0, (min, 100.0));
}
}
if d.str("tool") == "blobBrush" {
blob_tip_preview(ui, d);
}
if d.fields.contains_key("sizeMode") {
blob_size(ui, d);
} else if d.fields.contains_key("size") {
// The Eraser's Size rows are above.
if !eraser && d.fields.contains_key("sizeMode") {
size_rows(ui, d, &[Variation::Fixed, Variation::Pressure]);
} else if !eraser && d.fields.contains_key("size") {
number(ui, d, ("size", tl!("Size:")), "pt", 1, (0.1, 1000.0));
}
if d.fields.contains_key("fill") {
@@ -366,6 +366,38 @@ fn the_blob_brush_and_eraser_size_can_follow_the_pen_pressure() {
assert!(!text.contains("Variation:") && !text.contains("Fixed"), "{text}");
}
/// Eraser Tool Options gives Angle, Roundness and Size each a variation menu with a Variation
/// field; OK sets the modes and Variations. The Blob Brush keeps one menu, on Size.
#[test]
fn the_eraser_angle_roundness_and_size_each_have_a_variation_menu() {
let mut app = app();
app.run("tool.options", json!({"tool": "eraser"})).unwrap();
let text = painted(&mut app);
assert_eq!((text.matches("Variation:").count(), text.matches("Fixed").count()), (3, 3), "{text}");
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in [
("angleMode", json!("bearing")),
("angleVariation", json!(30)),
("roundnessMode", json!("random")),
("roundnessVariation", json!(20)),
("sizeMode", json!("rotation")),
("sizeVariation", json!(5)),
] {
d.fields.insert(k.into(), v);
}
let text = painted(&mut app);
assert!(text.contains("Bearing") && text.contains("Random") && text.contains("Rotation") && !text.contains("Fixed"), "{text}");
super::super::confirm(&mut app).unwrap();
let o = app.session.tool_options_of("eraser");
assert_eq!(
[&o["angleMode"], &o["angleVariation"], &o["roundnessMode"], &o["roundnessVariation"], &o["sizeMode"], &o["sizeVariation"]],
[&json!("bearing"), &json!(30.0), &json!("random"), &json!(20.0), &json!("rotation"), &json!(5.0)]
);
app.run("tool.options", json!({"tool": "blobBrush"})).unwrap();
let text = painted(&mut app);
assert_eq!(text.matches("Variation:").count(), 1, "{text}");
}
#[test]
fn the_eraser_options_set_the_tips_angle_and_roundness() {
let mut app = app();
+5 -1
View File
@@ -362,6 +362,10 @@ fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
// A mixed Shading leaves the light settings on, for the effects that shade.
ui.add_enabled_ui(flag(d, "shade", true) != Some(false), |ui| {
lighting_presets(ui, d);
// The panel's lighting widget; the dialog moves lights with its rings on the artboard.
if d.bool("__panel") {
super::revolve_lights::show(ui, d);
}
light_list(ui, d);
let mut selected = selected_light(d);
let before = light_record(&selected);
@@ -429,7 +433,7 @@ const LIGHT_KEYS: [(&str, &str); 6] = [
("behind", "lightBehind"),
];
fn explicit_lights(d: &Dialog) -> Option<&Vec<Value>> {
pub(super) fn explicit_lights(d: &Dialog) -> Option<&Vec<Value>> {
d.fields.get("lights").and_then(Value::as_array).filter(|v| !v.is_empty() && v.len() <= vectorcraft_three_d::MAX_LIGHTS)
}
+2 -2
View File
@@ -186,13 +186,13 @@ struct Drag {
fields: serde_json::Map<String, serde_json::Value>,
}
fn wrap(n: f64, snap: bool) -> f64 {
pub(super) fn wrap(n: f64, snap: bool) -> f64 {
let n = if snap { (n / 15.0).round() * 15.0 } else { n };
(n + 180.0).rem_euclid(360.0) - 180.0
}
/// A Height, 0° to 90°, on 15° steps with Shift.
fn snap_height(height: f64, snap: bool) -> f64 {
pub(super) fn snap_height(height: f64, snap: bool) -> f64 {
(if snap { (height / 15.0).round() * 15.0 } else { height }).clamp(0.0, 90.0)
}
@@ -0,0 +1,261 @@
//! The lighting widget on the 3D and Materials panel's Lighting tab for Extrude & Bevel, Revolve,
//! Inflate and Rotate: a sphere that shows each of the effect's lights
//! at its Rotation and Height, a filled sun in front of the object and a hollow one behind it.
//! Dragging a light moves it on its side of the object, a press selects it in the list of lights,
//! and the button below the sphere moves the selected light to the back or the front of the object.
//! Each light takes the keyboard focus, where the arrow keys move it, and has a name for screen
//! readers. The widget edits the panel's fields as the light settings do, so the panel previews a
//! drag and records it as one undo step when the pointer is released.
use egui::{Id, Key, Modifiers, Rect, Sense, Stroke, Vec2, vec2};
use serde_json::{Value, json};
use vectorcraft_geom::Point;
use vectorcraft_three_d::{DEFAULT_LIGHT, Revolve};
use super::revolve::{c3, explicit_lights, light_record, set_light_value};
use super::revolve_gizmo::{snap_height, wrap};
use crate::state::Dialog;
use crate::theme::Tokens;
/// The sphere's largest radius, in points.
pub(crate) const RADIUS: f32 = 56.0;
/// The radius of a light's sun.
const SUN: f32 = 7.0;
/// The arrow keys' steps in degrees, without and with Shift.
const STEP: f64 = 1.0;
const SHIFT_STEP: f64 = 15.0;
/// The keys that move the focused light.
const ARROWS: [Key; 4] = [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown];
/// One light as the widget shows it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Light {
pub rotation: f64,
pub height: f64,
pub behind: bool,
pub color: egui::Color32,
}
impl Light {
fn options(self) -> Revolve {
Revolve { light_rotation: self.rotation, light_height: self.height, light_behind: self.behind, ..Default::default() }
}
/// Where the light sits on a sphere of radius 1 around the origin, y down: the direction the
/// shading uses, seen from the front.
pub(crate) fn offset(self) -> Vec2 {
let [x, y, _] = self.options().light_direction();
vec2(x as f32, y as f32)
}
/// The Rotation and Height, rounded to 0.1°, of the light moved to `offset` (radius 1, y down)
/// on its side of the object: the farther out, the lower. Shift (`snap`) snaps both to 15°.
pub(crate) fn moved_to(self, offset: Vec2, snap: bool) -> (f64, f64) {
let [rotation, height] = self.options().light_at_on_side(Point::new(f64::from(offset.x), f64::from(offset.y)));
(round(wrap(rotation, snap)), round(snap_height(height, snap)))
}
/// The Rotation and Height after one press of `key` (Left and Right turn the light around the
/// object counterclockwise and clockwise, Up and Down raise and lower it). Only the setting the
/// key moves changes, rounded to 0.1°. `None` for another key.
fn stepped(self, key: Key, step: f64) -> Option<(f64, f64)> {
let turn = |by: f64| round(wrap(self.rotation + by, false));
let raise = |by: f64| round(snap_height(self.height + by, false));
match key {
Key::ArrowLeft => Some((turn(step), self.height)),
Key::ArrowRight => Some((turn(-step), self.height)),
Key::ArrowUp => Some((self.rotation, raise(step))),
Key::ArrowDown => Some((self.rotation, raise(-step))),
_ => None,
}
}
/// The name screen readers give the light at `index`: its number, Rotation, Height and side.
fn name(self, index: usize) -> String {
let side = if self.behind { format!(", {}", tl!("Light behind the object")) } else { String::new() };
format!("{} {}: {} {:.1}°, {} {:.1}°{side}", c3("Light"), index + 1, tl!("Rotation"), self.rotation, tl!("Height"), self.height)
}
}
fn round(n: f64) -> f64 {
(n * 10.0).round() / 10.0
}
/// The effect's lights: each light of its list, or its single light, with the effect's defaults for
/// the settings a light leaves out or the selected effects hold with different values (null).
pub(crate) fn lights(d: &Dialog) -> Vec<Light> {
let defaults = vectorcraft_effects::default_params(&d.str("__effect")).unwrap_or(json!({}));
let list = explicit_lights(d).cloned().unwrap_or_else(|| vec![light_record(d)]);
list.iter()
.map(|light| {
let value =
|key: &str, field: &str| light.get(key).filter(|v| !v.is_null()).or_else(|| defaults.get(field)).cloned().unwrap_or(Value::Null);
let [r, g, b] = value("color", "lightColor")
.as_str()
.filter(|s| s.len() == 7 && s.starts_with('#'))
.and_then(vectorcraft_color::Color::from_hex)
.unwrap_or(vectorcraft_color::Color::WHITE)
.to_rgb_uncalibrated()
.map(|c| (c * 255.0).round() as u8);
Light {
rotation: value("rotation", "lightRotation").as_f64().unwrap_or(DEFAULT_LIGHT.0),
height: value("height", "lightHeight").as_f64().unwrap_or(DEFAULT_LIGHT.1),
behind: value("behind", "lightBehind").as_bool().unwrap_or(false),
color: egui::Color32::from_rgb(r, g, b),
}
})
.collect()
}
/// The index of the selected light among `count` lights.
fn selected(d: &Dialog, count: usize) -> usize {
d.fields.get("__light").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).unwrap_or(0).min(count.saturating_sub(1))
}
/// Makes light `index` the selected one, as a choice in the list of lights does.
fn select(ui: &egui::Ui, d: &mut Dialog, index: usize) {
if d.fields.get("__light").and_then(Value::as_u64) != Some(index as u64) {
ui.memory_mut(|m| m.stop_text_input());
super::revolve_gizmo::clear(ui.ctx());
}
d.fields.insert("__light".into(), json!(index));
}
/// Draws the sphere with the lights and the button that moves the selected light to the back or
/// the front, editing `d.fields`.
pub(super) fn show(ui: &mut egui::Ui, d: &mut Dialog) {
let lights = lights(d);
let Some(&first) = lights.first() else { return };
let current = selected(d, lights.len());
let t = Tokens::get(ui.ctx());
let radius = RADIUS.min((ui.available_width() * 0.5 - SUN - 8.0).max(24.0));
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), (radius + SUN + 6.0) * 2.0), Sense::hover());
let center = rect.center();
let painter = ui.painter_at(rect);
// The sphere: its rim is Height 0, its center Height 90°, and the ring between them 45°.
painter.circle(center, radius, t.input, Stroke::new(1.0, t.input_border));
painter.circle_stroke(center, radius * std::f32::consts::FRAC_1_SQRT_2, Stroke::new(1.0, t.input_border.gamma_multiply(0.5)));
for axis in [vec2(radius, 0.0), vec2(0.0, radius)] {
painter.line_segment([center - axis, center + axis], Stroke::new(1.0, t.input_border.gamma_multiply(0.5)));
}
painter.circle(center, 4.0, t.panel, Stroke::new(1.0, t.text_dim));
// The selected light last, so it is drawn above the others and a press there takes it.
let order = (0..lights.len()).filter(|&i| i != current).chain([current]);
let mut moved = None;
let enabled = ui.is_enabled();
for index in order {
let light = lights.get(index).copied().unwrap_or(first);
let at = center + light.offset() * radius;
let id = Id::unique(("three-d-lighting-widget", index));
let response = ui.interact(Rect::from_center_size(at, Vec2::splat((SUN + 4.0) * 2.0)), id, Sense::click_and_drag());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, enabled, light.name(index)));
// A light the pointer took, or that the keyboard focus reached, becomes the selected light and
// keeps the focus, so the arrow keys move it next. Selecting another light stops the editor
// that had the focus, which clears the focus, so the light asks for it after that.
if response.drag_started() || response.clicked() || response.gained_focus() {
select(ui, d, index);
response.request_focus();
}
let origin = ui.input(|i| i.pointer.press_origin());
if response.drag_started()
&& let Some(origin) = origin
{
// The offset from the press to the light's center, kept for the drag, and whether the
// pointer has left the press yet.
ui.data_mut(|m| m.insert_temp(id, (at - origin, false)));
}
// The frame that releases the pointer can also move it: the light goes where it was dropped,
// the point of the press included once the pointer has left it.
if (response.dragged() || response.drag_stopped())
&& let Some(p) = response.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()))
{
let (grip, left): (Vec2, bool) = ui.data(|m| m.get_temp(id)).unwrap_or_default();
let left = left || origin != Some(p);
ui.data_mut(|m| m.insert_temp(id, (grip, left)));
if left {
let snap = ui.input(|i| i.modifiers.shift);
moved = Some((index, light.moved_to((p + grip - center) / radius, snap)));
}
}
if response.has_focus() {
ui.memory_mut(|m| {
m.set_focus_lock_filter(id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() })
});
// Every arrow-key press in the frame moves the light in turn, in the order it came.
let presses = ui.input_mut(|i| {
let mut presses = vec![];
i.events.retain(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. }
if ARROWS.contains(key) && (modifiers.matches_exact(Modifiers::NONE) || modifiers.matches_exact(Modifiers::SHIFT)) =>
{
presses.push((*key, if modifiers.shift { SHIFT_STEP } else { STEP }));
false
}
_ => true,
});
presses
});
let mut next = light;
for (key, step) in presses {
if let Some((rotation, height)) = next.stepped(key, step) {
(next.rotation, next.height) = (rotation, height);
}
}
if next != light {
moved = Some((index, (next.rotation, next.height)));
}
}
paint_light(ui, at, light, index == current, response.has_focus(), response.hovered() || response.dragged());
if response.hovered() || response.dragged() {
ui.ctx().set_cursor_icon(if response.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
}
response.on_hover_text(tl!("Drag a light to move it. The arrow keys move the focused light; Shift steps 15°."));
}
// Only a setting that moved is written: a press that leaves a light where it is, or a key at a
// limit, changes nothing, and a key leaves the other setting as each selected effect has it.
if let Some((index, (rotation, height))) = moved
&& let Some(light) = lights.get(index)
{
for (key, value, was) in [("lightRotation", rotation, light.rotation), ("lightHeight", height, light.height)] {
if (value - was).abs() > 1e-9 {
select(ui, d, index);
set_light_value(d, key, json!(value));
}
}
}
let index = selected(d, lights.len());
let behind = lights.get(index).is_some_and(|l| l.behind);
ui.horizontal(|ui| {
let text = if behind { tl!("Move Light to Front") } else { tl!("Move Light to Back") };
if ui.button(text).clicked() {
select(ui, d, index);
set_light_value(d, "lightBehind", json!(!behind));
}
});
ui.add_space(4.0);
}
/// A light's sun: filled with its color in front of the object, hollow behind it, larger and
/// ringed when it is the selected light, with a second ring while it has the keyboard focus.
fn paint_light(ui: &egui::Ui, at: egui::Pos2, light: Light, selected: bool, focused: bool, hot: bool) {
let t = Tokens::get(ui.ctx());
let painter = ui.painter();
let r = if selected { SUN } else { SUN - 2.0 };
let outline = Stroke::new(if hot { 2.5 } else { 1.5 }, if selected { t.warning } else { t.warning.gamma_multiply(0.7) });
if light.behind {
painter.circle(at, r, t.panel, outline);
painter.circle_stroke(at, r * 0.5, Stroke::new(1.5, light.color));
} else {
painter.circle(at, r, light.color, outline);
}
if selected {
for i in 0..8 {
let angle = i as f32 * std::f32::consts::TAU / 8.0;
let direction = vec2(angle.cos(), angle.sin());
painter.line_segment([at + direction * (r + 2.0), at + direction * (r + 4.0)], Stroke::new(1.25, t.warning));
}
}
if focused {
painter.circle_stroke(at, r + 6.0, Stroke::new(1.0, t.accent));
}
}
+7
View File
@@ -3327,6 +3327,7 @@ digits Malayalam Малаялам
Sixth Space Шесто разстояние
Size Размер
Size Tile to Art Оразмеряване на плочката според обектите
Move Tile with Art Преместване на плочката заедно с обектите
Size/Leading Размер/междуредие
Size: Размер:
Ridges per segment: Изпъкналости на сегмент:
@@ -3955,6 +3956,9 @@ digits Malayalam Малаялам
Working CMYK Работен CMYK
Working CMYK (don't tag) Работен CMYK (без етикет)
Working RGB (don't tag) Работен RGB (без етикет)
Don't Color Manage This Document Без управление на цветовете за този документ
Working RGB: {name} Работен RGB: {name}
Working CMYK: {name} Работен CMYK: {name}
Workspace Работно пространство
Workspace {i} Работно пространство {i}
Wrinkle Tool Инструмент „Набръчкване“
@@ -5711,6 +5715,9 @@ theme Appearance Външен вид
Softness Мекота
Ambient Light Околна светлина
Light behind the object Светлина зад обекта
Move Light to Back Премести светлината отзад
Move Light to Front Премести светлината отпред
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Плъзнете светлина, за да я преместите. Клавишите със стрелки местят фокусираната светлина; Shift я мести с 15°.
Shadows Сенки
Distance from Object Разстояние от обекта
Shadow Bounds Граници на сянката
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -2103,6 +2103,7 @@ digits Malayalam Malajalam
Show Swatch Bounds Prikaži granice uzorka boje
Show Tile Edge Prikaži ivicu pločice
Size Tile to Art Prilagodi pločicu crtežu
Move Tile with Art Pomjeri pločicu s crtežom
Tile Type: Vrsta pločice:
Top in Front Vrh naprijed
V Spacing: Okomiti razmak:
@@ -2145,6 +2146,9 @@ digits Malayalam Malajalam
Use Black Point Compensation Koristi kompenzaciju crne tačke
Working CMYK (don't tag) Radni CMYK (bez oznake)
Working RGB (don't tag) Radni RGB (bez oznake)
Don't Color Manage This Document Ne upravljaj bojama ovog dokumenta
Working RGB: {name} Radni RGB: {name}
Working CMYK: {name} Radni CMYK: {name}
{name} (spot) {name} (spot)
Add to Profiles… Dodaj u profile…
Align Stroke to Center Poravnaj potez po sredini
@@ -5797,6 +5801,9 @@ mark-style Japanese Japanski
Softness Mekoća
Ambient Light Ambijentalno svjetlo
Light behind the object Svjetlo iza objekta
Move Light to Back Pomjeri svjetlo iza
Move Light to Front Pomjeri svjetlo naprijed
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Prevucite svjetlo da ga pomjerite. Tipke sa strelicama pomjeraju fokusirano svjetlo; Shift ga pomjera za 15°.
Shadows Sjene
Distance from Object Udaljenost od objekta
Shadow Bounds Granice sjene
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -2339,6 +2339,9 @@ digits Malayalam Malaiàlam
Ligatures are off while the text is tracked. Les lligadures estan desactivades mentre el text té espaiat.
Light Clar
Light behind the object Llum darrere de l’objecte
Move Light to Back Mou la llum al darrere
Move Light to Front Mou la llum al davant
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Arrossegueu una llum per moure-la. Les tecles de fletxa mouen la llum amb el focus; Maj la mou de 15° en 15°.
Shadows Ombres
Distance from Object Distància de l’objecte
Shadow Bounds Límits de l’ombra
@@ -4349,6 +4352,7 @@ digits Malayalam Malaiàlam
Sixth Space Espai de sisena part
Size Mida
Size Tile to Art Ajusta la mida del mosaic a l’art
Move Tile with Art Mou el mosaic amb l’art
Size/Leading Mida/Interlineat
Size: Mida:
Size: {size} Tracking: {tracking} Mida: {size} Espaiat: {tracking}
@@ -5161,6 +5165,9 @@ digits Malayalam Malaiàlam
Working CMYK CMYK de treball
Working CMYK (don't tag) CMYK de treball (sense etiquetar)
Working RGB (don't tag) RGB de treball (sense etiquetar)
Don't Color Manage This Document No gestionis el color d'aquest document
Working RGB: {name} RGB de treball: {name}
Working CMYK: {name} CMYK de treball: {name}
Working RGB RGB de treball
Workspace Espai de treball
Workspace {i} Espai de treball {i}
@@ -5440,6 +5447,9 @@ digits Malayalam Malaiàlam
@msg automation path rejected: cannot resolve {path}: {e} camí d’automatització rebutjat: no es pot resoldre {path}: {e}
@msg automation path rejected: outside the read root {root}: {path} camí d’automatització rebutjat: fora de l’arrel de lectura {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} camí d’automatització rebutjat: fora de les arrels de lectura {roots}: {path}
@msg a linked JavaScript file is outside the read roots un fitxer JavaScript enllaçat és fora de les arrels de lectura
@msg the library's file is outside the read roots el fitxer de la biblioteca és fora de les arrels de lectura
@msg {file} names more than one linked JavaScript file {file} correspon a més d’un fitxer JavaScript enllaçat
@msg automation path rejected: outside the write root {root}: {path} camí d’automatització rebutjat: fora de l’arrel d’escriptura {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} camí d’automatització rebutjat: fora de les arrels d’escriptura {roots}: {path}
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} camí d’automatització rebutjat: recorregut ascendent per sota d’una carpeta que no existeix: {path}
@@ -5865,6 +5875,7 @@ digits Malayalam Malaiàlam
@msg no library `{key}` (see {list}) no hi ha la biblioteca `{key}` (vegeu {list})
@msg No linked files to look for No hi ha fitxers enllaçats per buscar
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas L’enllaç és dins d’un símbol, un disseny de gràfic, un motiu o una màscara d’opacitat i no té lloc al llenç
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la il·lustració d’un disseny de gràfic en una sèrie d’un gràfic es torna a dibuixar a partir del disseny: editeu el disseny per canviar-la
@msg no Live Paint edge near that point no hi ha cap aresta de pintura interactiva a prop d’aquell punt
@msg no Live Paint face at that point no hi ha cap cara de pintura interactiva en aquell punt
@msg no native format no hi ha format natiu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -2134,6 +2134,7 @@ digits Malayalam Malajálamské
Show Swatch Bounds Zobrazit hranice položky vzorníku
Show Tile Edge Zobrazit okraj dlaždice
Size Tile to Art Velikost dlaždice podle kresby
Move Tile with Art Přesouvat dlaždici s kresbou
Tile Type: Typ dlaždice:
Top in Front Horní vpředu
V Spacing: Svislá mezera:
@@ -2176,6 +2177,9 @@ digits Malayalam Malajálamské
Use Black Point Compensation Použít kompenzaci černého bodu
Working CMYK (don't tag) Pracovní CMYK (neoznačovat)
Working RGB (don't tag) Pracovní RGB (neoznačovat)
Don't Color Manage This Document Nespravovat barvy tohoto dokumentu
Working RGB: {name} Pracovní RGB: {name}
Working CMYK: {name} Pracovní CMYK: {name}
{name} (spot) {name} (přímá)
Add to Profiles… Přidat do profilů…
Align Stroke to Center Zarovnat tah na střed
@@ -5520,6 +5524,7 @@ imageTrace Outline Obrys
@msg every numbered name for {name} is taken in the document's folder ve složce dokumentu jsou obsazena všechna číslovaná jména pro {name}
@msg No linked files to look for Žádné propojené soubory k vyhledání
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Propojení je uvnitř symbolu, návrhu grafu, vzorku nebo masky krytí a nemá na plátně žádné místo
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it kresba návrhu grafu v datové řadě grafu se znovu vykresluje podle návrhu: chcete-li ji změnit, upravte návrh
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch`
@msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v}
@msg `colors` must be an array of colors, not {v} `colors` musí být pole barev, ne {v}
@@ -5539,6 +5544,7 @@ imageTrace Outline Obrys
@msg automation filesystem access is not granted: write authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění k zápisu: {path}
@msg automation path rejected: outside the read root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro čtení {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} cesta automatizace odmítnuta: mimo kořeny pro čtení {roots}: {path}
@msg a linked JavaScript file is outside the read roots propojený soubor JavaScript je mimo kořeny pro čtení
@msg automation path rejected: outside the write root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro zápis {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} cesta automatizace odmítnuta: mimo kořeny pro zápis {roots}: {path}
@msg automation path rejected: path is empty cesta automatizace odmítnuta: cesta je prázdná
@@ -6100,6 +6106,9 @@ mark-style Japanese Japonské
Softness Měkkost
Ambient Light Okolní světlo
Light behind the object Světlo za objektem
Move Light to Back Přesunout světlo dozadu
Move Light to Front Přesunout světlo dopředu
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Přetažením světlo přesunete. Šipky posouvají světlo s fokusem; se Shiftem po 15°.
Shadows Stíny
Distance from Object Vzdálenost od objektu
Shadow Bounds Hranice stínu
Can't render this file because it is too large.
+7
View File
@@ -2543,6 +2543,7 @@ digits Malayalam Malayalam
Show Swatch Bounds Vis farveprøvegrænser
Show Tile Edge Vis flisekant
Size Tile to Art Tilpas flise til grafik
Move Tile with Art Flyt flise med grafik
Tile Type: Flisetype:
Top in Front Toppen forrest
V Spacing: Lodret afstand:
@@ -2585,6 +2586,9 @@ digits Malayalam Malayalam
Use Black Point Compensation Brug sortpunktskompensation
Working CMYK (don't tag) Arbejds-CMYK (mærk ikke)
Working RGB (don't tag) Arbejds-RGB (mærk ikke)
Don't Color Manage This Document Farvestyr ikke dette dokument
Working RGB: {name} Arbejds-RGB: {name}
Working CMYK: {name} Arbejds-CMYK: {name}
{name} (spot) {name} (spotfarve)
Add to Profiles… Tilføj til profiler…
Align Stroke to Center Justér kontur til midten
@@ -6054,6 +6058,9 @@ mark-style Japanese Japansk
Softness Blødhed
Ambient Light Omgivelseslys
Light behind the object Lys bag objektet
Move Light to Back Flyt lys bagud
Move Light to Front Flyt lys frem
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Træk et lys for at flytte det. Piletasterne flytter lyset med fokus; Skift flytter det 15° ad gangen.
Shadows Skygger
Distance from Object Afstand fra objektet
Shadow Bounds Skyggegrænser
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -3389,6 +3389,7 @@ digits Malayalam Malayalam
Sixth Space Sechstelgeviert-Leerzeichen
Size Größe
Size Tile to Art Kachelgröße an Grafik anpassen
Move Tile with Art Kachel mit Grafik verschieben
Size/Leading Größe/Zeilenabstand
Size: Größe:
Ridges per segment: Zacken pro Segment:
@@ -3965,6 +3966,9 @@ effect Points Punkte
Working CMYK CMYK-Arbeitsfarbraum
Working CMYK (don't tag) CMYK-Arbeitsfarbraum (nicht kennzeichnen)
Working RGB (don't tag) RGB-Arbeitsfarbraum (nicht kennzeichnen)
Don't Color Manage This Document Kein Farbmanagement für dieses Dokument
Working RGB: {name} RGB-Arbeitsfarbraum: {name}
Working CMYK: {name} CMYK-Arbeitsfarbraum: {name}
Workspace Arbeitsbereich
Workspace {i} Arbeitsbereich {i}
Wrinkle Tool Zerknittern-Werkzeug
@@ -5682,6 +5686,9 @@ effect Soft Weich
Softness Weichheit
Ambient Light Umgebungslicht
Light behind the object Licht hinter dem Objekt
Move Light to Back Licht nach hinten
Move Light to Front Licht nach vorne
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Ziehen Sie ein Licht, um es zu verschieben. Die Pfeiltasten verschieben das fokussierte Licht; mit Umschalt in Schritten von 15°.
Shadows Schatten
Distance from Object Abstand vom Objekt
Shadow Bounds Schattenbegrenzung
Can't render this file because it is too large.
+11
View File
@@ -3972,6 +3972,7 @@ digits Malayalam Μαλαγιάλαμ
Toggle Line Composer Εναλλαγή σύνθεσης γραμμών
Sixth Space Διάστημα ενός έκτου
Size Tile to Art Προσαρμογή μεγέθους πλακιδίου στο σχέδιο
Move Tile with Art Μετακίνηση πλακιδίου μαζί με το σχέδιο
Size/Leading Μέγεθος/διάστιχο
Ridges per segment: Ράχες ανά τμήμα:
Size: {size} Tracking: {tracking} Μέγεθος: {size} Απόσταση χαρακτήρων: {tracking}
@@ -4433,6 +4434,9 @@ digits Malayalam Μαλαγιάλαμ
Width Tool Πλάτος (Width Tool)
Working CMYK (don't tag) Χώρος εργασίας CMYK (χωρίς ετικέτα)
Working RGB (don't tag) Χώρος εργασίας RGB (χωρίς ετικέτα)
Don't Color Manage This Document Χωρίς διαχείριση χρωμάτων για αυτό το έγγραφο
Working RGB: {name} Χώρος εργασίας RGB: {name}
Working CMYK: {name} Χώρος εργασίας CMYK: {name}
Workspace {i} Χώρος εργασίας {i}
Wrinkle Tool Πτυχώσεις (Wrinkle Tool)
Write Layers Εγγραφή επιπέδων
@@ -5162,6 +5166,7 @@ digits Malayalam Μαλαγιάλαμ
@msg every numbered name for {name} is taken in the document's folder όλα τα αριθμημένα ονόματα για το {name} χρησιμοποιούνται ήδη στον φάκελο εγγράφου
@msg No linked files to look for Δεν υπάρχουν συνδεδεμένα αρχεία για αναζήτηση
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Ο σύνδεσμος βρίσκεται μέσα σε σύμβολο, σχεδιασμό γραφήματος, μοτίβο ή μάσκα αδιαφάνειας και δεν έχει θέση στον καμβά
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it το έργο ενός σχεδιασμού γραφήματος σε μια σειρά γραφήματος σχεδιάζεται ξανά από τον σχεδιασμό: επεξεργαστείτε τον σχεδιασμό για να το αλλάξετε
Hide VectorCraft Απόκρυψη VectorCraft
Bring All to Front Μεταφορά όλων στο προσκήνιο
Break Link Κατάργηση σύνδεσης
@@ -5219,6 +5224,9 @@ digits Malayalam Μαλαγιάλαμ
@msg automation filesystem access is not granted: write authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα εγγραφής: {path}
@msg automation path rejected: outside the read root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας ανάγνωσης {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός των ριζών ανάγνωσης {roots}: {path}
@msg a linked JavaScript file is outside the read roots ένα συνδεδεμένο αρχείο JavaScript είναι εκτός των ριζών ανάγνωσης
@msg the library's file is outside the read roots το αρχείο της βιβλιοθήκης είναι εκτός των ριζών ανάγνωσης
@msg {file} names more than one linked JavaScript file το {file} αντιστοιχεί σε περισσότερα από ένα συνδεδεμένα αρχεία JavaScript
@msg automation path rejected: outside the write root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας εγγραφής {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός των ριζών εγγραφής {roots}: {path}
@msg automation path rejected: path is empty απορρίφθηκε η διαδρομή αυτοματισμού: η διαδρομή είναι κενή
@@ -5779,6 +5787,9 @@ digits Malayalam Μαλαγιάλαμ
to set spacing and orientation για ορισμό απόστασης και προσανατολισμού
: :
Light behind the object Φως πίσω από το αντικείμενο
Move Light to Back Μετακίνηση φωτός πίσω
Move Light to Front Μετακίνηση φωτός μπροστά
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Σύρετε ένα φως για να το μετακινήσετε. Τα πλήκτρα βέλους μετακινούν το εστιασμένο φως· με Shift ανά 15°.
Shadows Σκιές
Distance from Object Απόσταση από το αντικείμενο
Shadow Bounds Όρια σκιάς
Can't render this file because it is too large.
+9
View File
@@ -3420,6 +3420,7 @@ digits Malayalam Malayálam
Sixth Space Sexto de espacio
Size Tamaño
Size Tile to Art Ajustar tamaño del mosaico a la ilustración
Move Tile with Art Mover mosaico con la ilustración
Size/Leading Tamaño/interlineado
Size: Tamaño:
Ridges per segment: Crestas por segmento:
@@ -4047,6 +4048,9 @@ digits Malayalam Malayálam
Working CMYK CMYK de trabajo
Working CMYK (don't tag) CMYK de trabajo (sin etiquetar)
Working RGB (don't tag) RGB de trabajo (sin etiquetar)
Don't Color Manage This Document No gestionar el color de este documento
Working RGB: {name} RGB de trabajo: {name}
Working CMYK: {name} CMYK de trabajo: {name}
Workspace Espacio de trabajo
Workspace {i} Espacio de trabajo {i}
Wrinkle Tool Herramienta Arrugar
@@ -5213,6 +5217,7 @@ axis Both Ambos
@msg every numbered name for {name} is taken in the document's folder todos los nombres numerados para {name} ya existen en la carpeta del documento
@msg No linked files to look for No hay archivos vinculados que buscar
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas El vínculo está dentro de un símbolo, un diseño de gráfico, un motivo o una máscara de opacidad y no tiene lugar en el lienzo
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la ilustración de un diseño de gráfico en una serie de un gráfico se vuelve a dibujar a partir del diseño: edita el diseño para cambiarla
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Servicios
@@ -5258,6 +5263,7 @@ axis Both Ambos
@msg automation filesystem access is not granted: write authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de escritura: {path}
@msg automation path rejected: outside the read root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de lectura {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} ruta de automatización rechazada: fuera de las carpetas raíz de lectura {roots}: {path}
@msg a linked JavaScript file is outside the read roots un archivo JavaScript vinculado está fuera de las carpetas raíz de lectura
@msg automation path rejected: outside the write root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de escritura {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} ruta de automatización rechazada: fuera de las carpetas raíz de escritura {roots}: {path}
@msg automation path rejected: path is empty ruta de automatización rechazada: la ruta está vacía
@@ -6044,6 +6050,9 @@ mark-style Japanese Japonesas
Softness Suavidad
Ambient Light Luz ambiental
Light behind the object Luz detrás del objeto
Move Light to Back Mover luz detrás
Move Light to Front Mover luz delante
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Arrastre una luz para moverla. Las teclas de flecha mueven la luz con el foco; Mayús la mueve de 15° en 15°.
Shadows Sombras
Distance from Object Distancia del objeto
Shadow Bounds Límites de la sombra
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -2095,6 +2095,7 @@ digits Malayalam Malajalam
Show Swatch Bounds Näytä värimallin rajat
Show Tile Edge Näytä ruudun reuna
Size Tile to Art Sovita ruudun koko kuvitukseen
Move Tile with Art Siirrä ruutua kuvituksen mukana
Tile Type: Ruudun tyyppi:
Top in Front Ylin edessä
V Spacing: Pystyväli:
@@ -2137,6 +2138,9 @@ digits Malayalam Malajalam
Use Black Point Compensation Käytä mustan pisteen kompensointia
Working CMYK (don't tag) Työ-CMYK (ei tunnistetta)
Working RGB (don't tag) Työ-RGB (ei tunnistetta)
Don't Color Manage This Document Älä hallitse tämän asiakirjan värejä
Working RGB: {name} Työ-RGB: {name}
Working CMYK: {name} Työ-CMYK: {name}
{name} (spot) {name} (spottiväri)
Add to Profiles… Lisää profiileihin…
Align Stroke to Center Tasaa viiva keskelle
@@ -5421,6 +5425,7 @@ imageTrace Outline Ääriviiva
@msg every numbered name for {name} is taken in the document's folder kaikki kohteen {name} numeroidut nimet ovat jo käytössä asiakirjan kansiossa
@msg No linked files to look for Ei etsittäviä linkitettyjä tiedostoja
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Linkki on symbolin, kaaviomallin, kuvion tai peittävyysmaskin sisällä, eikä sillä ole paikkaa piirtoalueella
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it kaavion sarjassa olevan kaaviomallin piirros piirretään uudelleen mallista: muokkaa mallia muuttaaksesi sitä
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` on vaiheviittaus {r}, jonka vain `run` ja `command_batch` ratkaisevat
@msg `ids` must be an array of object ids, not {v} `ids` on oltava objektitunnusten taulukko, ei {v}
@msg `colors` must be an array of colors, not {v} `colors` on oltava värien taulukko, ei {v}
@@ -5440,6 +5445,9 @@ imageTrace Outline Ääriviiva
@msg automation filesystem access is not granted: write authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: kirjoitusoikeus puuttuu: {path}
@msg automation path rejected: outside the read root {root}: {path} automaation polku hylättiin: lukujuuren {root} ulkopuolella: {path}
@msg automation path rejected: outside the read roots {roots}: {path} automaation polku hylättiin: lukujuurten {roots} ulkopuolella: {path}
@msg a linked JavaScript file is outside the read roots linkitetty JavaScript-tiedosto on lukujuurten ulkopuolella
@msg the library's file is outside the read roots kirjaston tiedosto on lukujuurten ulkopuolella
@msg {file} names more than one linked JavaScript file {file} vastaa useampaa kuin yhtä linkitettyä JavaScript-tiedostoa
@msg automation path rejected: outside the write root {root}: {path} automaation polku hylättiin: kirjoitusjuuren {root} ulkopuolella: {path}
@msg automation path rejected: outside the write roots {roots}: {path} automaation polku hylättiin: kirjoitusjuurten {roots} ulkopuolella: {path}
@msg automation path rejected: path is empty automaation polku hylättiin: polku on tyhjä
@@ -5981,6 +5989,9 @@ mark-style Japanese Japanilainen
Softness Pehmeys
Ambient Light Ympäristövalo
Light behind the object Valo objektin takana
Move Light to Back Siirrä valo taakse
Move Light to Front Siirrä valo eteen
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Siirrä valoa vetämällä. Nuolinäppäimet siirtävät kohdistettua valoa; Vaihto siirtää 15° kerrallaan.
Shadows Varjot
Distance from Object Etäisyys objektista
Shadow Bounds Varjon rajat
Can't render this file because it contains an unexpected character in line 2 and column 73.
+9
View File
@@ -3412,6 +3412,7 @@ digits Malayalam Malayalam
Sixth Space Espace d’un sixième
Size Taille
Size Tile to Art Ajuster le carreau à l’illustration
Move Tile with Art Déplacer le carreau avec l’illustration
Size/Leading Taille/interlignage
Size: Taille :
Ridges per segment: Crêtes par segment :
@@ -4038,6 +4039,9 @@ digits Malayalam Malayalam
Working CMYK CMJN de travail
Working CMYK (don't tag) CMJN de travail (sans balise)
Working RGB (don't tag) RVB de travail (sans balise)
Don't Color Manage This Document Ne pas gérer les couleurs de ce document
Working RGB: {name} RVB de travail : {name}
Working CMYK: {name} CMJN de travail : {name}
Workspace Espace de travail
Workspace {i} Espace de travail {i}
Wrinkle Tool Outil Fronces
@@ -5198,6 +5202,7 @@ axis Both Les deux
@msg every numbered name for {name} is taken in the document's folder tous les noms numérotés pour {name} sont déjà pris dans le dossier du document
@msg No linked files to look for Aucun fichier lié à rechercher
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Le lien se trouve dans un symbole, une conception de graphe, un motif ou un masque d’opacité et n’a pas de place sur le canevas
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it l’illustration d’une conception de graphe dans une série de graphe est redessinée à partir de la conception, qu’il faut modifier pour la changer
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Services
@@ -5263,6 +5268,7 @@ axis Both Les deux
@msg automation filesystem access is not granted: write authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation d’écriture : {path}
@msg automation path rejected: outside the read root {root}: {path} chemin d’automatisation refusé : hors du dossier racine de lecture {root} : {path}
@msg automation path rejected: outside the read roots {roots}: {path} chemin d’automatisation refusé : hors des dossiers racines de lecture {roots} : {path}
@msg a linked JavaScript file is outside the read roots un fichier JavaScript lié est hors des dossiers racines de lecture
@msg automation path rejected: outside the write root {root}: {path} chemin d’automatisation refusé : hors du dossier racine d’écriture {root} : {path}
@msg automation path rejected: outside the write roots {roots}: {path} chemin d’automatisation refusé : hors des dossiers racines d’écriture {roots} : {path}
@msg automation path rejected: path is empty chemin d’automatisation refusé : le chemin est vide
@@ -6096,6 +6102,9 @@ theme Appearance Apparence
Softness Douceur
Ambient Light Lumière ambiante
Light behind the object Lumière derrière l'objet
Move Light to Back Placer la lumière derrière
Move Light to Front Placer la lumière devant
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Faites glisser une lumière pour la déplacer. Les touches fléchées déplacent la lumière active ; Maj par pas de 15°.
Shadows Ombres
Distance from Object Distance de l'objet
Shadow Bounds Limites de l'ombre
Can't render this file because it is too large.
+7
View File
@@ -1029,6 +1029,7 @@
Document model દસ્તાવેજ મોડેલ
Document open password દસ્તાવેજ ખોલવાનો પાસવર્ડ
Documents you create appear here. તમે બનાવેલા દસ્તાવેજો અહીં દેખાશે.
Don't Color Manage This Document આ દસ્તાવેજનું રંગ સંચાલન કરશો નહીં
Don't Relink ફરીથી લિંક કરશો નહીં
Don't Save સાચવશો નહીં
Don't Show Center સેન્ટર બતાવશો નહીં
@@ -1910,6 +1911,9 @@
Light Intensity લાઇટ તીવ્રતા
Light Rough લાઇટ રફ
Light behind the object ઑબ્જેક્ટની પાછળ લાઇટ
Move Light to Back લાઇટ પાછળ ખસેડો
Move Light to Front લાઇટ આગળ ખસેડો
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. લાઇટ ખસેડવા માટે તેને ખેંચો. ઍરો કી ફોકસ થયેલી લાઇટ ખસેડે છે; Shift સાથે 15° ના પગલાં.
Lighten હળવું કરો
Lighting લાઇટિંગ
Lights લાઇટ્સ
@@ -3536,6 +3540,7 @@
Sixth Space છઠ્ઠી જગ્યા
Size કદ
Size Tile to Art ટાઇલનું કદ આર્ટ પ્રમાણે કરો
Move Tile with Art ટાઇલને આર્ટ સાથે ખસેડો
Size/Leading કદ/લીડિંગ
Size: કદ:
Size: {size} Tracking: {tracking} કદ: {size} ટ્રેકિંગ: {tracking}
@@ -4177,6 +4182,8 @@
Word Spacing: શબ્દ અંતર:
Words Longer Than: આનાથી લાંબા શબ્દો:
Working CMYK કાર્યકારી CMYK
Working CMYK: {name} કાર્યકારી CMYK: {name}
Working RGB: {name} કાર્યકારી RGB: {name}
Workspace વર્કસ્પેસ
Wrinkle કરચલી
Wrinkle Tool કરચલી ટૂલ
Can't render this file because it contains an unexpected character in line 72 and column 5.
+11
View File
@@ -2587,6 +2587,9 @@ antiAlias Sharp शार्प
Ligatures are off while the text is tracked. टेक्स्ट पर ट्रैकिंग लागू होने पर लिगेचर ऑफ़ रहते हैं।
Light लाइट
Light behind the object ऑब्जेक्ट के पीछे प्रकाश
Move Light to Back प्रकाश को पीछे ले जाएँ
Move Light to Front प्रकाश को आगे ले जाएँ
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. प्रकाश को हिलाने के लिए उसे खींचें। तीर कुंजियाँ फ़ोकस वाले प्रकाश को हिलाती हैं; Shift के साथ 15° के चरण।
Shadows छायाएँ
Distance from Object ऑब्जेक्ट से दूरी
Shadow Bounds छाया सीमाएँ
@@ -4696,6 +4699,7 @@ antiAlias Sharp शार्प
Sixth Space छठे हिस्से जितना स्पेस
Size आकार
Size Tile to Art आकार टाइल कला के लिए
Move Tile with Art टाइल को कला के साथ ले जाएँ
Size: साइज़:
Size: {size} Tracking: {tracking} साइज़: {size} ट्रैकिंग: {tracking}
Size/Leading साइज़/लीडिंग
@@ -5573,6 +5577,9 @@ antiAlias Sharp शार्प
Working CMYK वर्किंग CMYK
Working CMYK (don't tag) कार्य CMYK (टैग नहीं)
Working RGB (don't tag) कार्य RGB (टैग नहीं)
Don't Color Manage This Document इस दस्तावेज़ का रंग प्रबंधन न करें
Working RGB: {name} कार्य RGB: {name}
Working CMYK: {name} कार्य CMYK: {name}
Working RGB वर्किंग RGB
Workspace वर्कस्पेस
Workspace {i} वर्कस्पेस {i}
@@ -6464,6 +6471,9 @@ antiAlias Sharp शार्प
@msg automation path rejected: cannot resolve {path}: {e} स्वचालन पाथ खारिज कर दिया: {path} को हल नहीं कर सकता: {e}
@msg automation path rejected: outside the read root {root}: {path} स्वचालन पाथ खारिज: पढ़ने की जड़ के बाहर {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} स्वचालन पाथ खारिज: पढ़ने की जड़ों के बाहर {roots}: {path}
@msg a linked JavaScript file is outside the read roots एक लिंक की गई जावास्क्रिप्ट फ़ाइल पढ़ने की जड़ों के बाहर है
@msg the library's file is outside the read roots लाइब्रेरी की फ़ाइल पढ़ने की जड़ों के बाहर है
@msg {file} names more than one linked JavaScript file {file} एक से अधिक लिंक की गई जावास्क्रिप्ट फ़ाइलों से मेल खाता है
@msg automation path rejected: outside the write root {root}: {path} स्वचालन पाथ खारिज: लिखने की जड़ के बाहर {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} स्वचालन पाथ खारिज: लिखने की जड़ों के बाहर {roots}: {path}
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} स्वचालन पाथ खारिज कर दिया: एक फ़ोल्डर के नीचे माता-पिता traversal जो मौजूद नहीं है: {path}
@@ -7376,6 +7386,7 @@ antiAlias Sharp शार्प
@msg No line breaks to remove हटाने के लिए कोई लाइन ब्रेक नहीं
@msg No linked files to look for देखने के लिए कोई लिंक्ड फ़ाइल नहीं
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas लिंक किसी सिंबल, ग्राफ़ डिज़ाइन, पैटर्न या ओपेसिटी मास्क के अंदर है और कैनवास पर इसकी कोई जगह नहीं है
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it ग्राफ़ श्रृंखला में ग्राफ़ डिज़ाइन की कला डिज़ाइन से फिर से बनाई जाती है: इसे बदलने के लिए डिज़ाइन संपादित करें
@msg no Live Paint edge near that point उस बिंदु के पास कोई लाइव पेंट एज नहीं
@msg no Live Paint face at that point उस बिंदु पर कोई लाइव पेंट फेस नहीं
@msg no metafile format कोई मेटाफ़ाइल फ़ॉर्मैट
Can't render this file because it is too large.
+9
View File
@@ -4069,6 +4069,7 @@ mark-style Japanese Japán
Sixth Space Hatodkvirt-szóköz
Size Méret
Size Tile to Art Csempe méretezése a grafikához
Move Tile with Art Csempe mozgatása a grafikával
Size/Leading Méret/sorköz
Size: Méret:
Size: {size} Tracking: {tracking} Méret: {size} Betűköz: {tracking}
@@ -4788,6 +4789,9 @@ mark-style Japanese Japán
Working CMYK Munka-CMYK
Working CMYK (don't tag) Munka-CMYK (címke nélkül)
Working RGB (don't tag) Munka-RGB (címke nélkül)
Don't Color Manage This Document Ne legyen színkezelés ebben a dokumentumban
Working RGB: {name} Munka-RGB: {name}
Working CMYK: {name} Munka-CMYK: {name}
Working RGB Munka-RGB
Workspace Munkaterület
Workspace {i} Munkaterület {i}
@@ -5044,6 +5048,7 @@ imageTrace Outline Körvonal
@msg automation path rejected: cannot resolve {path}: {e} az automatizálás útvonala elutasítva: nem oldható fel: {path}: {e}
@msg automation path rejected: outside the read root {root}: {path} az automatizálás útvonala elutasítva: kívül esik az olvasási gyökéren ({root}): {path}
@msg automation path rejected: outside the read roots {roots}: {path} az automatizálás útvonala elutasítva: kívül esik az olvasási gyökereken ({roots}): {path}
@msg a linked JavaScript file is outside the read roots egy csatolt JavaScript-fájl kívül esik az olvasási gyökereken
@msg automation path rejected: outside the write root {root}: {path} az automatizálás útvonala elutasítva: kívül esik az írási gyökéren ({root}): {path}
@msg automation path rejected: outside the write roots {roots}: {path} az automatizálás útvonala elutasítva: kívül esik az írási gyökereken ({roots}): {path}
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} az automatizálás útvonala elutasítva: szülőmappára lépés egy nem létező mappa alatt: {path}
@@ -5252,6 +5257,7 @@ imageTrace Outline Körvonal
@msg every numbered name for {name} is taken in the document's folder a dokumentum mappájában minden sorszámozott név foglalt: {name}
@msg No linked files to look for Nincs keresendő csatolt fájl
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas A csatolás egy szimbólumban, diagrammintában, mintázatban vagy átlátszósági maszkban van, és nincs helye a vásznon
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it egy diagram adatsorában a diagramminta grafikája a mintából rajzolódik újra: a módosításához szerkeszd a mintát
@msg every search thread stopped on an internal error (please report this bug) minden keresési szál belső hibával leállt (kérjük, jelentsd ezt a hibát)
@msg Expand Appearance: the selection has a basic appearance Megjelenés kibontása: a kijelölésnek alapmegjelenése van
@msg Expand: no brush strokes selected Kibontás: nincs kijelölt ecsetvonás
@@ -6083,6 +6089,9 @@ imageTrace Outline Körvonal
Softness Lágyság
Ambient Light Környezeti fény
Light behind the object Fény az objektum mögött
Move Light to Back Fény hátra
Move Light to Front Fény előre
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Húzzon egy fényt az áthelyezéséhez. A nyílbillentyűk a fókuszban lévő fényt mozgatják; Shifttel 15°-onként.
Shadows Árnyékok
Distance from Object Távolság az objektumtól
Shadow Bounds Árnyék határai
Can't render this file because it is too large.
+7
View File
@@ -1934,6 +1934,7 @@ digits Malayalam Malayalam
Show Swatch Bounds Tampilkan batas swatch
Show Tile Edge Tampilkan tepi ubin
Size Tile to Art Sesuaikan ukuran ubin dengan karya seni
Move Tile with Art Pindahkan ubin bersama karya seni
Tile Type: Jenis ubin:
Top in Front Atas di depan
V Spacing: Jarak V:
@@ -1976,6 +1977,9 @@ digits Malayalam Malayalam
Use Black Point Compensation Gunakan kompensasi titik hitam
Working CMYK (don't tag) CMYK kerja (tanpa tag)
Working RGB (don't tag) RGB kerja (tanpa tag)
Don't Color Manage This Document Jangan kelola warna dokumen ini
Working RGB: {name} RGB kerja: {name}
Working CMYK: {name} CMYK kerja: {name}
{name} (spot) {name} (spot)
Add to Profiles… Tambahkan ke profil…
Align Stroke to Center Ratakan stroke ke tengah
@@ -5444,6 +5448,9 @@ mark-style Japanese Jepang
Softness Kelembutan
Ambient Light Cahaya ambient
Light behind the object Cahaya di belakang objek
Move Light to Back Pindahkan Cahaya ke Belakang
Move Light to Front Pindahkan Cahaya ke Depan
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Seret cahaya untuk memindahkannya. Tombol panah memindahkan cahaya yang difokuskan; Shift memindahkannya 15°.
Shadows Bayangan
Distance from Object Jarak dari objek
Shadow Bounds Batas bayangan
Can't render this file because it contains an unexpected character in line 2458 and column 5.
+9
View File
@@ -3465,6 +3465,7 @@ digits Malayalam Malayalam
Sixth Space Spazio di un sesto
Size Dimensione
Size Tile to Art Adatta tassello alla grafica
Move Tile with Art Sposta tassello con la grafica
Size/Leading Dimensione/interlinea
Size: Dimensione:
Ridges per segment: Creste per segmento:
@@ -4094,6 +4095,9 @@ digits Malayalam Malayalam
Working CMYK CMYK di lavoro
Working CMYK (don't tag) CMYK di lavoro (senza tag)
Working RGB (don't tag) RGB di lavoro (senza tag)
Don't Color Manage This Document Non gestire il colore di questo documento
Working RGB: {name} RGB di lavoro: {name}
Working CMYK: {name} CMYK di lavoro: {name}
Workspace Area di lavoro
Workspace {i} Area di lavoro {i}
Wrinkle Tool Strumento Grinza
@@ -5263,6 +5267,7 @@ axis Both Entrambi
@msg every numbered name for {name} is taken in the document's folder tutti i nomi numerati per {name} sono già usati nella cartella del documento
@msg No linked files to look for Nessun file collegato da cercare
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Il collegamento si trova in un simbolo, un design di grafico, un motivo o una maschera di opacità e non ha una posizione nell’area di disegno
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la grafica di un design di grafico in una serie del grafico viene ridisegnata dal design: modifica il design per cambiarla
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Servizi
@@ -5308,6 +5313,7 @@ axis Both Entrambi
@msg automation filesystem access is not granted: write authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di scrittura: {path}
@msg automation path rejected: outside the read root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di lettura {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} percorso di automazione rifiutato: fuori dalle cartelle radice di lettura {roots}: {path}
@msg a linked JavaScript file is outside the read roots un file JavaScript collegato è fuori dalle cartelle radice di lettura
@msg automation path rejected: outside the write root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di scrittura {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} percorso di automazione rifiutato: fuori dalle cartelle radice di scrittura {roots}: {path}
@msg automation path rejected: path is empty percorso di automazione rifiutato: il percorso è vuoto
@@ -5717,6 +5723,9 @@ lighting Light Luce
Softness Morbidezza
Ambient Light Luce ambientale
Light behind the object Luce dietro l'oggetto
Move Light to Back Sposta luce dietro
Move Light to Front Sposta luce davanti
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Trascina una luce per spostarla. I tasti freccia spostano la luce attiva; Maiusc a passi di 15°.
Shadows Ombre
Distance from Object Distanza dall'oggetto
Shadow Bounds Limiti dell'ombra
Can't render this file because it is too large.
+9
View File
@@ -3281,6 +3281,7 @@ mark-style Japanese 日本式
Single-line Composer 1 行ずつ組む
Toggle Line Composer 行の組版方式を切り替え
Size Tile to Art タイルをアートワークのサイズに合わせる
Move Tile with Art タイルをアートワークと一緒に移動
Size/Leading サイズ/行送り
Size: サイズ:
Ridges per segment: セグメントごとの山数:
@@ -4122,6 +4123,9 @@ file-filter Templates テンプレート
Windows Windows
Working CMYK (don't tag) 作業用 CMYK (タグなし)
Working RGB (don't tag) 作業用 RGB (タグなし)
Don't Color Manage This Document このドキュメントにカラーマネジメントを適用しない
Working RGB: {name} 作業用 RGB: {name}
Working CMYK: {name} 作業用 CMYK: {name}
Workspace {i} ワークスペース {i}
Yellow イエロー
[Normal Character Style] [標準文字スタイル]
@@ -5330,6 +5334,7 @@ swatch Bright Violet ビビッドバイオレット
@msg every numbered name for {name} is taken in the document's folder ドキュメントのフォルダーでは、{name} の番号付きの名前がすべて使われています
@msg No linked files to look for 検索するリンクファイルがありません
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas このリンクはシンボル、グラフデザイン、パターン、または不透明マスクの中にあり、カンバス上に位置がありません
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it グラフの系列にあるグラフデザインのアートはデザインから描き直されます: 変更するにはデザインを編集してください
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです
@msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません)
@msg `colors` must be an array of colors, not {v} `colors` は色の配列にしてください ({v} は使えません)
@@ -5349,6 +5354,7 @@ swatch Bright Violet ビビッドバイオレット
@msg automation filesystem access is not granted: write authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 書き込み権限がありません: {path}
@msg automation path rejected: outside the read root {root}: {path} 自動化のパスを拒否しました: 読み込みルート {root} の外です: {path}
@msg automation path rejected: outside the read roots {roots}: {path} 自動化のパスを拒否しました: 読み込みルート {roots} のいずれの中でもありません: {path}
@msg a linked JavaScript file is outside the read roots リンクされた JavaScript ファイルが読み込みルートの外にあります
@msg automation path rejected: outside the write root {root}: {path} 自動化のパスを拒否しました: 書き込みルート {root} の外です: {path}
@msg automation path rejected: outside the write roots {roots}: {path} 自動化のパスを拒否しました: 書き込みルート {roots} のいずれの中でもありません: {path}
@msg automation path rejected: path is empty 自動化のパスを拒否しました: パスが空です
@@ -5760,6 +5766,9 @@ lighting Light ライト
Softness 柔らかさ
Ambient Light 環境光
Light behind the object オブジェクトの背後のライト
Move Light to Back ライトを背面へ
Move Light to Front ライトを前面へ
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. ライトをドラッグして移動します。矢印キーでフォーカスのあるライトを移動し、Shift キーで 15° ずつ移動します。
Shadows シャドウ
Distance from Object オブジェクトからの距離
Shadow Bounds シャドウの範囲
Can't render this file because it is too large.
+11
View File
@@ -1863,6 +1863,7 @@ digits Malayalam მალაიალამური
Show Swatch Bounds ფერის ნიმუშის საზღვრების ჩვენება
Show Tile Edge ფილის კიდის ჩვენება
Size Tile to Art ფილის ზომის ნახატზე მორგება
Move Tile with Art ფილის ნახატთან ერთად გადატანა
Tile Type: ფილის ტიპი:
Top in Front ზედა წინ
V Spacing: ვერტ. დაშორება:
@@ -1905,6 +1906,9 @@ digits Malayalam მალაიალამური
Use Black Point Compensation შავი წერტილის კომპენსაციის გამოყენება
Working CMYK (don't tag) სამუშაო CMYK (ტეგის გარეშე)
Working RGB (don't tag) სამუშაო RGB (ტეგის გარეშე)
Don't Color Manage This Document ამ დოკუმენტის ფერების მართვის გარეშე
Working RGB: {name} სამუშაო RGB: {name}
Working CMYK: {name} სამუშაო CMYK: {name}
{name} (spot) {name} (სპოტ)
Add to Profiles… პროფილებში დამატება…
Align Stroke to Center კონტურის ცენტრზე სწორება
@@ -4864,6 +4868,9 @@ font-technology Variable ცვლადი
@msg automation filesystem access is not granted: write authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: ჩაწერის უფლება არ არის: {path}
@msg automation path rejected: outside the read root {root}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირის {root} გარეთაა: {path}
@msg automation path rejected: outside the read roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირების {roots} გარეთაა: {path}
@msg a linked JavaScript file is outside the read roots დაკავშირებული JavaScript ფაილი წაკითხვის ძირების გარეთაა
@msg the library's file is outside the read roots ბიბლიოთეკის ფაილი წაკითხვის ძირების გარეთაა
@msg {file} names more than one linked JavaScript file {file} ერთზე მეტ დაკავშირებულ JavaScript ფაილს შეესაბამება
@msg automation path rejected: outside the write root {root}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირის {root} გარეთაა: {path}
@msg automation path rejected: outside the write roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირების {roots} გარეთაა: {path}
@msg automation path rejected: path is empty ავტომატიზაციის გზა უარყოფილია: გზა ცარიელია
@@ -5225,6 +5232,7 @@ font-technology Variable ცვლადი
@msg every numbered name for {name} is taken in the document's folder დოკუმენტის საქაღალდეში {name}-ის ყველა დანომრილი სახელი დაკავებულია
@msg No linked files to look for მოსაძებნი დაკავშირებული ფაილები არ არის
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas ბმული სიმბოლოში, დიაგრამის დიზაინში, პატერნში ან გაუმჭვირვალობის ნიღაბშია და ტილოზე ადგილი არ აქვს
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it დიაგრამის სერიაში დიაგრამის დიზაინის გრაფიკა დიზაინიდან თავიდან იხატება: მის შესაცვლელად დაარედაქტირეთ დიზაინი
Ruler Origin სახაზავების სათავე
Reset Ruler Origin სახაზავების სათავის ჩამოყრა
Artistic მხატვრული
@@ -5653,6 +5661,9 @@ imageTrace Outline კონტური
In the File ფაილში
Keys კლავიშები
Light behind the object სინათლე ობიექტის უკან
Move Light to Back სინათლის უკან გადატანა
Move Light to Front სინათლის წინ გადატანა
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. გადაათრიეთ სინათლე მის გადასაადგილებლად. ისრიანი ღილაკები ფოკუსირებულ სინათლეს ამოძრავებს; Shift-ით 15°-იანი ბიჯით.
Shadows ჩრდილები
Distance from Object მანძილი ობიექტიდან
Shadow Bounds ჩრდილის საზღვრები
Can't render this file because it is too large.
+9
View File
@@ -3270,6 +3270,7 @@ mark-style Japanese 일본식
Single-line Composer 한 줄 조판
Toggle Line Composer 줄 조판 방식 전환
Size Tile to Art 타일을 아트웍 크기에 맞춤
Move Tile with Art 아트웍과 함께 타일 이동
Size/Leading 크기/행간
Size: 크기:
Ridges per segment: 세그먼트당 융기 수:
@@ -4110,6 +4111,9 @@ file-filter Templates 템플릿
Windows Windows
Working CMYK (don't tag) 작업용 CMYK (태그 안 함)
Working RGB (don't tag) 작업용 RGB (태그 안 함)
Don't Color Manage This Document 이 문서에 색상 관리 안 함
Working RGB: {name} 작업용 RGB: {name}
Working CMYK: {name} 작업용 CMYK: {name}
Workspace {i} 작업 영역 {i}
Yellow 노랑
[Normal Character Style] [기본 문자 스타일]
@@ -5257,6 +5261,7 @@ theme Appearance 외관
@msg every numbered name for {name} is taken in the document's folder 문서 폴더에서 {name}을(를) 위한 번호 이름이 모두 사용 중입니다
@msg No linked files to look for 찾을 링크된 파일이 없습니다
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 링크가 심볼, 그래프 디자인, 패턴 또는 불투명 마스크 안에 있어 캔버스에 위치가 없습니다
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 그래프 계열에 있는 그래프 디자인의 아트는 디자인에서 다시 그려집니다: 변경하려면 디자인을 편집하세요
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`는 단계 참조 {r}이며, `run`과 `command_batch`만 이를 해석합니다
@msg `ids` must be an array of object ids, not {v} `ids`는 {v}이(가) 아니라 개체 id 배열이어야 합니다
@msg `colors` must be an array of colors, not {v} `colors`는 {v}이(가) 아니라 색상 배열이어야 합니다
@@ -5276,6 +5281,7 @@ theme Appearance 외관
@msg automation filesystem access is not granted: write authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 쓰기 권한이 없습니다: {path}
@msg automation path rejected: outside the read root {root}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {root} 밖입니다: {path}
@msg automation path rejected: outside the read roots {roots}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {roots} 밖입니다: {path}
@msg a linked JavaScript file is outside the read roots 연결된 JavaScript 파일이 읽기 루트 밖에 있습니다
@msg automation path rejected: outside the write root {root}: {path} 자동화 경로가 거부되었습니다: 쓰기 루트 {root} 밖입니다: {path}
@msg automation path rejected: outside the write roots {roots}: {path} 자동화 경로가 거부되었습니다: 쓰기 루트 {roots} 밖입니다: {path}
@msg automation path rejected: path is empty 자동화 경로가 거부되었습니다: 경로가 비어 있습니다
@@ -5678,6 +5684,9 @@ lighting Light 광원
Softness 부드러움
Ambient Light 주변광
Light behind the object 개체 뒤의 조명
Move Light to Back 조명을 뒤로 이동
Move Light to Front 조명을 앞으로 이동
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. 조명을 드래그하여 이동합니다. 화살표 키로 포커스된 조명을 이동하고, Shift 키를 누르면 15°씩 이동합니다.
Shadows 그림자
Distance from Object 개체로부터의 거리
Shadow Bounds 그림자 범위
Can't render this file because it is too large.
+7
View File
@@ -2452,6 +2452,7 @@ digits Malayalam Malayalam
Show Swatch Bounds Staalgrenzen weergeven
Show Tile Edge Tegelrand weergeven
Size Tile to Art Tegel aan artwork aanpassen
Move Tile with Art Tegel met artwork verplaatsen
Tile Type: Tegeltype:
Top in Front Bovenkant voorop
V Spacing: V-spatiëring:
@@ -2494,6 +2495,9 @@ digits Malayalam Malayalam
Use Black Point Compensation Zwartpuntcompensatie gebruiken
Working CMYK (don't tag) Werk-CMYK (niet taggen)
Working RGB (don't tag) Werk-RGB (niet taggen)
Don't Color Manage This Document Geen kleurbeheer voor dit document
Working RGB: {name} Werk-RGB: {name}
Working CMYK: {name} Werk-CMYK: {name}
{name} (spot) {name} (spotkleur)
Add to Profiles… Toevoegen aan profielen…
Align Stroke to Center Lijn in het midden uitlijnen
@@ -7847,6 +7851,9 @@ mark-style Japanese Japans
Softness Zachtheid
Ambient Light Omgevingslicht
Light behind the object Licht achter het object
Move Light to Back Licht naar achteren
Move Light to Front Licht naar voren
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Sleep een licht om het te verplaatsen. De pijltoetsen verplaatsen het licht met de focus; Shift verplaatst het per 15°.
Shadows Schaduwen
Distance from Object Afstand tot object
Shadow Bounds Schaduwgrenzen
Can't render this file because it is too large.
+9
View File
@@ -2052,6 +2052,7 @@ digits Malayalam Malajalam
Show Swatch Bounds Pokaż granice próbki
Show Tile Edge Pokaż krawędź kafelka
Size Tile to Art Dopasuj kafelek do kompozycji
Move Tile with Art Przesuwaj kafelek z kompozycją
Tile Type: Typ kafelka:
Top in Front Górny na wierzchu
V Spacing: Odstęp pion.:
@@ -2094,6 +2095,9 @@ digits Malayalam Malajalam
Use Black Point Compensation Użyj kompensacji punktu czerni
Working CMYK (don't tag) Roboczy CMYK (bez znacznika)
Working RGB (don't tag) Roboczy RGB (bez znacznika)
Don't Color Manage This Document Nie zarządzaj kolorem tego dokumentu
Working RGB: {name} Roboczy RGB: {name}
Working CMYK: {name} Roboczy CMYK: {name}
{name} (spot) {name} (dodatkowy)
Add to Profiles… Dodaj do profili…
Align Stroke to Center Wyrównaj obrys do środka
@@ -5347,6 +5351,7 @@ imageTrace Outline Kontur
@msg every numbered name for {name} is taken in the document's folder wszystkie numerowane nazwy dla {name} są zajęte w folderze dokumentu
@msg No linked files to look for Brak połączonych plików do wyszukania
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Łącze znajduje się w symbolu, wzorze wykresu, wzorku lub masce krycia i nie ma miejsca na obszarze roboczym
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it grafika wzoru wykresu w serii danych wykresu jest rysowana od nowa na podstawie wzoru: aby ją zmienić, edytuj wzór
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` to odwołanie do kroku {r}, które rozwiązują tylko `run` i `command_batch`
@msg `ids` must be an array of object ids, not {v} `ids` musi być tablicą identyfikatorów obiektów, nie {v}
@msg `colors` must be an array of colors, not {v} `colors` musi być tablicą kolorów, nie {v}
@@ -5366,6 +5371,7 @@ imageTrace Outline Kontur
@msg automation filesystem access is not granted: write authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do zapisu: {path}
@msg automation path rejected: outside the read root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym odczytu {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} odrzucono ścieżkę automatyzacji: poza katalogami głównymi odczytu {roots}: {path}
@msg a linked JavaScript file is outside the read roots połączony plik JavaScript jest poza katalogami głównymi odczytu
@msg automation path rejected: outside the write root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym zapisu {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} odrzucono ścieżkę automatyzacji: poza katalogami głównymi zapisu {roots}: {path}
@msg automation path rejected: path is empty odrzucono ścieżkę automatyzacji: ścieżka jest pusta
@@ -5940,6 +5946,9 @@ mark-style Japanese Japońskie
Softness Miękkość
Ambient Light Światło otoczenia
Light behind the object Światło za obiektem
Move Light to Back Przenieś światło do tyłu
Move Light to Front Przenieś światło do przodu
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Przeciągnij światło, aby je przesunąć. Klawisze strzałek przesuwają aktywne światło; z Shift o 15°.
Shadows Cienie
Distance from Object Odległość od obiektu
Shadow Bounds Granice cienia
Can't render this file because it is too large.
+9
View File
@@ -2892,6 +2892,7 @@ digits Malayalam Malaiala
Sixth Space Espaço de Sexto
Size Tamanho
Size Tile to Art Dimensionar Bloco para a Arte
Move Tile with Art Mover Bloco com a Arte
Size/Leading Tamanho/Entrelinhamento
Size: Tamanho:
Size: {size} Tracking: {tracking} Tamanho: {size} Rastreamento: {tracking}
@@ -4668,6 +4669,7 @@ axis Both Ambos
@msg automation filesystem access is not granted: write authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de gravação: {path}
@msg automation path rejected: outside the read root {root}: {path} caminho da automação rejeitado: fora da raiz de leitura {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} caminho da automação rejeitado: fora das raízes de leitura {roots}: {path}
@msg a linked JavaScript file is outside the read roots um arquivo JavaScript vinculado está fora das raízes de leitura
@msg automation path rejected: outside the write root {root}: {path} caminho da automação rejeitado: fora da raiz de gravação {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} caminho da automação rejeitado: fora das raízes de gravação {roots}: {path}
@msg automation path rejected: path is empty caminho da automação rejeitado: o caminho está vazio
@@ -4801,6 +4803,9 @@ axis Both Ambos
Softness Suavidade
Ambient Light Luz ambiente
Light behind the object Luz atrás do objeto
Move Light to Back Mover luz para trás
Move Light to Front Mover luz para a frente
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Arraste uma luz para movê-la. As teclas de seta movem a luz em foco; Shift a move de 15° em 15°.
Shadows Sombras
Distance from Object Distância do objeto
Shadow Bounds Limites da sombra
@@ -5317,6 +5322,9 @@ axis Both Ambos
Windows Windows
Working CMYK (don't tag) CMYK de Trabalho (não marcar)
Working RGB (don't tag) RGB de Trabalho (não marcar)
Don't Color Manage This Document Não gerenciar as cores deste documento
Working RGB: {name} RGB de Trabalho: {name}
Working CMYK: {name} CMYK de Trabalho: {name}
Workspace {i} Espaço de Trabalho {i}
Yellow Amarelo
[Normal Character Style] [Estilo de Caractere Normal]
@@ -5448,6 +5456,7 @@ axis Both Ambos
@msg every numbered name for {name} is taken in the document's folder todos os nomes numerados para {name} já existem na pasta do documento
@msg No linked files to look for Nenhum arquivo vinculado para procurar
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas O link está dentro de um símbolo, projeto de gráfico, padrão ou máscara de opacidade e não tem lugar na tela
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it a arte de um projeto de gráfico em uma série do gráfico é desenhada de novo a partir do projeto: edite o projeto para alterá-la
De-Interlace Desentrelaçar
Eliminate Eliminar
Create New Fields by Criar Novos Campos por
Can't render this file because it is too large.
+9
View File
@@ -3452,6 +3452,7 @@ digits Malayalam Малаялам
Sixth Space Шестая шпация
Size Размер
Size Tile to Art Подогнать плитку по рисунку
Move Tile with Art Перемещать плитку вместе с рисунком
Size/Leading Размер/интерлиньяж
Size: Размер:
Ridges per segment: Гребней на сегмент:
@@ -4079,6 +4080,9 @@ digits Malayalam Малаялам
Working CMYK Рабочий CMYK
Working CMYK (don't tag) Рабочий CMYK (без пометки)
Working RGB (don't tag) Рабочий RGB (без пометки)
Don't Color Manage This Document Не управлять цветом этого документа
Working RGB: {name} Рабочий RGB: {name}
Working CMYK: {name} Рабочий CMYK: {name}
Workspace Рабочая среда
Workspace {i} Рабочая среда {i}
Wrinkle Tool Инструмент «Морщины»
@@ -5250,6 +5254,7 @@ axis Both Оба
@msg every numbered name for {name} is taken in the document's folder все нумерованные имена для {name} уже заняты в папке документа
@msg No linked files to look for Нет связанных файлов для поиска
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Связь находится внутри символа, дизайна диаграммы, узора или непрозрачной маски и не имеет места на холсте
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it графика дизайна диаграммы в ряду данных диаграммы перерисовывается по дизайну: чтобы изменить её, отредактируйте дизайн
# The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages.
Services Службы
@@ -5297,6 +5302,7 @@ axis Both Оба
@msg automation filesystem access is not granted: write authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на запись: {path}
@msg automation path rejected: outside the read root {root}: {path} путь автоматизации отклонён: вне корневой папки для чтения {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} путь автоматизации отклонён: вне корневых папок для чтения {roots}: {path}
@msg a linked JavaScript file is outside the read roots связанный файл JavaScript находится вне корневых папок для чтения
@msg automation path rejected: outside the write root {root}: {path} путь автоматизации отклонён: вне корневой папки для записи {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} путь автоматизации отклонён: вне корневых папок для записи {roots}: {path}
@msg automation path rejected: path is empty путь автоматизации отклонён: путь пуст
@@ -5704,6 +5710,9 @@ lighting Light Свет
Softness Мягкость
Ambient Light Окружающий свет
Light behind the object Свет позади объекта
Move Light to Back Переместить свет назад
Move Light to Front Переместить свет вперёд
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Перетащите источник света, чтобы переместить его. Клавиши со стрелками перемещают выбранный источник; с Shift — шагами по 15°.
Shadows Тени
Distance from Object Расстояние от объекта
Shadow Bounds Границы тени
Can't render this file because it is too large.
+7
View File
@@ -3342,6 +3342,7 @@ digits Malayalam Malayalam
Sixth Space Sjättedelsblanksteg
Size Storlek
Size Tile to Art Anpassa rutans storlek efter grafiken
Move Tile with Art Flytta rutan med grafiken
Size/Leading Storlek/radavstånd
Size: Storlek:
Ridges per segment: Åsar per segment:
@@ -3968,6 +3969,9 @@ digits Malayalam Malayalam
Working CMYK Arbets-CMYK
Working CMYK (don't tag) Arbets-CMYK (tagga inte)
Working RGB (don't tag) Arbets-RGB (tagga inte)
Don't Color Manage This Document Färghantera inte det här dokumentet
Working RGB: {name} Arbets-RGB: {name}
Working CMYK: {name} Arbets-CMYK: {name}
Workspace Arbetslayout
Workspace {i} Arbetslayout {i}
Wrinkle Tool Skrynkelverktyg
@@ -5993,6 +5997,9 @@ theme Appearance Utseende
Softness Mjukhet
Ambient Light Omgivande ljus
Light behind the object Ljus bakom objektet
Move Light to Back Flytta ljus bakåt
Move Light to Front Flytta ljus framåt
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Dra ett ljus för att flytta det. Piltangenterna flyttar ljuset med fokus; Skift flyttar det 15° i taget.
Shadows Skuggor
Distance from Object Avstånd från objektet
Shadow Bounds Skuggans gränser
Can't render this file because it is too large.
+42
View File
@@ -3146,6 +3146,48 @@ fn selected_path_reason_is_translated() {
}
}
/// The refusals of a linked JavaScript file or a library's file outside the read roots, of a file
/// name that fits more than one linked JavaScript file, and of an appearance edit of the art a
/// graph design draws in a graph series have translated rows in languages that translate every
/// message. Each row keeps `{file}` and shows translated both alone and inside the
/// invalid-parameters error of the command that returns it.
#[test]
fn read_root_script_and_graph_design_reasons_are_translated() {
use vectorcraft_engine::EngineError;
let reasons = [
("document.export", "a linked JavaScript file is outside the read roots"),
("swatch.library.copyToUser", "the library's file is outside the read roots"),
("svgInteractivity.removeFile", "{file} names more than one linked JavaScript file"),
("appearance", "the art of a graph design in a graph series is drawn again from the design: edit the design to change it"),
];
let literals = message_literals();
for (_, reason) in reasons {
assert!(literals.contains(reason), "{reason}");
}
let mut rows = [0; 4];
for code in COMPLETE_MESSAGES {
let lang = Lang::from_code(code).expect("registered");
let (entries, _) = parse_entries(lang.0.source);
for (i, (cmd, reason)) in reasons.into_iter().enumerate() {
let row = entries.iter().find(|(ctx, src, _)| ctx == "@msg" && src == reason).map(|(_, _, tr)| tr.as_str());
let Some(row) = row else { continue };
rows[i] += 1;
assert_ne!(row, reason, "{code}");
assert_eq!(row.matches("{file}").count(), reason.matches("{file}").count(), "{code}: {row}");
let sample = reason.replace("{file}", "\"a.js\"");
let translated = row.replace("{file}", "\"a.js\"");
assert_eq!(message(lang, &sample), translated, "{code}");
let error = EngineError::BadParams { cmd: cmd.into(), msg: sample.clone() }.to_string();
assert_eq!(message(Lang::EN, &error), error);
let shown = message(lang, &error);
assert!(shown.contains(&translated) && !shown.contains(&sample) && shown.contains(&format!("`{cmd}`")), "{code}: {shown}");
}
}
for ((_, reason), count) in reasons.into_iter().zip(rows) {
assert!(count > 0, "no complete-message catalog has a `@msg` row for {reason:?}");
}
}
/// The errors a script's `File` and `Folder` objects, `vc.run` and `$.evalFile` raise are
/// translated in every language of [`COMPLETE_MESSAGES`], and so is the status bar's line for a run
/// that ends with one: the script's name, its place, the command ids and what the script itself
+7
View File
@@ -3218,6 +3218,7 @@ digits Malayalam Malayalam
Sixth Space Altıncı Uzay
Size Boyut
Size Tile to Art Sanata Boyut Döşeme
Move Tile with Art Döşemeyi Sanatla Taşı
Size/Leading Boyut/Satır Aralığı
Size: Boyut:
Ridges per segment: Segment başına sırtlar:
@@ -3827,6 +3828,9 @@ digits Malayalam Malayalam
Working CMYK CMYK'de çalışıyor
Working CMYK (don't tag) CMYK çalışıyor (etiketlemeyin)
Working RGB (don't tag) Çalışan RGB (etiketlemeyin)
Don't Color Manage This Document Bu belgede renk yönetimi yapma
Working RGB: {name} Çalışma RGB'si: {name}
Working CMYK: {name} Çalışma CMYK'sı: {name}
Workspace Çalışma Alanı
Workspace {i} Çalışma Alanı {i}
Wrinkle Tool Kırışıklık Aracı
@@ -5877,6 +5881,9 @@ theme Appearance Görünüm
Softness Yumuşaklık
Ambient Light Ortam Işığı
Light behind the object Nesnenin arkasındaki ışık
Move Light to Back Işığı Arkaya Taşı
Move Light to Front Işığı Öne Taşı
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Işığı taşımak için sürükleyin. Ok tuşları odaktaki ışığı taşır; Shift ile 15° adımlarla.
Shadows Gölgeler
Distance from Object Nesneden uzaklık
Shadow Bounds Gölge sınırları
Can't render this file because it is too large.
+9
View File
@@ -3457,6 +3457,7 @@ digits Malayalam Малаялам
Sixth Space Шоста шпація
Size Розмір
Size Tile to Art Підігнати плитку за малюнком
Move Tile with Art Переміщувати плитку разом із малюнком
Size/Leading Розмір/інтерліньяж
Size: Розмір:
Ridges per segment: Гребенів на сегмент:
@@ -4087,6 +4088,9 @@ effect Points Точки
Working CMYK Робочий CMYK
Working CMYK (don't tag) Робочий CMYK (без позначки)
Working RGB (don't tag) Робочий RGB (без позначки)
Don't Color Manage This Document Не керувати кольором цього документа
Working RGB: {name} Робочий RGB: {name}
Working CMYK: {name} Робочий CMYK: {name}
Workspace Робоче середовище
Workspace {i} Робоче середовище {i}
Wrinkle Tool Інструмент «Зморшки»
@@ -5247,6 +5251,7 @@ axis Both Обидві
@msg every numbered name for {name} is taken in the document's folder усі нумеровані імена для {name} уже зайняті в папці документа
@msg No linked files to look for Немає пов’язаних файлів для пошуку
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Зв’язок міститься в символі, дизайні діаграми, візерунку або масці непрозорості й не має місця на полотні
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it графіка дизайну діаграми в ряді даних діаграми перемальовується за дизайном: щоб змінити її, відредагуйте дизайн
Services Служби
Hide VectorCraft Приховати VectorCraft
Zoom Масштаб
@@ -5292,6 +5297,7 @@ axis Both Обидві
@msg automation filesystem access is not granted: write authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на запис: {path}
@msg automation path rejected: outside the read root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для читання {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} шлях автоматизації відхилено: поза кореневими папками для читання {roots}: {path}
@msg a linked JavaScript file is outside the read roots зв’язаний файл JavaScript перебуває поза кореневими папками для читання
@msg automation path rejected: outside the write root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для запису {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} шлях автоматизації відхилено: поза кореневими папками для запису {roots}: {path}
@msg automation path rejected: path is empty шлях автоматизації відхилено: шлях порожній
@@ -5697,6 +5703,9 @@ lighting Light Світло
Softness М'якість
Ambient Light Навколишнє світло
Light behind the object Світло позаду об'єкта
Move Light to Back Перемістити світло назад
Move Light to Front Перемістити світло вперед
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Перетягніть світло, щоб перемістити його. Клавіші зі стрілками переміщують вибране світло; із Shift — кроками по 15°.
Shadows Тіні
Distance from Object Відстань від об'єкта
Shadow Bounds Межі тіні
Can't render this file because it is too large.
+11
View File
@@ -3812,6 +3812,7 @@ mark-style Japanese Kiểu Nhật
Sixth Space Dấu cách 1/6
Size Kích thước
Size Tile to Art Kích thước ô theo đối tượng
Move Tile with Art Di chuyển ô cùng đối tượng
Size/Leading Cỡ chữ/Giãn dòng
Size: Kích thước:
Size: {size} Tracking: {tracking} Kích thước: {size} Giãn chữ: {tracking}
@@ -4462,6 +4463,9 @@ mark-style Japanese Kiểu Nhật
Working CMYK CMYK làm việc
Working CMYK (don't tag) CMYK làm việc (không gắn thẻ)
Working RGB (don't tag) RGB làm việc (không gắn thẻ)
Don't Color Manage This Document Không quản lý màu cho tài liệu này
Working RGB: {name} RGB làm việc: {name}
Working CMYK: {name} CMYK làm việc: {name}
Workspace Không gian làm việc
Workspace {i} Không gian làm việc {i}
Wrinkle Tool Công cụ nhăn
@@ -4698,6 +4702,9 @@ theme Appearance Giao diện
@msg automation path rejected: cannot resolve {path}: {e} đường dẫn tự động bị từ chối: không thể phân giải {path}: {e}
@msg automation path rejected: outside the read root {root}: {path} đường dẫn tự động bị từ chối: ngoài thư mục gốc đọc {root}: {path}
@msg automation path rejected: outside the read roots {roots}: {path} đường dẫn tự động bị từ chối: ngoài các thư mục gốc đọc {roots}: {path}
@msg a linked JavaScript file is outside the read roots một tệp JavaScript được liên kết nằm ngoài các thư mục gốc đọc
@msg the library's file is outside the read roots tệp của thư viện nằm ngoài các thư mục gốc đọc
@msg {file} names more than one linked JavaScript file {file} khớp với nhiều hơn một tệp JavaScript được liên kết
@msg automation path rejected: outside the write root {root}: {path} đường dẫn tự động bị từ chối: ngoài thư mục gốc ghi {root}: {path}
@msg automation path rejected: outside the write roots {roots}: {path} đường dẫn tự động bị từ chối: ngoài các thư mục gốc ghi {roots}: {path}
@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} đường dẫn tự động bị từ chối: duyệt cha bên dưới thư mục không tồn tại: {path}
@@ -5080,6 +5087,7 @@ theme Appearance Giao diện
@msg no library `{key}` (see {list}) không có thư viện `{key}` (xem {list})
@msg No linked files to look for Không có tệp liên kết để tìm kiếm
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Liên kết nằm trong ký hiệu, thiết kế biểu đồ, họa tiết hoặc mặt nạ độ mờ và không có vị trí trên canvas
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it hình vẽ của một thiết kế biểu đồ trong một chuỗi dữ liệu của biểu đồ được vẽ lại từ thiết kế: hãy sửa thiết kế để thay đổi nó
@msg no Live Paint edge near that point không có cạnh tô trực tiếp gần điểm đó
@msg no Live Paint face at that point không có mặt tô trực tiếp tại điểm đó
@msg no native format không có định dạng gốc
@@ -5702,6 +5710,9 @@ theme Appearance Giao diện
Softness Độ mềm
Ambient Light Ánh sáng môi trường
Light behind the object Ánh sáng phía sau đối tượng
Move Light to Back Đưa ánh sáng ra sau
Move Light to Front Đưa ánh sáng ra trước
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. Kéo một nguồn sáng để di chuyển nó. Phím mũi tên di chuyển nguồn sáng đang có tiêu điểm; Shift di chuyển theo bước 15°.
Shadows Bóng đổ
Distance from Object Khoảng cách từ đối tượng
Shadow Bounds Giới hạn bóng
Can't render this file because it contains an unexpected character in line 204 and column 5.
+9
View File
@@ -3280,6 +3280,7 @@ digits Malayalam 马拉雅拉姆数字
Sixth Space 六分之一空格
Size 大小
Size Tile to Art 使拼贴适合图稿
Move Tile with Art 拼贴随图稿移动
Size/Leading 字号/行距
Size: 尺寸:
Ridges per segment: 每段起伏数:
@@ -3926,6 +3927,9 @@ digits Malayalam 马拉雅拉姆数字
Working CMYK 工作 CMYK
Working CMYK (don't tag) 工作 CMYK (不加标签)
Working RGB (don't tag) 工作 RGB (不加标签)
Don't Color Manage This Document 不对此文档进行色彩管理
Working RGB: {name} 工作 RGB:{name}
Working CMYK: {name} 工作 CMYK:{name}
Workspace 工作区
Workspace {i} 工作区 {i}
Wrinkle Tool 褶皱工具
@@ -5006,6 +5010,7 @@ imageTrace Outline 轮廓
@msg every numbered name for {name} is taken in the document's folder 文档所在文件夹中 {name} 的所有带编号的名称均已被占用
@msg No linked files to look for 没有要查找的链接文件
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 该链接位于符号、图表设计、图案或不透明度蒙版中,在画布上没有位置
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 图表系列中图表设计的图稿会根据设计重新绘制:要更改它,请编辑设计
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它
@msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v}
@msg `colors` must be an array of colors, not {v} `colors` 必须是由颜色组成的数组,而不是 {v}
@@ -5025,6 +5030,7 @@ imageTrace Outline 轮廓
@msg automation filesystem access is not granted: write authority is absent: {path} 未授予自动化文件系统访问权限:缺少写入权限:{path}
@msg automation path rejected: outside the read root {root}: {path} 已拒绝自动化路径:位于读取根目录 {root} 之外:{path}
@msg automation path rejected: outside the read roots {roots}: {path} 已拒绝自动化路径:位于所有读取根目录 {roots} 之外:{path}
@msg a linked JavaScript file is outside the read roots 链接的 JavaScript 文件位于所有读取根目录之外
@msg automation path rejected: outside the write root {root}: {path} 已拒绝自动化路径:位于写入根目录 {root} 之外:{path}
@msg automation path rejected: outside the write roots {roots}: {path} 已拒绝自动化路径:位于所有写入根目录 {roots} 之外:{path}
@msg automation path rejected: path is empty 已拒绝自动化路径:路径为空
@@ -5970,6 +5976,9 @@ lighting Light 光源
Softness 柔和度
Ambient Light 环境光
Light behind the object 光源位于对象后方
Move Light to Back 将光源移到后方
Move Light to Front 将光源移到前方
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. 拖动光源可移动它。方向键移动具有焦点的光源;按住 Shift 以 15° 为步长。
Shadows 阴影
Distance from Object 与对象的距离
Shadow Bounds 阴影边界
Can't render this file because it is too large.
+9
View File
@@ -2705,6 +2705,7 @@ file-filter Templates 範本
@msg every numbered name for {name} is taken in the document's folder 文件所在資料夾中 {name} 的所有編號名稱都已被使用
@msg No linked files to look for 沒有要尋找的連結檔案
@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 此連結位於符號、圖表設計、圖樣或不透明度遮色片中,在畫布上沒有位置
@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 圖表數列中圖表設計的圖稿會依設計重新繪製:若要變更,請編輯設計
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它
@msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v}
@msg `colors` must be an array of colors, not {v} `colors` 必須是由顏色組成的陣列,而非 {v}
@@ -2716,6 +2717,7 @@ file-filter Templates 範本
@msg automation filesystem access is not granted: write authority is absent: {path} 未授予自動化檔案系統存取權:沒有寫入權限:{path}
@msg automation path rejected: outside the read root {root}: {path} 已拒絕自動化路徑:位於讀取根目錄 {root} 之外:{path}
@msg automation path rejected: outside the read roots {roots}: {path} 已拒絕自動化路徑:位於所有讀取根目錄 {roots} 之外:{path}
@msg a linked JavaScript file is outside the read roots 連結的 JavaScript 檔案位於所有讀取根目錄之外
@msg automation path rejected: outside the write root {root}: {path} 已拒絕自動化路徑:位於寫入根目錄 {root} 之外:{path}
@msg automation path rejected: outside the write roots {roots}: {path} 已拒絕自動化路徑:位於所有寫入根目錄 {roots} 之外:{path}
@msg automation path rejected: path is empty 已拒絕自動化路徑:路徑是空的
@@ -4538,6 +4540,7 @@ digits Malayalam 馬來亞拉姆數字
Sixth Space 六分之一空格
Size 大小
Size Tile to Art 拼貼符合圖稿大小
Move Tile with Art 拼貼隨圖稿移動
Size/Leading 大小/行距
Size: 大小:
Ridges per segment: 每段起伏數:
@@ -5189,6 +5192,9 @@ digits Malayalam 馬來亞拉姆數字
Working CMYK 使用中的 CMYK
Working CMYK (don't tag) 工作 CMYK (不加標記)
Working RGB (don't tag) 工作 RGB (不加標記)
Don't Color Manage This Document 不對此文件進行色彩管理
Working RGB: {name} 工作 RGB:{name}
Working CMYK: {name} 工作 CMYK:{name}
Workspace 工作區
Workspace {i} 工作區 {i}
Wrinkle Tool 皺摺工具
@@ -5802,6 +5808,9 @@ lighting Light 光源
Softness 柔和度
Ambient Light 環境光
Light behind the object 光源位於物件後方
Move Light to Back 將光源移到後方
Move Light to Front 將光源移到前方
Drag a light to move it. The arrow keys move the focused light; Shift steps 15°. 拖曳光源即可移動。方向鍵會移動具有焦點的光源;按住 Shift 以 15° 為間距。
Shadows 陰影
Distance from Object 與物件的距離
Shadow Bounds 陰影邊界
Can't render this file because it is too large.
+2 -2
View File
@@ -117,7 +117,7 @@ fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool {
/// it scrolls when it is taller than the window.
pub(super) fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(230.0);
widgets::menu_min_width(ui, 230.0);
render_items(app, ui, items, true, clicked);
});
}
@@ -137,7 +137,7 @@ pub(super) fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Ite
Entry::Sub(label, children) => {
ui.menu_button(label, |ui| {
widgets::menu_scroll(ui, |ui| {
ui.set_min_width(200.0);
widgets::menu_min_width(ui, 200.0);
render_items(app, ui, children, checks || only_toggles(app, children), clicked);
});
});

Some files were not shown because too many files have changed in this diff Show More