mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 13:36:53 +00:00
Merge main into fix/3d-face-budget-per-object
This commit is contained in:
+159
-42
@@ -5,7 +5,7 @@
|
||||
//! tint (`Paint::Solid.swatch` / `.tint`, `GradientStop.swatch` / `.tint`); a tint swatch is a
|
||||
//! swatch whose own paint links to its base that way. [`Document::map_solid_paints`] rewrites
|
||||
//! those links (and colours) wherever art and swatches hold them, copying only the nodes that
|
||||
//! change.
|
||||
//! change; [`Document::map_paints`] replaces whole paints of any kind in the same places.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
@@ -124,6 +124,52 @@ impl Document {
|
||||
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
|
||||
/// copied. Returns the number of changed paints.
|
||||
pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize {
|
||||
let mut n = self.map_art_paints(&mut relink(f));
|
||||
for gs in &mut self.graphic_styles {
|
||||
for it in &mut gs.appearance.items {
|
||||
n += usize::from(item_paint_mut(it).map_links(f));
|
||||
}
|
||||
}
|
||||
for w in self.swatches_iter_mut() {
|
||||
n += usize::from(w.paint.map_links(f));
|
||||
}
|
||||
let mut g = relink(f);
|
||||
for def in &mut self.char_styles {
|
||||
for key in ["fill", "stroke"] {
|
||||
n += usize::from(map_style_paint(&mut def.attrs, key, &mut g));
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Visit every paint in the places [`Document::map_solid_paints`] visits (fills, strokes, text
|
||||
/// runs, image colorize paints, dynamic symbol instances' own paints and graphs' stored series and
|
||||
/// label paints in the art, opacity masks included, symbol definitions, graph designs, pattern tiles and the tile that pattern
|
||||
/// editing's Cancel puts back; graphic styles; the swatches; character styles' fill and
|
||||
/// stroke), whatever its kind. `f(paint)` gives the paint to put in its place, or `None` to keep
|
||||
/// it. Only nodes holding a changed paint (and the paths to them) are copied. Returns the number
|
||||
/// of changed paints.
|
||||
pub fn map_paints(&mut self, f: &mut PaintFn) -> usize {
|
||||
let mut n = self.map_art_paints(f);
|
||||
for gs in &mut self.graphic_styles {
|
||||
for it in &mut gs.appearance.items {
|
||||
n += usize::from(replace_paint(item_paint_mut(it), f));
|
||||
}
|
||||
}
|
||||
for w in self.swatches_iter_mut() {
|
||||
n += usize::from(replace_paint(&mut w.paint, f));
|
||||
}
|
||||
for def in &mut self.char_styles {
|
||||
for key in ["fill", "stroke"] {
|
||||
n += usize::from(map_style_paint(&mut def.attrs, key, f));
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// [`Document::map_paints`] limited to the node trees: the art, symbol definitions, graph
|
||||
/// designs, pattern tiles and the tile that pattern editing's Cancel puts back.
|
||||
fn map_art_paints(&mut self, f: &mut PaintFn) -> usize {
|
||||
let mut n = map_trees(&mut self.layers, f);
|
||||
for s in &mut self.symbols {
|
||||
n += map_tree(&mut s.art, f);
|
||||
@@ -134,19 +180,6 @@ impl Document {
|
||||
for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) {
|
||||
n += map_trees(&mut p.art, f);
|
||||
}
|
||||
for gs in &mut self.graphic_styles {
|
||||
for it in &mut gs.appearance.items {
|
||||
n += usize::from(item_paint_mut(it).map_links(f));
|
||||
}
|
||||
}
|
||||
for w in self.swatches_iter_mut() {
|
||||
n += usize::from(w.paint.map_links(f));
|
||||
}
|
||||
for def in &mut self.char_styles {
|
||||
for key in ["fill", "stroke"] {
|
||||
n += usize::from(map_style_paint(&mut def.attrs, key, f));
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
@@ -156,9 +189,10 @@ impl Document {
|
||||
let roots: Vec<NodeId> =
|
||||
ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect();
|
||||
let mut n = 0;
|
||||
let mut g = relink(f);
|
||||
for id in roots {
|
||||
let Some(node) = self.node(id) else { continue };
|
||||
if let Some((new, count)) = map_node(node, f)
|
||||
if let Some((new, count)) = map_node(node, &mut g)
|
||||
&& let Some(slot) = self.node_mut(id)
|
||||
{
|
||||
*slot = new;
|
||||
@@ -176,7 +210,7 @@ impl Document {
|
||||
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
|
||||
let mut tree = |n: &Node| {
|
||||
walk_with_masks(n, &mut |m| {
|
||||
node_paints(m).chain(override_paints(m)).for_each(&mut *f);
|
||||
node_paints(m).chain(override_paints(m)).chain(graph_paints(m)).for_each(&mut *f);
|
||||
if let NodeKind::Mesh(mesh) = &m.kind {
|
||||
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
|
||||
}
|
||||
@@ -258,14 +292,39 @@ impl FreeSwatchNames {
|
||||
/// true when it changed them.
|
||||
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
|
||||
|
||||
/// Apply `f` to the paint a character style's attribute `key` holds, as [`Paint::map_links`]
|
||||
/// does, and store the result in the attribute. True when it changed.
|
||||
fn map_style_paint(attrs: &mut serde_json::Map<String, serde_json::Value>, key: &str, f: &mut LinkFn) -> bool {
|
||||
let Some(value) = attrs.get_mut(key) else { return false };
|
||||
let Ok(mut paint) = serde_json::from_value::<Paint>(value.clone()) else { return false };
|
||||
if !paint.map_links(f) {
|
||||
return false;
|
||||
/// What [`Document::map_paints`] applies to each paint: the paint to put in its place, or `None`
|
||||
/// to keep it.
|
||||
pub type PaintFn<'a> = dyn FnMut(&Paint) -> Option<Paint> + 'a;
|
||||
|
||||
/// `f` as a [`PaintFn`]: a solid or gradient paint with `f` applied ([`Paint::map_links`]) when
|
||||
/// `f` changed it, `None` for every other paint.
|
||||
fn relink<'a>(f: &'a mut LinkFn<'_>) -> impl FnMut(&Paint) -> Option<Paint> + 'a {
|
||||
move |p| match p {
|
||||
Paint::Solid { .. } | Paint::Gradient(_) => {
|
||||
let mut q = p.clone();
|
||||
q.map_links(f).then_some(q)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `f(p)` in place of `p` when it gives a paint. True when it did.
|
||||
fn replace_paint(p: &mut Paint, f: &mut PaintFn) -> bool {
|
||||
match f(p) {
|
||||
Some(q) => {
|
||||
*p = q;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `f` to the paint a character style's attribute `key` holds and store the result in the
|
||||
/// attribute. An attribute that is not a paint stays as it is. True when it changed.
|
||||
fn map_style_paint(attrs: &mut serde_json::Map<String, serde_json::Value>, key: &str, f: &mut PaintFn) -> bool {
|
||||
let Some(value) = attrs.get_mut(key) else { return false };
|
||||
let Ok(paint) = serde_json::from_value::<Paint>(value.clone()) else { return false };
|
||||
let Some(paint) = f(&paint) else { return false };
|
||||
match serde_json::to_value(&paint) {
|
||||
Ok(changed) => {
|
||||
*value = changed;
|
||||
@@ -323,6 +382,23 @@ pub fn override_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
n.extras().symbol_overrides.iter().flat_map(|o| [o.fill.as_ref(), o.stroke.as_ref()]).flatten()
|
||||
}
|
||||
|
||||
/// The paints a graph keeps for its series and its labels ([`crate::graph::GraphSpec`]), which its
|
||||
/// marks and labels take each time it is drawn again and which [`node_paints`] leaves out.
|
||||
pub fn graph_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
let g = n.extras().graph.as_deref();
|
||||
let series = g.into_iter().flat_map(|g| &g.series_paints).flat_map(|s| [s.fill.as_ref(), s.stroke.as_ref()]).flatten();
|
||||
let labels = g.into_iter().flat_map(|g| [&g.label_styles.values, &g.label_styles.categories, &g.label_styles.legend]).flatten();
|
||||
series.chain(labels.flat_map(|st| [&st.fill, &st.stroke]))
|
||||
}
|
||||
|
||||
/// Mutable [`graph_paints`] of graph `g`, in the same order.
|
||||
fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator<Item = &mut Paint> {
|
||||
let series = g.series_paints.iter_mut().flat_map(|s| [s.fill.as_mut(), s.stroke.as_mut()]).flatten();
|
||||
let l = &mut g.label_styles;
|
||||
let labels = [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten();
|
||||
series.chain(labels.flat_map(|st| [&mut st.fill, &mut st.stroke]))
|
||||
}
|
||||
|
||||
/// Mutable [`node_paints`], in the same order.
|
||||
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
let runs = match &mut n.kind {
|
||||
@@ -333,20 +409,11 @@ pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])).chain(colorize)
|
||||
}
|
||||
|
||||
/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then
|
||||
/// nothing was copied). Also returns the number of changed paints.
|
||||
fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
|
||||
// Try each own solid or gradient paint on a scratch copy first, so unchanged nodes are never
|
||||
// cloned.
|
||||
let mut changes: Vec<(usize, Paint)> = vec![];
|
||||
for (i, p) in node_paints(n).enumerate() {
|
||||
if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) {
|
||||
let mut q = p.clone();
|
||||
if q.map_links(f) {
|
||||
changes.push((i, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `n` with `f` applied to its subtree's paints, or `None` when nothing changed (then nothing was
|
||||
/// copied). Also returns the number of changed paints.
|
||||
fn map_node(n: &Node, f: &mut PaintFn) -> Option<(Node, usize)> {
|
||||
// `f` gives each own paint's replacement first, so unchanged nodes are never cloned.
|
||||
let changes: Vec<(usize, Paint)> = node_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect();
|
||||
let mut count = changes.len();
|
||||
let mut out: Option<Node> = None;
|
||||
if !changes.is_empty() {
|
||||
@@ -366,15 +433,29 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
|
||||
let mut overrides = n.extras().symbol_overrides.clone();
|
||||
let mut k = 0;
|
||||
for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() {
|
||||
if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) {
|
||||
k += 1;
|
||||
}
|
||||
k += usize::from(replace_paint(p, f));
|
||||
}
|
||||
if k > 0 {
|
||||
out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides;
|
||||
count += k;
|
||||
}
|
||||
}
|
||||
// A graph's series and label paints, which its marks and labels take when it is drawn again.
|
||||
let graph: Vec<(usize, Paint)> = graph_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect();
|
||||
if !graph.is_empty() {
|
||||
count += graph.len();
|
||||
let m = out.get_or_insert_with(|| n.clone());
|
||||
if let Some(spec) = m.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
|
||||
let mut graph = graph.into_iter().peekable();
|
||||
for (i, p) in graph_paints_mut(spec).enumerate() {
|
||||
if graph.peek().is_some_and(|c| c.0 == i)
|
||||
&& let Some((_, q)) = graph.next()
|
||||
{
|
||||
*p = q;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
for (i, c) in ch.iter().enumerate() {
|
||||
if let Some((new, k)) = map_node(c, f) {
|
||||
@@ -415,6 +496,13 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
|
||||
/// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only
|
||||
/// the nodes holding a changed paint are copied. Returns the number of changed paints.
|
||||
pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize {
|
||||
map_node_paints(n, &mut relink(f))
|
||||
}
|
||||
|
||||
/// [`Document::map_paints`] for art a document keeps outside its trees, such as the art of a brush:
|
||||
/// `f` applied to every paint of `n` and its subtree (opacity masks included). Only the nodes
|
||||
/// holding a changed paint are copied. Returns the number of changed paints.
|
||||
pub fn map_node_paints(n: &mut Node, f: &mut PaintFn) -> usize {
|
||||
match map_node(n, f) {
|
||||
Some((new, count)) => {
|
||||
*n = new;
|
||||
@@ -424,7 +512,7 @@ pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
|
||||
fn map_tree(n: &mut Arc<Node>, f: &mut PaintFn) -> usize {
|
||||
match map_node(n, f) {
|
||||
Some((new, count)) => {
|
||||
*n = Arc::new(new);
|
||||
@@ -434,7 +522,7 @@ fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_trees(v: &mut [Arc<Node>], f: &mut LinkFn) -> usize {
|
||||
fn map_trees(v: &mut [Arc<Node>], f: &mut PaintFn) -> usize {
|
||||
v.iter_mut().map(|n| map_tree(n, f)).sum()
|
||||
}
|
||||
|
||||
@@ -593,6 +681,35 @@ mod tests {
|
||||
assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 0, "nothing left to change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_reaches_the_series_and_label_paints_a_graph_keeps() {
|
||||
let (mut d, a, _) = doc();
|
||||
let red = linked(Color::rgb(1.0, 0.0, 0.0), "Brand");
|
||||
let spec = crate::graph::GraphSpec {
|
||||
series_paints: vec![crate::SeriesPaint { fill: Some(red.clone()), stroke: Some(red.clone()), ..Default::default() }],
|
||||
label_styles: crate::graph::LabelStyles {
|
||||
legend: Some(crate::CharStyle { fill: red.clone(), stroke: Paint::None, ..Default::default() }),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec));
|
||||
let mut seen = 0;
|
||||
d.visit_paints(&mut |p| seen += usize::from(*p == red));
|
||||
assert_eq!(seen, 4, "the fill and the graph's series fill, series stroke and legend fill");
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let mut to_blue = |c: &mut Color, s: &mut Option<String>, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue;
|
||||
assert_eq!(d.map_solid_paints(&mut to_blue), 4);
|
||||
let g = d.node(a).unwrap().extras().graph.clone().unwrap();
|
||||
assert_eq!(g.series_paints[0].fill, Some(linked(blue, "Brand")));
|
||||
assert_eq!(g.series_paints[0].stroke, Some(linked(blue, "Brand")));
|
||||
let legend = g.label_styles.legend.unwrap();
|
||||
assert_eq!((legend.fill, legend.stroke), (linked(blue, "Brand"), Paint::None));
|
||||
let mut cleared = |p: &Paint| (*p == linked(blue, "Brand")).then_some(Paint::None);
|
||||
assert_eq!(d.map_paints(&mut cleared), 4, "the fill and the graph's three");
|
||||
assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_within_ids_visits_each_paint_once() {
|
||||
let (mut d, a, b) = doc();
|
||||
|
||||
@@ -508,6 +508,21 @@ pub enum Mojikumi {
|
||||
/// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket,
|
||||
/// comma or full stop ending a line is set half width.
|
||||
LineEndHalf,
|
||||
/// YakumonoHankaku: every opening and closing bracket, comma and full stop is set half width,
|
||||
/// a middle dot loses a quarter em on each side, and a quarter em separates Japanese and Latin
|
||||
/// letters or digits.
|
||||
YakumonoHankaku,
|
||||
/// GyomatsuYakumonoHankaku: punctuation keeps its full width, except that an opening bracket
|
||||
/// starting a line is set flush with the line's start, a closing bracket, comma or full stop
|
||||
/// ending a line is set half width and a middle dot ending a line loses the quarter em after
|
||||
/// it; a quarter em separates Japanese and Latin letters or digits.
|
||||
GyomatsuYakumonoHankaku,
|
||||
/// GyomatsuYakumonoZenkaku: punctuation keeps its full width at a line's end too; a quarter em
|
||||
/// separates Japanese and Latin letters or digits.
|
||||
GyomatsuYakumonoZenkaku,
|
||||
/// YakumonoZenkaku: punctuation keeps its full width, with no added space between Japanese and
|
||||
/// Latin text.
|
||||
YakumonoZenkaku,
|
||||
}
|
||||
|
||||
impl Mojikumi {
|
||||
|
||||
@@ -397,7 +397,9 @@ pub(crate) fn commit_new_art_colors(s: &mut Session) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaults) -> Result<()> {
|
||||
/// Record new-art paints `before` and the session's current ones with the step just made (or the
|
||||
/// interaction or undo group in progress), so Undo puts `before` back and Redo the current ones.
|
||||
pub(super) fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaults) -> Result<()> {
|
||||
let after = s.paint.clone();
|
||||
if before != after || s.batch_stash.is_some() || s.doc()?.undo_group.is_some() {
|
||||
let st = s.doc_mut()?;
|
||||
@@ -422,7 +424,9 @@ fn record_new_art_colors(s: &mut Session, before: vectorcraft_tools::PaintDefaul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_art_colors_before(s: &Session) -> Result<vectorcraft_tools::PaintDefaults> {
|
||||
/// The new-art paints an undo step that starts now puts back: those at the start of the undo group
|
||||
/// or interaction in progress, else the session's current ones.
|
||||
pub(super) fn new_art_colors_before(s: &Session) -> Result<vectorcraft_tools::PaintDefaults> {
|
||||
let st = s.doc()?;
|
||||
Ok(st.undo_group.as_ref().map(|g| &g.paint).or_else(|| st.interaction.as_ref().map(|it| &it.paint)).cloned().unwrap_or_else(|| s.paint.clone()))
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use vectorcraft_render::effects;
|
||||
use super::appearance::{ItemTarget, aligned, appearance_targets, index_param, item_indices, item_target, item_target_at};
|
||||
use super::expand::has_seeded;
|
||||
use super::rasterfx;
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param, switch_or};
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or};
|
||||
|
||||
/// The name of the `svg.filter` effect drawing `xml`: its `<filter>` id. An effect names the
|
||||
/// document's filter of that name, so `xml` that differs from the document's filter of the same
|
||||
@@ -76,7 +76,7 @@ fn effect_specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
[],
|
||||
None,
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
|
||||
"{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}",
|
||||
has_doc,
|
||||
set_params
|
||||
),
|
||||
@@ -650,15 +650,28 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
|
||||
return vec![];
|
||||
};
|
||||
let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] };
|
||||
// A 3D effect whose type `threeDType` changes takes the new effect's parameters.
|
||||
// A 3D effect whose type `threeDType` changes, and a Warp whose style `warpStyle` changes, take
|
||||
// the new effect's parameters.
|
||||
let kind = three_d_type(p, C).ok().flatten();
|
||||
let warp = p.get("warpStyle").and_then(Value::as_str).filter(|style| effects::WarpStyle::from_id(style).is_some());
|
||||
let effect = |&(id, at, index): &(NodeId, Option<usize>, usize)| {
|
||||
let id = &st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id;
|
||||
Some(kind.filter(|_| effects::is_three_d(id)).map_or_else(|| id.clone(), str::to_string))
|
||||
Some(edited_effect_id(id, kind, warp))
|
||||
};
|
||||
targets.iter().filter_map(effect).collect()
|
||||
}
|
||||
|
||||
/// The id effect `id` has once `effect.setParams` sets its 3D type to `kind` or its Warp style to
|
||||
/// `warp` (each when given and when `id` is an effect of that family).
|
||||
fn edited_effect_id(id: &str, kind: Option<&str>, warp: Option<&str>) -> String {
|
||||
let is_warp = id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_some();
|
||||
match (kind, warp) {
|
||||
(Some(to), _) if effects::is_three_d(id) => to.to_string(),
|
||||
(_, Some(style)) if is_warp => format!("warp.{style}"),
|
||||
_ => id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The effect `threeDType` in `p` turns a 3D effect into ([`effects::THREE_D_TYPES`]), when given.
|
||||
fn three_d_type(p: &Value, cmd: &str) -> Result<Option<&'static str>> {
|
||||
let key = "threeDType";
|
||||
@@ -719,6 +732,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(format!("warp.{style}"))
|
||||
})
|
||||
.transpose()?;
|
||||
check_switches(p, &["visible"], C)?;
|
||||
let visible = p.get("visible").and_then(Value::as_bool);
|
||||
let item = item_target(s, p, C)?;
|
||||
let edited = set_params_effects(s, p);
|
||||
|
||||
@@ -664,6 +664,50 @@ fn warp_options_non_warp_target_rejection_is_atomic() {
|
||||
assert!(!state.is_dirty());
|
||||
}
|
||||
|
||||
/// `effect.setParams` refuses a `visible` that is not true or false, when called and when a step
|
||||
/// plays, and the effect, the document and the undo history stay as they were. `null` leaves the
|
||||
/// visibility as it is.
|
||||
#[test]
|
||||
fn set_params_refuses_a_visible_that_is_not_true_or_false() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": null})).unwrap();
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for visible in [json!(1), json!(0), json!("false"), json!([])] {
|
||||
let p = json!({"index": 0, "item": null, "visible": visible, "params": {"size": 9}});
|
||||
for replay in [false, true] {
|
||||
let r = if replay { s.replay("effect.setParams", &p) } else { s.execute("effect.setParams", &p) };
|
||||
let err = r.err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(err.contains("`visible` must be true or false"), "{visible} (replay {replay}): {err}");
|
||||
let state = s.doc().unwrap();
|
||||
assert_eq!(state.doc, before, "{visible} (replay {replay})");
|
||||
assert_eq!(state.history.undo.len(), undo, "{visible} (replay {replay})");
|
||||
assert!(!state.is_dirty());
|
||||
}
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 0, "item": null, "visible": null, "params": {"size": 9}})).unwrap();
|
||||
let n = node(&s, id);
|
||||
assert!(n.appearance.effects[0].visible);
|
||||
assert_eq!(n.appearance.effects[0].params["size"], 9);
|
||||
}
|
||||
|
||||
/// The effect `effect.setParams` checks the parameters against is the one it leaves: a Warp's new
|
||||
/// style and a 3D effect's new type, and the effect as it is otherwise.
|
||||
#[test]
|
||||
fn set_params_checks_the_effect_it_leaves() {
|
||||
use super::edited_effect_id;
|
||||
assert_eq!(edited_effect_id("warp.arc", None, Some("flag")), "warp.flag");
|
||||
assert_eq!(edited_effect_id("warp.arc", None, None), "warp.arc");
|
||||
assert_eq!(edited_effect_id("distort.roughen", None, Some("flag")), "distort.roughen");
|
||||
assert_eq!(edited_effect_id("threeD.extrude", Some("threeD.revolve"), None), "threeD.revolve");
|
||||
assert_eq!(edited_effect_id("warp.arc", Some("threeD.revolve"), None), "warp.arc");
|
||||
let (mut s, _) = session_with_rect();
|
||||
s.execute("effect.apply", &json!({"effect": "warp.arc", "item": null})).unwrap();
|
||||
let p = json!({"index": 0, "item": null, "warpStyle": "flag", "params": {"bend": 25}});
|
||||
assert_eq!(super::set_params_effects(&s, &p), ["warp.flag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_remove_and_set_params() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
|
||||
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
|
||||
//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`.
|
||||
//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT).
|
||||
//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PageMaker).
|
||||
//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
|
||||
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
|
||||
//! - `metafile`: EMF and WMF export, open and place.
|
||||
//! - `metafile`: EMF and WMF export, open and place; PICT open and place.
|
||||
//! - `hpgl`: HP-GL export options and plots.
|
||||
//! - `importer`: files that importer plug-ins open (`document.open {importer}`, and extensions no
|
||||
//! built-in format reads).
|
||||
@@ -217,6 +217,8 @@ mod tests_dxfimport;
|
||||
mod tests_hpgl;
|
||||
#[cfg(test)]
|
||||
mod tests_metafile;
|
||||
#[cfg(test)]
|
||||
mod tests_pict;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfoutput;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
//! DXF export for every DXF path (`document.export {format: dxf}`, `document.exportDxf`, Export As
|
||||
//! → DXF Options): the options parsed from the params, one drawing per artboard with Use
|
||||
//! Artboards, and the formats that can't be written (DWG, PICT) with what to use instead.
|
||||
//! Artboards, and the formats that can't be written (DWG, PageMaker) with what to use instead.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -106,12 +106,6 @@ pub const UNSUPPORTED: &[Unsupported] = &[
|
||||
extensions: &["dwg"],
|
||||
hint: "DWG is not available (no openly licensed writer exists): export DXF instead, which CAD apps open",
|
||||
},
|
||||
Unsupported {
|
||||
id: "pict",
|
||||
label: "PICT",
|
||||
extensions: &["pict", "pct", "pic"],
|
||||
hint: "PICT is a retired format and is not supported: convert the file to PDF or PNG first",
|
||||
},
|
||||
Unsupported {
|
||||
id: "pmd",
|
||||
label: "PageMaker",
|
||||
|
||||
@@ -520,6 +520,8 @@ pub const FORMATS: &[Format] = &[
|
||||
reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true),
|
||||
// jxl-oxide reads a JPEG XL file's first frame, which is stored as PNG. Exports don't write it.
|
||||
reader("jxl", "JPEG XL", &["jxl"], "image/jxl", true),
|
||||
// QuickDraw pictures, read by `vectorcraft_metafile::import_pict` into paths, type and images.
|
||||
reader("pict", "PICT", &["pct", "pic", "pict"], "image/x-pict", false),
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
@@ -564,6 +566,9 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"dxf",
|
||||
"emf",
|
||||
"wmf",
|
||||
"pct",
|
||||
"pic",
|
||||
"pict",
|
||||
"plt",
|
||||
"hpgl",
|
||||
"hgl",
|
||||
@@ -581,10 +586,10 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000, HEIC/HEIF or JPEG XL type, nor one for the `.pns`, `.dib` and `.rle` names of
|
||||
/// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS
|
||||
/// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it.
|
||||
/// The packaging declares no type for HP-GL plot files either.
|
||||
/// The packaging declares no type for HP-GL plot files or PICT pictures either.
|
||||
pub const UNASSOCIATED_EXTS: &[&str] = &[
|
||||
"psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc",
|
||||
"heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg",
|
||||
"heic", "heif", "jxl", "plt", "hpgl", "hgl", "hpg", "pct", "pic", "pict",
|
||||
];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
@@ -658,6 +663,9 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"dxf",
|
||||
"emf",
|
||||
"wmf",
|
||||
"pct",
|
||||
"pic",
|
||||
"pict",
|
||||
"plt",
|
||||
"hpgl",
|
||||
"hgl",
|
||||
|
||||
@@ -134,6 +134,12 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
||||
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
|
||||
return Some(f);
|
||||
}
|
||||
// A version 2 PICT is known by its version opcodes after the picture's frame, at byte 522 of a
|
||||
// file or byte 10 of a bare picture (version 1 by its file's name), so a format whose
|
||||
// signature starts the file is checked first.
|
||||
if vectorcraft_metafile::is_pict(bytes) {
|
||||
return format("pict");
|
||||
}
|
||||
if clipboard::looks_like_svg(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])) {
|
||||
return format("svg");
|
||||
}
|
||||
@@ -505,6 +511,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let (doc, warnings) = super::metafile::import(bytes)?;
|
||||
(doc, warnings, false)
|
||||
}
|
||||
"pict" => {
|
||||
let (doc, warnings) = super::metafile::import_pict(bytes)?;
|
||||
(doc, warnings, false)
|
||||
}
|
||||
"hpgl" => {
|
||||
let i = vectorcraft_cad::hpgl::import(bytes).map_err(err)?;
|
||||
(i.document, i.warnings, false)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! `document.export {format: emf|wmf}` and Export As (one picture of an artboard, or one per
|
||||
//! artboard with Use Artboards; raster effects as images, rendered at the document's raster
|
||||
//! effects resolution), `document.open` and File → Place (one artboard, the picture's frame).
|
||||
//! QuickDraw pictures (PICT) open and place the same way; they are never written.
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::Document;
|
||||
@@ -52,3 +53,9 @@ pub(super) fn import(bytes: &[u8]) -> Result<(Document, Vec<String>)> {
|
||||
let i = vectorcraft_metafile::import(bytes).map_err(super::err)?;
|
||||
Ok((i.document, i.warnings))
|
||||
}
|
||||
|
||||
/// A QuickDraw picture's document and the import's warnings.
|
||||
pub(super) fn import_pict(bytes: &[u8]) -> Result<(Document, Vec<String>)> {
|
||||
let p = vectorcraft_metafile::import_pict(bytes).map_err(super::err)?;
|
||||
Ok((p.document, p.warnings))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! DXF export through the commands: options, artboards, selection, images, and the formats that
|
||||
//! can't be written (DWG, PICT).
|
||||
//! can't be written (DWG, PageMaker).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -275,23 +275,24 @@ fn formats_list_dxf_and_what_can_not_be_written() {
|
||||
}
|
||||
assert!(r["writable"].as_array().unwrap().contains(&json!("dxf")));
|
||||
let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(unsupported, ["dwg", "pict", "pmd"]);
|
||||
assert_eq!(unsupported, ["dwg", "pmd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dwg_and_pict_say_what_to_use_instead() {
|
||||
fn dwg_and_pagemaker_say_what_to_use_instead() {
|
||||
let mut s = two_boards();
|
||||
for p in [json!({"format": "dwg"}), json!({"path": "/tmp/plan.DWG"})] {
|
||||
let e = s.execute("document.export", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("DWG is not available") && e.contains("DXF"), "{p}: {e}");
|
||||
}
|
||||
// PICT opens; it isn't written.
|
||||
let e = s.execute("document.export", &json!({"format": "pict"})).unwrap_err().to_string();
|
||||
assert!(e.contains("PICT is a retired format"), "{e}");
|
||||
assert!(e.contains("PICT files can be opened but not written"), "{e}");
|
||||
let e = s
|
||||
.execute("document.open", &json!({"name": "old.pct", "dataBase64": vectorcraft_format::base64_encode(&[0u8; 600])}))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains("old.pct") && e.contains("PICT"), "{e}");
|
||||
assert!(e.contains("not a PICT file"), "{e}");
|
||||
let e = s
|
||||
.execute("document.open", &json!({"name": "site.dwg", "dataBase64": vectorcraft_format::base64_encode(b"AC1032\0\0")}))
|
||||
.unwrap_err()
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! PICT pictures through the commands: `document.open` by extension or by content, File › Place as
|
||||
//! one group clipped to the picture's frame, the dialogs' filters and the format list, and files
|
||||
//! that aren't pictures, or a PICT export, refused with a message.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
vectorcraft_format::base64_encode(bytes)
|
||||
}
|
||||
|
||||
/// A version 2 PICT file of a 200 × 100 pt frame: a red rectangle and a line.
|
||||
fn picture() -> Vec<u8> {
|
||||
let words = |v: &[i16]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
|
||||
let mut b = vec![0; 512];
|
||||
b.extend(words(&[0, 0, 0, 100, 200]));
|
||||
b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]);
|
||||
b.extend(words(&[-1, 0]));
|
||||
b.extend([0; 20]);
|
||||
b.extend(words(&[0x1A, -1, 0, 0, 0x31, 10, 20, 50, 80, 0x20, 70, 10, 70, 190, 0xFF]));
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_open_reads_a_picture() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.open", &json!({"name": "logo.pct", "dataBase64": b64(&picture())})).unwrap();
|
||||
assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("pict"), Some("logo.pct")), "{r}");
|
||||
assert_eq!(r["warnings"], json!([]));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.artboards[0].rect.size(), (200.0, 100.0).into());
|
||||
let mut paths = vec![];
|
||||
st.doc.walk(|n| {
|
||||
if let NodeKind::Path { .. } = n.kind {
|
||||
paths.push(n.clone());
|
||||
}
|
||||
});
|
||||
assert_eq!(paths.len(), 2);
|
||||
let b = paths[0].geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 20.0).abs() < 1e-9 && (b.width() - 60.0).abs() < 1e-9, "{b:?}");
|
||||
// Opened as artwork: Save asks for a name.
|
||||
assert!(st.path.is_none());
|
||||
|
||||
// A picture opens by its content under a name no format reads, and in each of its names.
|
||||
for name in ["logo.PICT", "logo.pic", "logo.bin", "logo"] {
|
||||
let r = Session::new().execute("document.open", &json!({"name": name, "dataBase64": b64(&picture())})).unwrap();
|
||||
assert_eq!(r["format"].as_str(), Some("pict"), "{name}: {r}");
|
||||
}
|
||||
assert_eq!(detect("logo.dat", &picture()).map(|f| f.id), Some("pict"));
|
||||
assert_eq!(detect("bare.dat", &picture()[512..]).map(|f| f.id), Some("pict"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_place_places_a_picture_as_one_clipped_group() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
let p = json!({"name": "logo.pct", "dataBase64": b64(&picture()), "at": [150, 150]});
|
||||
let info = s.execute("file.place.info", &p).unwrap();
|
||||
assert_eq!(info["format"], "pict", "{info}");
|
||||
assert!((info["width"].as_f64().unwrap() - 200.0).abs() < 1e-9, "{info}");
|
||||
let r = s.execute("file.place", &p).unwrap();
|
||||
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("pict"), Some(1)), "{r}");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let group = doc.node(vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(group.name.as_deref(), Some("logo.pct"));
|
||||
let NodeKind::Group { clip: true, .. } = &group.kind else { panic!("a clipping group, got {:?}", group.kind) };
|
||||
let b = group.geometric_bounds().unwrap();
|
||||
assert!((b.width() - 200.0).abs() < 1e-6 && (b.height() - 100.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_are_not_pictures_are_refused() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
|
||||
let e = s.execute("document.open", &json!({"name": "old.pct", "dataBase64": b64(&[0; 600])})).expect_err("refused");
|
||||
assert!(e.to_string().contains("not a PICT file"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), (100.0, 50.0).into());
|
||||
assert!(s.execute("file.place", &json!({"name": "old.pct", "dataBase64": b64(&[0; 600])})).is_err());
|
||||
// Other formats' files aren't taken for pictures by their content.
|
||||
// A file that starts with a PNG signature and holds a picture's version opcodes at byte 522.
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
png.extend(&picture()[8..]);
|
||||
assert!(detect("x.png", &png).is_none_or(|f| f.id != "pict"));
|
||||
let e = s.execute("document.export", &json!({"format": "pict"})).expect_err("refused").to_string();
|
||||
assert!(e.contains("PICT files can be opened but not written"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dialogs_and_the_format_list_name_pict() {
|
||||
let exts = ["pct", "pic", "pict"];
|
||||
for e in exts {
|
||||
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
|
||||
assert_eq!(format_for_name(&format!("a.{e}")).map(|f| f.id), Some("pict"), "{e}");
|
||||
}
|
||||
let filter = ("PICT", &exts[..]);
|
||||
assert!(open_filters().any(|f| f == filter) && place_filters().any(|f| f == filter));
|
||||
let r = Session::new().execute("document.formats", &json!({})).unwrap();
|
||||
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "pict").unwrap().clone();
|
||||
assert_eq!((f["read"].as_bool(), f["write"].as_bool(), f["raster"].as_bool()), (Some(true), Some(false), Some(false)), "{f}");
|
||||
assert_eq!(f["mime"], "image/x-pict");
|
||||
assert!(writable_format(None, Some("x/logo.pct")).is_err());
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -15,8 +15,8 @@
|
||||
//! commands a command runs, the steps of a `command.batch` or an `action.batch` included: a
|
||||
//! misspelled `widht` no longer runs the command with its default. So are the `params` of an
|
||||
//! effect (#1592): `effect.apply` and
|
||||
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
|
||||
//! default, with a parameter error ([`check_effect_params`]).
|
||||
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than the
|
||||
//! effect reads, with a parameter error ([`check_effect_params`]).
|
||||
//!
|
||||
//! A command with a parameter schema (`CommandSpec::schema`) is also checked against the schema
|
||||
//! before it runs ([`crate::schemas::check`]): the schema refuses a value of the wrong type or out
|
||||
@@ -263,15 +263,15 @@ pub(crate) fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError
|
||||
}
|
||||
|
||||
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
|
||||
/// effect takes (those of its dialog defaults and of its params description), each of its
|
||||
/// default's type, a `color` the effect can draw ([`check_effect_color`]) and a Levels or Curves
|
||||
/// `channel` the effect has ([`check_effect_channel`]). Numbers out of range pass: the effect
|
||||
/// clamps them when it draws. An effect plug-in resolves its own against its manifest, and an
|
||||
/// unknown effect is the command's to report.
|
||||
/// effect takes (those of its dialog defaults and of its params description), each of the type
|
||||
/// the effect reads ([`check_effect_types`]), a `color` the effect can draw ([`check_effect_color`])
|
||||
/// and a Levels or Curves `channel` the effect has ([`check_effect_channel`]). Numbers out of range
|
||||
/// pass: the effect clamps them when it draws. An installed effect plug-in's are checked against its
|
||||
/// manifest, and an unknown effect is the command's to report.
|
||||
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
let Value::Object(given) = params else { return Ok(()) };
|
||||
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
|
||||
return Ok(());
|
||||
return check_effect_types(id, effect, params);
|
||||
}
|
||||
let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) };
|
||||
let defaults = info.defaults.as_object();
|
||||
@@ -282,18 +282,10 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
for (key, v) in given {
|
||||
if !keys.contains(&key.as_str()) {
|
||||
return Err(unknown(id, key, &keys));
|
||||
}
|
||||
match defaults.and_then(|d| d.get(key)) {
|
||||
Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))),
|
||||
Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))),
|
||||
// (A colour is also taken as `[r, g, b]` or as a colour object.)
|
||||
Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) {
|
||||
return Err(unknown(id, key, &keys));
|
||||
}
|
||||
check_effect_types(id, effect, params)?;
|
||||
// An SVG filter's XML is one drawable <filter> element.
|
||||
if effect == vectorcraft_render::effects::SVG_FILTER
|
||||
&& let Some(xml) = given.get("xml")
|
||||
@@ -329,6 +321,59 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
|
||||
check_effect_channel(id, effect, params)
|
||||
}
|
||||
|
||||
/// The type of the values in `params` of effect `effect`, as command `id` was given them: a key
|
||||
/// the effect has a dialog default for takes a value of its default's type, a key only the
|
||||
/// effect's params description names takes the type the effect reads ([`undefaulted_type`]), and
|
||||
/// an installed effect plug-in's take the types its manifest gives them. The `style` of Crop Marks
|
||||
/// is `roman` or `japanese`. A number given as text (`"10 pt"`), `true` for a number, `1` for a
|
||||
/// switch and `null` for either are refused.
|
||||
fn check_effect_types(id: &str, effect: &str, params: &Value) -> Result<()> {
|
||||
let Value::Object(given) = params else { return Ok(()) };
|
||||
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
|
||||
return match vectorcraft_plugins::effect::installed(effect) {
|
||||
Some(plugin) => plugin.manifest().resolve_params(params).map(drop).map_err(|why| bad(id, why.to_string())),
|
||||
None => Ok(()),
|
||||
};
|
||||
}
|
||||
let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) };
|
||||
for (key, v) in given {
|
||||
let want = info.defaults.get(key).cloned().or_else(|| undefaulted_type(effect, key));
|
||||
match want {
|
||||
Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))),
|
||||
Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))),
|
||||
// (A colour is also taken as `[r, g, b]` or as a colour object.)
|
||||
Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Crop Marks draw one of two styles.
|
||||
if effect == vectorcraft_render::effects::CROP_MARKS
|
||||
&& let Some(style) = given.get("style")
|
||||
&& style.as_str().and_then(vectorcraft_doc::marks::MarkStyle::parse).is_none()
|
||||
{
|
||||
return Err(bad(id, "`style` must be roman or japanese"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A value of the type effect `effect` reads for `key`, a parameter its params description names
|
||||
/// without a dialog default: the earlier 3D Perspective and light (`perspective`, `lightAzimuth`,
|
||||
/// `lightElevation`) and Scribble `variation` are numbers, an Outline Stroke's `width` is a number
|
||||
/// and a Gaussian Blur's `linear` is true or false. A Glass or Texturizer `textureImage`, an SVG
|
||||
/// filter's `name` and `xml`, a Warp's `version` and the `style` of Crop Marks have checks of their
|
||||
/// own; they aren't listed.
|
||||
pub(crate) fn undefaulted_type(effect: &str, key: &str) -> Option<Value> {
|
||||
use vectorcraft_render::effects;
|
||||
let number = || Some(Value::from(0.0));
|
||||
match key {
|
||||
"perspective" | "lightAzimuth" | "lightElevation" if effects::is_three_d(effect) => number(),
|
||||
"variation" if effect == "stylize.scribble" => number(),
|
||||
"width" if effect == "path.outlineStroke" => number(),
|
||||
"linear" if effect == "blur.gaussian" => Some(Value::Bool(false)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`,
|
||||
/// `#rrggbbaa`, black, white, red, green, blue), `[r, g, b]` whose first three entries are numbers
|
||||
/// (read on a 0..1 scale) or a color object (`{"model": "rgb", "r": …}`)? The effect draws its
|
||||
|
||||
@@ -14,6 +14,7 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint};
|
||||
use vectorcraft_doc::swatches::PaintFn;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
@@ -54,7 +55,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
in_pattern_edit,
|
||||
pattern_done
|
||||
),
|
||||
cmd!("object.pattern.cancel", "Cancel", [], None, "{} leave pattern editing mode, discarding changes", in_pattern_edit, pattern_cancel),
|
||||
cmd!(
|
||||
"object.pattern.cancel",
|
||||
"Cancel",
|
||||
[],
|
||||
None,
|
||||
"{} leave pattern editing mode, discarding changes; a pattern made for this edit is removed with its swatches, and every paint of it, new art's fill and stroke included, becomes none",
|
||||
in_pattern_edit,
|
||||
pattern_cancel
|
||||
),
|
||||
cmd!(
|
||||
"object.pattern.saveCopy",
|
||||
"Save a Copy",
|
||||
@@ -78,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Delete Pattern",
|
||||
[],
|
||||
None,
|
||||
"{name} delete a pattern swatch (objects painted with it lose that paint)",
|
||||
"{name} delete a pattern and its swatches; every paint of it, new art's fill and stroke included, becomes none → {unpainted: the number of document paints cleared}",
|
||||
has_doc,
|
||||
pattern_delete
|
||||
),
|
||||
@@ -403,26 +412,96 @@ pub(crate) fn finish_edit(d: &mut Document, sel: &mut Selection) -> Result<Strin
|
||||
Ok(pe.pattern)
|
||||
}
|
||||
|
||||
/// Leave pattern editing mode, discarding the changes (`object.pattern.cancel`). An existing
|
||||
/// pattern gets its definition back, its name included when Pattern Options renamed it while it was
|
||||
/// edited and no other pattern, swatch or color group has taken that name since. A new pattern is
|
||||
/// removed with its swatches, and every paint of it left in the document and in the fill, stroke
|
||||
/// and appearance new art takes becomes no paint ([`forget_pattern`]).
|
||||
fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.edit("Cancel Pattern Editing", |d, sel| {
|
||||
let changed = s.edit("Cancel Pattern Editing", |d, sel| {
|
||||
let (pe, _) = leave_edit(d, sel)?;
|
||||
match pe.original {
|
||||
Some(orig) => {
|
||||
Some(mut orig) => {
|
||||
if orig.name != pe.pattern && (d.pattern(&orig.name).is_some() || swatch_name_clash(d, &pe.pattern, &orig.name)) {
|
||||
orig.name = pe.pattern.clone();
|
||||
}
|
||||
let back = (orig.name != pe.pattern).then(|| orig.name.clone());
|
||||
if let Some(def) = d.pattern_mut(&pe.pattern) {
|
||||
*def = orig;
|
||||
}
|
||||
if let Some(old) = &back {
|
||||
rename_pattern_uses(d, &pe.pattern, old);
|
||||
}
|
||||
Ok(back.map(|old| (pe.pattern, Some(old))))
|
||||
}
|
||||
None => {
|
||||
d.patterns.retain(|p| p.name != pe.pattern);
|
||||
d.swatches.retain(|sw| !matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == pe.pattern));
|
||||
forget_pattern(d, &pe.pattern);
|
||||
Ok(Some((pe.pattern, None)))
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
after_edit_mode(s, None)?;
|
||||
if let Some((name, to)) = changed {
|
||||
map_new_art_paints(s, &mut pattern_paints(&name, to.as_deref()))?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A [`PaintFn`] that puts a paint of pattern `to` in place of each paint of pattern `name`, with
|
||||
/// its placement kept, or no paint for `to` `None`.
|
||||
fn pattern_paints<'a>(name: &'a str, to: Option<&'a str>) -> impl FnMut(&Paint) -> Option<Paint> + 'a {
|
||||
move |p| match p {
|
||||
Paint::Pattern { pattern, xf } if pattern == name => Some(match to {
|
||||
Some(n) => Paint::Pattern { pattern: n.to_string(), xf: *xf },
|
||||
None => Paint::None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove pattern `name` from `d`: its definition and every swatch, grouped or not, whose paint is
|
||||
/// the pattern. Every paint of the pattern left anywhere in the document (the art, opacity masks,
|
||||
/// type, symbol definitions, graph designs, graphs' stored series and label paints, other patterns'
|
||||
/// tiles, graphic and character styles and the art of brushes) becomes no paint. Returns the number of such paints.
|
||||
fn forget_pattern(d: &mut Document, name: &str) -> usize {
|
||||
d.patterns.retain(|p| p.name != name);
|
||||
let is_it = |sw: &vectorcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if pattern == name);
|
||||
d.swatches.retain(|sw| !is_it(sw));
|
||||
for g in &mut d.swatch_groups {
|
||||
g.swatches.retain(|sw| !is_it(sw));
|
||||
}
|
||||
super::swatch::map_all_paints(d, &mut pattern_paints(name, None))
|
||||
}
|
||||
|
||||
/// Apply `f` to the fill and stroke new art takes and to the paints of its template appearance. A
|
||||
/// change is recorded with the step just made, so Undo and Redo put these paints back with the
|
||||
/// document.
|
||||
fn map_new_art_paints(s: &mut Session, f: &mut PaintFn) -> Result<()> {
|
||||
let start = super::colormgmt::new_art_colors_before(s)?;
|
||||
let old = s.paint.clone();
|
||||
let pd = &mut s.paint;
|
||||
let template = pd.appearance.iter_mut().flat_map(|a| a.items.iter_mut()).map(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
|
||||
vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
});
|
||||
for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) {
|
||||
if let Some(q) = f(p) {
|
||||
*p = q;
|
||||
}
|
||||
}
|
||||
if s.paint != old {
|
||||
super::colormgmt::record_new_art_colors(s, start)?;
|
||||
// The step takes the paints the command finally leaves, after the selection's appearance
|
||||
// is inherited.
|
||||
let st = s.doc()?;
|
||||
if st.interaction.is_none() {
|
||||
let uid = st.uid;
|
||||
s.batch_paint_docs.push(uid);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let wanted = text_param(p, "name", "object.pattern.saveCopy")?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let name = s.edit("Save a Copy", |d, _| {
|
||||
@@ -524,6 +603,9 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(nn) = &new_name {
|
||||
map_new_art_paints(s, &mut pattern_paints(&name, Some(nn)))?;
|
||||
}
|
||||
Ok(json!({ "name": out_name }))
|
||||
}
|
||||
|
||||
@@ -552,47 +634,19 @@ fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the
|
||||
/// pattern's own take the new name.
|
||||
/// pattern's own take the new name. The paints are renamed wherever [`forget_pattern`] clears them.
|
||||
fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) {
|
||||
let fix = |p: &mut Paint| {
|
||||
if let Paint::Pattern { pattern, .. } = p
|
||||
&& pattern == old
|
||||
{
|
||||
*pattern = new.to_string();
|
||||
}
|
||||
};
|
||||
for sw in d.swatches.iter_mut().chain(d.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) {
|
||||
for sw in d.swatches_iter_mut() {
|
||||
if sw.name == old && pattern_name(&sw.paint).is_some_and(|p| p == old) {
|
||||
sw.name = new.to_string();
|
||||
}
|
||||
fix(&mut sw.paint);
|
||||
}
|
||||
if let Some(pe) = &mut d.pattern_edit
|
||||
&& pe.pattern == old
|
||||
{
|
||||
pe.pattern = new.to_string();
|
||||
}
|
||||
let ids = nodes_using(d, Some(old));
|
||||
for id in ids {
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
for it in n.appearance.items.iter_mut() {
|
||||
match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => fix(&mut f.paint),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(s) => fix(&mut s.paint),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn nodes_using(d: &Document, name: Option<&str>) -> Vec<NodeId> {
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if pattern::uses_pattern(n, name) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
ids
|
||||
super::swatch::map_all_paints(d, &mut pattern_paints(old, Some(new)));
|
||||
}
|
||||
|
||||
fn pattern_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -604,29 +658,8 @@ fn pattern_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if editing(s).is_some_and(|e| e.pattern == name) {
|
||||
return Err(bad(C, "finish editing the pattern first"));
|
||||
}
|
||||
let n = s.edit("Delete Pattern", |d, _| {
|
||||
d.patterns.retain(|p| p.name != name);
|
||||
let is_it = |sw: &vectorcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == name);
|
||||
d.swatches.retain(|sw| !is_it(sw));
|
||||
for g in &mut d.swatch_groups {
|
||||
g.swatches.retain(|sw| !is_it(sw));
|
||||
}
|
||||
let ids = nodes_using(d, Some(&name));
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
for it in n.appearance.items.iter_mut() {
|
||||
let p = match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
|
||||
vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
|
||||
};
|
||||
if matches!(p, Paint::Pattern { pattern, .. } if *pattern == name) {
|
||||
*p = Paint::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ids.len())
|
||||
})?;
|
||||
let n = s.edit("Delete Pattern", |d, _| Ok(forget_pattern(d, &name)))?;
|
||||
map_new_art_paints(s, &mut pattern_paints(&name, None))?;
|
||||
Ok(json!({ "unpainted": n }))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! `link` ([`super::links`]).
|
||||
//! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options
|
||||
//! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`).
|
||||
//! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by
|
||||
//! its art (`crop: "bounding"`).
|
||||
//! - An EMF, WMF or PICT picture becomes one group, clipped to its frame (`crop: "crop"`) or
|
||||
//! bounded by its art (`crop: "bounding"`).
|
||||
//! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by
|
||||
//! its art: the document an EPS file of ours carries, else what its PostScript draws, else its
|
||||
//! preview ([`fileio::load`]).
|
||||
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
|
||||
@@ -212,6 +212,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("fontNamesInEnglish", "Type", "Options", "Show Font Names in English", bool),
|
||||
p!("autoSizeAreaType", "Type", "Options", "Auto Size New Area Type", bool),
|
||||
p!("fontPreview", "Type", "Options", "Enable in-menu font previews", bool),
|
||||
p!("fontSamplesOnWhite", "Type", "Options", "Show samples on white", bool),
|
||||
p!("fontPreviewSize", "Type", "Options", "Font Preview Size", choice(&[("small", "Small"), ("medium", "Medium"), ("large", "Large")])),
|
||||
p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)),
|
||||
p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool),
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent};
|
||||
use vectorcraft_doc::pattern::pattern_paint;
|
||||
use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors};
|
||||
use vectorcraft_doc::swatches::{LinkFn, PaintFn, color_name, map_node_paints, map_node_solid_paints, node_colors};
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
|
||||
use super::paint::paint_from;
|
||||
@@ -681,32 +681,16 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
|
||||
/// paint changed is merged into its entry ([`merge_stored`]), so fields the brush definition does
|
||||
/// not read are kept at every level.
|
||||
pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize {
|
||||
use vectorcraft_brush::{Brush, BrushKind, Colorization};
|
||||
let mut n = d.map_solid_paints(f);
|
||||
// Each stored entry, its brush when it parses, the brush as written before the edit (only
|
||||
// when its paint changed), and whether its paint changed.
|
||||
let mut lib: Vec<(Value, Option<Brush>, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) {
|
||||
Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(),
|
||||
None => {
|
||||
vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect()
|
||||
}
|
||||
};
|
||||
let mut in_brushes = 0;
|
||||
for (_, b, base, changed) in lib.iter_mut() {
|
||||
let Some(b) = b else { continue };
|
||||
let before_edit = serde_json::to_value(&*b).unwrap_or_default();
|
||||
use vectorcraft_brush::{BrushKind, Colorization};
|
||||
let n = d.map_solid_paints(f);
|
||||
n + edit_brush_library(d, &mut |b| {
|
||||
let key = match b.kind.colorization() {
|
||||
Colorization::HueShift { key } => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
let before = key.map(|_| art_paint_colors(b.art()));
|
||||
let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, f)).sum::<usize>();
|
||||
*changed = in_brush > 0;
|
||||
if *changed {
|
||||
*base = before_edit;
|
||||
}
|
||||
in_brushes += in_brush;
|
||||
let (Some(key), Some(before)) = (key, before) else { continue };
|
||||
let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, &mut *f)).sum::<usize>();
|
||||
let (Some(key), Some(before)) = (key, before) else { return in_brush };
|
||||
// Colors are compared by hex value, as `art_colors` (Brush Options' key colors) lists them.
|
||||
let same = |a: &Color, b: &Color| a.to_hex() == b.to_hex();
|
||||
let after = art_paint_colors(b.art());
|
||||
@@ -719,10 +703,47 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize {
|
||||
BrushKind::Art(a) => &mut a.colorization,
|
||||
BrushKind::Scatter(s) => &mut s.colorization,
|
||||
BrushKind::Pattern(p) => &mut p.colorization,
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => continue,
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => return in_brush,
|
||||
};
|
||||
*c = Colorization::HueShift { key };
|
||||
}
|
||||
in_brush
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a paint rewrite ([`Document::map_paints`]) to the document and to the art of the brushes
|
||||
/// in its library ([`edit_brush_library`]). Returns the number of changed paints.
|
||||
pub(super) fn map_all_paints(d: &mut Document, f: &mut PaintFn) -> usize {
|
||||
let n = d.map_paints(f);
|
||||
n + edit_brush_library(d, &mut |b| b.art_mut().into_iter().map(|art| map_node_paints(art, &mut *f)).sum())
|
||||
}
|
||||
|
||||
/// Apply `edit` to each brush of the document's library, the stored entries or, with none stored,
|
||||
/// the built-in library; `edit` returns how many paints of the brush's art it changed. The library
|
||||
/// is stored again only when a brush's paint changed. Stored entries are stored again as they were
|
||||
/// read, except that a brush whose paint changed is merged into its entry ([`merge_stored`]), so
|
||||
/// fields the brush definition does not read are kept at every level. Returns the number of
|
||||
/// changed paints.
|
||||
fn edit_brush_library(d: &mut Document, edit: &mut dyn FnMut(&mut vectorcraft_brush::Brush) -> usize) -> usize {
|
||||
use vectorcraft_brush::Brush;
|
||||
// Each stored entry, its brush when it parses, the brush as written before the edit (only
|
||||
// when its paint changed), and whether its paint changed.
|
||||
let mut lib: Vec<(Value, Option<Brush>, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) {
|
||||
Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(),
|
||||
None => {
|
||||
vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect()
|
||||
}
|
||||
};
|
||||
let mut in_brushes = 0;
|
||||
for (_, b, base, changed) in lib.iter_mut() {
|
||||
let Some(b) = b else { continue };
|
||||
let before_edit = serde_json::to_value(&*b).unwrap_or_default();
|
||||
let in_brush = edit(b);
|
||||
*changed = in_brush > 0;
|
||||
if *changed {
|
||||
*base = before_edit;
|
||||
}
|
||||
in_brushes += in_brush;
|
||||
}
|
||||
if in_brushes > 0 {
|
||||
let lib = lib
|
||||
@@ -733,9 +754,8 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize {
|
||||
})
|
||||
.collect();
|
||||
d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(lib));
|
||||
n += in_brushes;
|
||||
}
|
||||
n
|
||||
in_brushes
|
||||
}
|
||||
|
||||
/// `new` (a definition as written after an edit) with the parts of `raw` (its stored entry) that
|
||||
|
||||
@@ -2108,6 +2108,60 @@ fn new_type_is_composed_with_line_end_half_width_punctuation() {
|
||||
assert!(serde_json::to_value(&old).unwrap().get("mojikumi").is_none());
|
||||
}
|
||||
|
||||
/// `text.setFormat {mojikumi}` takes each predefined Mojikumi set as one undo step, refuses any
|
||||
/// other value without changing the type, and each set survives a native save and reopen and a
|
||||
/// paragraph style that captures it.
|
||||
#[test]
|
||||
fn every_predefined_mojikumi_set_is_set_saved_and_carried_by_styles() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
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 para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.para.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let sets = [
|
||||
("yakumonoHankaku", Mojikumi::YakumonoHankaku),
|
||||
("gyomatsuYakumonoHankaku", Mojikumi::GyomatsuYakumonoHankaku),
|
||||
("gyomatsuYakumonoZenkaku", Mojikumi::GyomatsuYakumonoZenkaku),
|
||||
("yakumonoZenkaku", Mojikumi::YakumonoZenkaku),
|
||||
("none", Mojikumi::None),
|
||||
("lineEndHalf", Mojikumi::LineEndHalf),
|
||||
];
|
||||
for (key, set) in sets {
|
||||
s.execute("text.setFormat", &json!({"mojikumi": key})).unwrap();
|
||||
assert_eq!(para(&s).mojikumi, set, "{key}");
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
match &back.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => assert_eq!(t.para.mojikumi, set, "{key} saved"),
|
||||
_ => panic!("text"),
|
||||
}
|
||||
let style = format!("Spacing {key}");
|
||||
s.execute("paraStyle.new", &json!({"name": style, "attrs": {"mojikumi": key}})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"mojikumi": if set == Mojikumi::None { "lineEndHalf" } else { "none" }})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": style, "id": id})).unwrap();
|
||||
assert_eq!(para(&s).mojikumi, set, "{key} from a paragraph style");
|
||||
}
|
||||
// One undo step back to the set before.
|
||||
s.execute("text.setFormat", &json!({"mojikumi": "yakumonoHankaku"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf);
|
||||
// A value that is no set, or not a string, is refused and leaves the type as it was.
|
||||
s.execute("text.setFormat", &json!({"mojikumi": "gyomatsuYakumonoZenkaku"})).unwrap();
|
||||
for bad in [json!("YakumonoHankaku"), json!("lineEnd"), json!(""), json!(1)] {
|
||||
assert!(s.execute("text.setFormat", &json!({"mojikumi": bad, "leftIndent": 12})).is_err(), "{bad}");
|
||||
let p = para(&s);
|
||||
assert_eq!((p.mojikumi, p.left_indent), (Mojikumi::GyomatsuYakumonoZenkaku, 0.0), "{bad}");
|
||||
}
|
||||
// A null value leaves the set as it is.
|
||||
s.execute("text.setFormat", &json!({"mojikumi": null, "leftIndent": 12})).unwrap();
|
||||
let p = para(&s);
|
||||
assert_eq!((p.mojikumi, p.left_indent), (Mojikumi::GyomatsuYakumonoZenkaku, 12.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -4,12 +4,12 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Node, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Node, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::{
|
||||
CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, checked_ids_param, cmd, color_value, f64_or,
|
||||
CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, checked_ids_param, cmd, color_value, f64_or, f64_req,
|
||||
find_command, finite_numbers, has_doc, index,
|
||||
};
|
||||
use crate::EngineError;
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Free Distort",
|
||||
[],
|
||||
None,
|
||||
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays",
|
||||
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), angle?: degrees (counter-clockwise; `from`, or the selection bounds, is then a box in the frame turned by it, as a rotated bounding box is; default 0), ids?} projective warp of anchors and handles; a linked opacity mask's art is warped with its object, an unlinked one stays",
|
||||
has_doc,
|
||||
distort
|
||||
),
|
||||
@@ -62,11 +62,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
pub struct Projective {
|
||||
m: [f64; 8],
|
||||
src: Rect,
|
||||
/// Page → the frame `src` is measured in.
|
||||
pre: Affine,
|
||||
}
|
||||
|
||||
impl Projective {
|
||||
/// Map `src`'s corners (TL, TR, BR, BL) onto `q`.
|
||||
pub fn from_rect(src: Rect, q: [Point; 4]) -> Self {
|
||||
/// Map the corners (TL, TR, BR, BL) of `src`, a box that may be turned, onto the page points `q`.
|
||||
pub fn from_box(bx: OrientedBox, q: [Point; 4]) -> Self {
|
||||
let src = bx.rect;
|
||||
let [p0, p1, p2, p3] = q;
|
||||
let (dx1, dx2, dx3) = (p1.x - p2.x, p3.x - p2.x, p0.x - p1.x + p2.x - p3.x);
|
||||
let (dy1, dy2, dy3) = (p1.y - p2.y, p3.y - p2.y, p0.y - p1.y + p2.y - p3.y);
|
||||
@@ -80,11 +83,12 @@ impl Projective {
|
||||
let b = p3.x - p0.x + h * p3.x;
|
||||
let d = p1.y - p0.y + g * p1.y;
|
||||
let e = p3.y - p0.y + h * p3.y;
|
||||
Self { m: [a, b, p0.x, d, e, p0.y, g, h], src }
|
||||
Self { m: [a, b, p0.x, d, e, p0.y, g, h], src, pre: bx.to_doc().inverse() }
|
||||
}
|
||||
|
||||
pub fn apply(&self, p: Point) -> Point {
|
||||
let [a, b, c, d, e, f, g, h] = self.m;
|
||||
let p = self.pre * p;
|
||||
let u = if self.src.width().abs() > 1e-12 { (p.x - self.src.x0) / self.src.width() } else { 0.0 };
|
||||
let v = if self.src.height().abs() > 1e-12 { (p.y - self.src.y0) / self.src.height() } else { 0.0 };
|
||||
let w = g * u + h * v + 1.0;
|
||||
@@ -152,17 +156,29 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "nothing to distort"));
|
||||
}
|
||||
let angle = p.get("angle").filter(|v| !v.is_null()).map(|_| f64_req(p, "angle", C)).transpose()?.map_or(0.0, vectorcraft_geom::normalize_deg);
|
||||
let src = match p.get("from").filter(|v| !v.is_null()) {
|
||||
None => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?,
|
||||
None if angle == 0.0 => OrientedBox::aligned(s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?),
|
||||
None => {
|
||||
// The selection's bounds square to the turned frame.
|
||||
let doc = &s.doc()?.doc;
|
||||
let (symbols, to_local) = (doc.symbol_boxes(), OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse());
|
||||
let rect = ids
|
||||
.iter()
|
||||
.filter_map(|id| doc.node(*id))
|
||||
.fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.bounds_in(&symbols, to_local, false)));
|
||||
OrientedBox { angle, rect: rect.ok_or_else(|| bad(C, "nothing to distort"))? }
|
||||
}
|
||||
Some(v) => {
|
||||
let [x0, y0, x1, y1] = finite_numbers(v).ok_or_else(|| bad(C, "from must be [x0, y0, x1, y1]"))?;
|
||||
Rect::new(x0, y0, x1, y1)
|
||||
OrientedBox { angle, rect: Rect::new(x0, y0, x1, y1) }
|
||||
}
|
||||
};
|
||||
if !src.width().is_finite() || !src.height().is_finite() || src.width().abs() < 1e-9 || src.height().abs() < 1e-9 {
|
||||
let r = src.rect;
|
||||
if !r.width().is_finite() || !r.height().is_finite() || r.width().abs() < 1e-9 || r.height().abs() < 1e-9 {
|
||||
return Err(bad(C, "cannot distort a zero-size or non-finite bounding box"));
|
||||
}
|
||||
let pr = Projective::from_rect(src, [tl, tr, br, bl]);
|
||||
let pr = Projective::from_box(src, [tl, tr, br, bl]);
|
||||
s.edit("Free Distort", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
|
||||
@@ -27,7 +27,8 @@ pub struct HistoryEntry {
|
||||
pub(crate) named: Option<NamedStep>,
|
||||
/// A number no other step takes ([`History::step`]).
|
||||
serial: u64,
|
||||
/// Original and converted new-art colors, only for steps that change document color mode.
|
||||
/// Original and changed new-art paints, only for steps that change them: a document color mode
|
||||
/// change, and a pattern Cancel, Delete or rename that clears or renames a pattern paint.
|
||||
pub(crate) color_mode_paints: Option<(vectorcraft_tools::PaintDefaults, vectorcraft_tools::PaintDefaults)>,
|
||||
/// Owned new-art snapshots included in both directional estimates.
|
||||
color_mode_bytes: usize,
|
||||
|
||||
@@ -217,9 +217,10 @@ pub struct Session {
|
||||
/// groups: for each step, the documents ([`DocState::uid`]) whose undo groups it belongs to, and
|
||||
/// the index of the document it left active when it changed the active document.
|
||||
pub(crate) batch_steps: Option<Vec<(Vec<u64>, Option<usize>)>>,
|
||||
/// After `command.batch` kept a color-mode step's new-art colors, until the top-level command
|
||||
/// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest
|
||||
/// history step takes the colors the batch finally left.
|
||||
/// After `command.batch` kept a color-mode step's new-art colors, or a pattern command recorded
|
||||
/// the new-art paints it changed, until the top-level command has inherited the selection's
|
||||
/// appearance: the documents ([`DocState::uid`]) whose newest history step takes the colors the
|
||||
/// command finally left.
|
||||
pub(crate) batch_paint_docs: Vec<u64>,
|
||||
/// The composite fonts (lowercase names) that the open documents defined when the font
|
||||
/// database was last brought in step with them ([`cmd::compositefonts::sync`]).
|
||||
|
||||
@@ -104,6 +104,9 @@ pub struct Prefs {
|
||||
pub font_names_in_english: bool,
|
||||
pub auto_size_area_type: bool,
|
||||
pub font_preview: bool,
|
||||
/// Type › Show samples on white: the font menus draw each family's sample in dark type on a
|
||||
/// white area in every brightness (off by default).
|
||||
pub font_samples_on_white: bool,
|
||||
pub font_preview_size: String,
|
||||
pub recent_fonts_count: u32,
|
||||
pub missing_glyph_protection: bool,
|
||||
@@ -427,6 +430,7 @@ impl Default for Prefs {
|
||||
font_names_in_english: true,
|
||||
auto_size_area_type: false,
|
||||
font_preview: true,
|
||||
font_samples_on_white: false,
|
||||
font_preview_size: s("medium"),
|
||||
recent_fonts_count: 10,
|
||||
missing_glyph_protection: true,
|
||||
|
||||
@@ -290,7 +290,10 @@ impl Session {
|
||||
st.revision += 1;
|
||||
}
|
||||
st.doc = it.doc.clone();
|
||||
let restore_paint = cmd == "command.batch" || it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch");
|
||||
// A batch, or a preview that changed the new-art paints (a pattern rename), started from
|
||||
// the paints the interaction began with.
|
||||
let restore_paint =
|
||||
cmd == "command.batch" || it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch") || it.color_mode_paints.is_some();
|
||||
let paint = it.paint.clone();
|
||||
it.preview_paint = None;
|
||||
it.color_mode_paints = None;
|
||||
@@ -423,7 +426,7 @@ impl Session {
|
||||
if let Some(paint) = self
|
||||
.active()
|
||||
.and_then(|st| st.interaction.as_ref())
|
||||
.filter(|it| it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch"))
|
||||
.filter(|it| it.preview.as_ref().is_some_and(|(id, _)| id == "command.batch") || it.color_mode_paints.is_some())
|
||||
.map(|it| it.paint.clone())
|
||||
{
|
||||
self.paint = paint;
|
||||
@@ -433,6 +436,9 @@ impl Session {
|
||||
st.undo_interaction();
|
||||
// Guides locked and slices locked or hidden during the interaction stay deselected.
|
||||
deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
// With New Art Has Basic Appearance off, new art takes the appearance of the selection as
|
||||
// the document has it again, not as a preview left it.
|
||||
self.inherit_new_art();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,92 @@ fn handles_turn_with_the_object_and_scale_along_its_axes() {
|
||||
assert!(near(selection_box(&s).rect.width(), 100.0));
|
||||
}
|
||||
|
||||
/// Presses, drags and lets go with the current tool along `pts`, holding `mods`.
|
||||
fn drag_with(s: &mut Session, pts: &[Point], mods: Mods) {
|
||||
let v = ViewInfo::default();
|
||||
let kinds = std::iter::once(PointerKind::Down).chain(std::iter::repeat_n(PointerKind::Drag, pts.len().saturating_sub(2)));
|
||||
for (kind, p) in kinds.chain([PointerKind::Up]).zip(pts) {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchors of path `id`, on the page.
|
||||
fn anchors(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect()
|
||||
}
|
||||
|
||||
fn has_point(pts: &[Point], p: Point) -> bool {
|
||||
pts.iter().any(|q| q.distance(p) < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_handles_turn_with_the_object_and_scale_along_its_axes() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
let b = selection_box(&s);
|
||||
// The Right handle of the turned box, dragged 50 pt further out along the object's own x axis.
|
||||
let right = b.to_doc() * Handle::Right.pos(b.rect);
|
||||
let to = b.to_doc() * (Handle::Right.pos(b.rect) + vectorcraft_geom::Vec2::new(50.0, 0.0));
|
||||
let n = undo_len(&s);
|
||||
s.select_tool("freeTransform", ViewInfo::default()).unwrap();
|
||||
drag_with(&mut s, &[right, to, to], Mods::default());
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Scale"));
|
||||
// 150 × 50 along its own axes, still at exactly 45°, the left side where it was.
|
||||
assert_eq!(angle(&s, a), b.angle);
|
||||
let nb = selection_box(&s);
|
||||
assert!(near(nb.rect.width(), 150.0) && near(nb.rect.height(), 50.0), "{nb:?}");
|
||||
let left = |b: &OrientedBox| b.to_doc() * Handle::Left.pos(b.rect);
|
||||
assert!(left(&nb).distance(left(&b)) < 1e-6);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(selection_box(&s).rect.width(), 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_shears_along_the_turned_side() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
let b = selection_box(&s);
|
||||
let [tl, tr, br, bl] = b.corners();
|
||||
// Dragging the Top handle 20 pt along the object's own top side with Cmd held once the drag
|
||||
// started (a Cmd press is the temporary Selection tool) slides that side along itself; the
|
||||
// bottom side stays.
|
||||
let top = b.to_doc() * Handle::Top.pos(b.rect);
|
||||
let along = b.to_doc() * (Handle::Top.pos(b.rect) + vectorcraft_geom::Vec2::new(20.0, 0.0));
|
||||
let shift = along - top;
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("freeTransform", v).unwrap();
|
||||
let cmd = Mods { cmd: true, ..Default::default() };
|
||||
for (kind, p, m) in [(PointerKind::Down, top, Mods::default()), (PointerKind::Drag, along, cmd), (PointerKind::Up, along, cmd)] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(m), v).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Shear"));
|
||||
let pts = anchors(&s, a);
|
||||
for p in [tl + shift, tr + shift, br, bl] {
|
||||
assert!(has_point(&pts, p), "{p:?} not in {pts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_distort_starts_from_the_turned_box() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
let b = selection_box(&s);
|
||||
let [tl, tr, br, bl] = b.corners();
|
||||
s.select_tool("freeTransform", ViewInfo::default()).unwrap();
|
||||
s.set_tool_option("mode", &json!("distort"));
|
||||
let to = br + vectorcraft_geom::Vec2::new(10.0, 20.0);
|
||||
let n = undo_len(&s);
|
||||
drag_with(&mut s, &[br, to, to], Mods::default());
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
|
||||
// The dragged corner of the turned box lands under the pointer; the other three stay.
|
||||
let pts = anchors(&s, a);
|
||||
for p in [tl, tr, to, bl] {
|
||||
assert!(has_point(&pts, p), "{p:?} not in {pts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_panel_sizes_follow_the_turned_box() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -600,6 +600,105 @@ fn effect_params_are_the_effects_own() {
|
||||
assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok());
|
||||
}
|
||||
|
||||
/// An effect parameter that only the effect's params description names, with no dialog default,
|
||||
/// is held to the type the effect reads: the earlier 3D Perspective and light, Scribble's
|
||||
/// `variation` and an Outline Stroke's `width` are numbers, and a Gaussian Blur's `linear` is true
|
||||
/// or false. The `style` of Crop Marks is `roman` or `japanese`. Every such key of every effect is
|
||||
/// listed, or has a check of its own. A key left out keeps its default.
|
||||
#[test]
|
||||
fn an_effect_parameter_without_a_default_is_held_to_its_type() {
|
||||
use crate::cmd::params::undefaulted_type;
|
||||
use vectorcraft_render::effects::effect_catalog;
|
||||
// Read by checks of their own: a stored texture or a texture to load, an SVG filter, a Warp's
|
||||
// map and the style of Crop Marks.
|
||||
let own_check = |effect: &str, key: &str| match key {
|
||||
"textureImage" | "name" | "xml" => true,
|
||||
"version" => effect.starts_with("warp."),
|
||||
"style" => effect == "cropMarks",
|
||||
_ => false,
|
||||
};
|
||||
let mut unlisted = vec![];
|
||||
for e in effect_catalog() {
|
||||
for key in doc_keys(e.params).unwrap().keys {
|
||||
if e.defaults.get(key).is_none() && undefaulted_type(e.id, key).is_none() && !own_check(e.id, key) {
|
||||
unlisted.push(format!("{}.{key}", e.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(unlisted.is_empty(), "parameters without a default or a listed type: {unlisted:?}");
|
||||
let apply = |effect: &str, params| json!({"effect": effect, "params": params});
|
||||
for (effect, params, why) in [
|
||||
("threeD.revolve", json!({"perspective": true}), "`perspective` must be a number"),
|
||||
("threeD.extrude", json!({"lightAzimuth": "30"}), "`lightAzimuth` must be a number"),
|
||||
("threeD.rotate", json!({"lightElevation": null}), "`lightElevation` must be a number"),
|
||||
("threeD.inflate", json!({"perspective": "50%"}), "`perspective` must be a number"),
|
||||
("path.outlineStroke", json!({"width": "10 pt"}), "`width` must be a number"),
|
||||
("stylize.scribble", json!({"variation": false}), "`variation` must be a number"),
|
||||
("blur.gaussian", json!({"radius": 2, "linear": 1}), "`linear` must be true or false"),
|
||||
("cropMarks", json!({"style": "bogus"}), "`style` must be roman or japanese"),
|
||||
("cropMarks", json!({"style": 1}), "`style` must be roman or japanese"),
|
||||
("cropMarks", json!({"style": null}), "`style` must be roman or japanese"),
|
||||
] {
|
||||
assert_eq!(refusal("effect.apply", apply(effect, params.clone())), why, "{effect} {params}");
|
||||
assert_eq!(refusal("appearance.addEffect", apply(effect, params.clone())), why, "{effect} {params}");
|
||||
}
|
||||
for (effect, params) in [
|
||||
("threeD.revolve", json!({"perspective": 40, "lightAzimuth": 30.5, "lightElevation": 0})),
|
||||
("path.outlineStroke", json!({"width": 10})),
|
||||
("stylize.scribble", json!({"variation": 2})),
|
||||
("blur.gaussian", json!({"linear": true})),
|
||||
("blur.gaussian", json!({})),
|
||||
("cropMarks", json!({"style": "japanese"})),
|
||||
("cropMarks", json!({})),
|
||||
] {
|
||||
assert!(check_params("effect.apply", &apply(effect, params.clone())).is_ok(), "{effect} {params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `effect.apply`, `appearance.addEffect` and `effect.setParams` refuse such a parameter of the
|
||||
/// wrong type when they run, and so does a step of a `command.batch` whose effect a step before it
|
||||
/// added, with the document and its undo history as they were. A saved step still plays with it,
|
||||
/// as with a key the effect no longer takes.
|
||||
#[test]
|
||||
fn effect_commands_refuse_an_undefaulted_parameter_of_the_wrong_type() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.history.undo.len(), st.doc.clone())
|
||||
};
|
||||
let before = state(&s);
|
||||
let apply = |effect: &str, params| json!({"effect": effect, "params": params});
|
||||
for (id, params, why) in [
|
||||
("effect.apply", apply("path.outlineStroke", json!({"width": "10 pt"})), "`width` must be a number"),
|
||||
("appearance.addEffect", apply("blur.gaussian", json!({"linear": 1})), "`linear` must be true or false"),
|
||||
("effect.apply", apply("threeD.revolve", json!({"perspective": "50"})), "`perspective` must be a number"),
|
||||
] {
|
||||
let e = s.execute(id, ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{id} {params}: {e}");
|
||||
assert_eq!(state(&s), before, "{id} {params}");
|
||||
}
|
||||
let batch = json!({"commands": [
|
||||
{"command": "effect.apply", "params": apply("path.outlineStroke", json!({"width": 3}))},
|
||||
{"command": "effect.setParams", "params": {"index": 0, "params": {"width": "10 pt"}}},
|
||||
]});
|
||||
let e = s.execute("command.batch", &batch).unwrap_err().to_string();
|
||||
assert!(e.contains("`width` must be a number"), "{e}");
|
||||
assert_eq!(state(&s), before);
|
||||
s.execute("effect.apply", &apply("path.outlineStroke", json!({"width": 3}))).unwrap();
|
||||
let applied = state(&s);
|
||||
for params in [json!({"width": "10 pt"}), json!({"width": null}), json!({"width": true})] {
|
||||
let e = s.execute("effect.setParams", &json!({"index": 0, "params": params.clone()})).unwrap_err().to_string();
|
||||
assert!(e.contains("`width` must be a number"), "{params}: {e}");
|
||||
assert_eq!(state(&s), applied, "{params}");
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"width": 4}})).unwrap();
|
||||
s.replay("effect.setParams", &json!({"index": 0, "params": {"retired": "x", "width": "10 pt"}})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.layers[0].children().unwrap()[0].appearance.effects[0].params["width"], json!("10 pt"));
|
||||
}
|
||||
|
||||
/// The Transform effect (`distort.transform`) is held to its params description: `effect.apply`
|
||||
/// and `effect.setParams` refuse each of its on/off options given as text, such as `reflectX:
|
||||
/// "false"`, and each of its numbers given as text, such as `copies: "0"`, and leave the effect
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType};
|
||||
use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType, pattern_paint};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
@@ -212,6 +212,186 @@ fn cancel_new_pattern_removes_it() {
|
||||
assert_eq!(doc(&s).layers.len(), 1);
|
||||
}
|
||||
|
||||
fn doc_mut(s: &mut Session) -> &mut vectorcraft_doc::Document {
|
||||
Arc::make_mut(&mut s.doc_mut().unwrap().doc)
|
||||
}
|
||||
|
||||
fn is_pattern(p: &Paint, name: &str) -> bool {
|
||||
matches!(p, Paint::Pattern { pattern, .. } if pattern == name)
|
||||
}
|
||||
|
||||
/// How many paints of pattern `name` the document holds: in the art and the definitions
|
||||
/// ([`vectorcraft_doc::Document::visit_paints`]), the swatches, the character styles and the
|
||||
/// stored brush library.
|
||||
fn pattern_uses(d: &vectorcraft_doc::Document, name: &str) -> usize {
|
||||
let mut n = 0;
|
||||
d.visit_paints(&mut |p| n += usize::from(is_pattern(p, name)));
|
||||
n += d.swatches_iter().filter(|w| is_pattern(&w.paint, name)).count();
|
||||
let styled = d.char_styles.iter().flat_map(|c| ["fill", "stroke"].map(|k| c.attrs.get(k).cloned())).flatten();
|
||||
n += styled.filter(|v| serde_json::from_value::<Paint>(v.clone()).is_ok_and(|p| is_pattern(&p, name))).count();
|
||||
let needle = format!("\"pattern\":\"{name}\"");
|
||||
n + d.unknown.get(vectorcraft_brush::DOC_KEY).map_or(0, |v| v.to_string().matches(&needle).count())
|
||||
}
|
||||
|
||||
/// Canceling a new pattern removes every swatch of it, a color group's included, and the fills and
|
||||
/// strokes of it on art outside the editing layer become no paint. The object's other paints, and
|
||||
/// a swatch that only shares the pattern's name, stay.
|
||||
#[test]
|
||||
fn cancel_new_pattern_forgets_group_swatch_and_outside_fills_and_strokes() {
|
||||
let mut s = session();
|
||||
let other = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setStroke", &json!({"ids": [other.0], "color": "#00ff00"})).unwrap();
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[r]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [other.0], "swatch": "Tiles"})).unwrap();
|
||||
let d = doc_mut(&mut s);
|
||||
let solid = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
|
||||
d.swatch_groups.push(vectorcraft_color::SwatchGroup {
|
||||
name: "Mine".into(),
|
||||
swatches: vec![
|
||||
vectorcraft_color::Swatch { name: "Grouped".into(), paint: pattern_paint("Tiles"), global: false, spot: false },
|
||||
vectorcraft_color::Swatch { name: "Tiles".into(), paint: solid.clone(), global: false, spot: false },
|
||||
],
|
||||
});
|
||||
assert!(pattern_uses(doc(&s), "Tiles") >= 3);
|
||||
s.execute("object.pattern.cancel", &json!({})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert!(d.pattern("Tiles").is_none());
|
||||
assert_eq!(pattern_uses(d, "Tiles"), 0);
|
||||
assert!(d.swatch("Grouped").is_none(), "the group's pattern swatch goes");
|
||||
assert_eq!(d.swatch("Tiles").map(|w| &w.paint), Some(&solid), "a color swatch named like the pattern stays");
|
||||
let n = node(&s, other);
|
||||
assert!(n.appearance.fill_paint().is_none());
|
||||
assert!(matches!(n.appearance.stroke_paint(), Paint::Solid { .. }), "the stroke stays");
|
||||
}
|
||||
|
||||
/// Save a Copy keeps the copy as its own pattern when the new pattern is canceled, and the copy's
|
||||
/// tile art loses its paints of the canceled pattern.
|
||||
#[test]
|
||||
fn cancel_new_pattern_forgets_its_paints_in_a_saved_copy() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[r]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap();
|
||||
let layer = doc(&s).pattern_edit.as_ref().unwrap().layer;
|
||||
let tile = doc(&s).node(layer).unwrap().children().unwrap()[0].id;
|
||||
s.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Tiles"})).unwrap();
|
||||
assert_eq!(s.execute("object.pattern.saveCopy", &json!({})).unwrap()["name"], "Tiles copy");
|
||||
s.execute("object.pattern.cancel", &json!({})).unwrap();
|
||||
let d = doc(&s);
|
||||
let copy = d.pattern("Tiles copy").expect("the copy stays");
|
||||
assert_eq!(copy.art.len(), 1);
|
||||
assert!(copy.art[0].appearance.fill_paint().is_none());
|
||||
assert_eq!(pattern_uses(d, "Tiles"), 0);
|
||||
}
|
||||
|
||||
/// Canceling a new pattern also clears its paints in type, opacity mask art, symbol definitions,
|
||||
/// graph designs, graphic and character styles and brush art, and in the fill, stroke and
|
||||
/// appearance new art takes, so new art does not take a paint of a pattern that is gone.
|
||||
#[test]
|
||||
fn cancel_new_pattern_forgets_hidden_paints_and_new_art_defaults() {
|
||||
let mut s = session();
|
||||
let masked = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
let m = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, m.0]})).unwrap();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 20, "y": 300, "text": "Hi"})).unwrap());
|
||||
let band = rect(&mut s, 200.0, 200.0, 40.0, 10.0);
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[r]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [band.0], "swatch": "Tiles"})).unwrap();
|
||||
s.execute("brush.new", &json!({"type": "art", "name": "Band", "ids": [band.0]})).unwrap();
|
||||
let p = pattern_paint("Tiles");
|
||||
let d = doc_mut(&mut s);
|
||||
let art = d.node(band).unwrap().clone();
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Sym".into(), art: Arc::new(art.clone()), dynamic: false });
|
||||
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Bar".into(), art: Arc::new(art.clone()) });
|
||||
d.graphic_styles.push(vectorcraft_doc::GraphicStyle {
|
||||
name: "Style".into(),
|
||||
appearance: (*art.appearance).clone(),
|
||||
id: 0,
|
||||
opacity: 1.0,
|
||||
blend: Default::default(),
|
||||
isolate: false,
|
||||
knockout: Default::default(),
|
||||
unit_box: true,
|
||||
});
|
||||
let attrs = json!({"fill": p, "stroke": p, "size": 12});
|
||||
d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Char".into(), attrs: attrs.as_object().unwrap().clone() });
|
||||
if let Some(NodeKind::Text(text)) = d.node_mut(t).map(|n| &mut n.kind) {
|
||||
text.runs[0].style.fill = p.clone();
|
||||
}
|
||||
let mask = d.node_mut(masked).and_then(|n| n.extras_if_any()).and_then(|e| e.mask.as_deref_mut()).expect("the mask");
|
||||
Arc::make_mut(&mut mask.art).appearance = vectorcraft_doc::Appearance::basic(p.clone(), Paint::None, 0.0).into();
|
||||
s.paint.fill = p.clone();
|
||||
s.paint.appearance = Some(vectorcraft_doc::Appearance::basic(Paint::None, p.clone(), 1.0));
|
||||
assert!(pattern_uses(doc(&s), "Tiles") >= 10, "{}", pattern_uses(doc(&s), "Tiles"));
|
||||
s.execute("object.pattern.cancel", &json!({})).unwrap();
|
||||
assert_eq!(pattern_uses(doc(&s), "Tiles"), 0);
|
||||
assert!(doc(&s).char_styles[0].attrs["size"] == json!(12), "the style's other attributes stay");
|
||||
assert!(s.paint.fill.is_none());
|
||||
assert!(s.paint.appearance.as_ref().unwrap().items.iter().all(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => !is_pattern(&f.paint, "Tiles"),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(k) => !is_pattern(&k.paint, "Tiles"),
|
||||
}));
|
||||
let fresh = rect(&mut s, 300.0, 300.0, 20.0, 20.0);
|
||||
let n = node(&s, fresh);
|
||||
assert!(!is_pattern(&n.appearance.fill_paint(), "Tiles") && !is_pattern(&n.appearance.stroke_paint(), "Tiles"));
|
||||
}
|
||||
|
||||
/// Cancel is one undo step: Undo gives the pattern, its paints and the fill new art takes back, and
|
||||
/// Redo clears them again.
|
||||
#[test]
|
||||
fn cancel_new_pattern_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
let other = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[r]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [other.0], "swatch": "Tiles"})).unwrap();
|
||||
s.paint.fill = pattern_paint("Tiles");
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.pattern.cancel", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
assert!(s.paint.fill.is_none() && doc(&s).pattern("Tiles").is_none());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(is_pattern(&s.paint.fill, "Tiles"));
|
||||
assert!(doc(&s).pattern("Tiles").is_some() && doc(&s).pattern_edit.is_some());
|
||||
assert!(is_pattern(&node(&s, other).appearance.fill_paint(), "Tiles"));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(s.paint.fill.is_none() && doc(&s).pattern("Tiles").is_none());
|
||||
assert_eq!(pattern_uses(doc(&s), "Tiles"), 0);
|
||||
}
|
||||
|
||||
/// Canceling the edit of a pattern that Pattern Options renamed while it was edited gives the
|
||||
/// pattern its old name back, with its swatch and the paints of it, so nothing is left painted
|
||||
/// with a name no pattern has.
|
||||
#[test]
|
||||
fn cancel_after_renaming_an_edited_pattern_restores_its_name_and_paints() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
|
||||
s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap();
|
||||
s.execute("pattern.options", &json!({"newName": "Spots"})).unwrap();
|
||||
assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Spots"));
|
||||
s.execute("object.pattern.cancel", &json!({})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert!(d.pattern("Dots").is_some() && d.pattern("Spots").is_none());
|
||||
assert!(d.swatch("Dots").is_some_and(|w| is_pattern(&w.paint, "Dots")));
|
||||
assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Dots"));
|
||||
assert_eq!(pattern_uses(d, "Spots"), 0);
|
||||
// When Save a Copy has taken the old name since, the pattern keeps its new name and paints.
|
||||
s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap();
|
||||
s.execute("pattern.options", &json!({"newName": "Spots"})).unwrap();
|
||||
s.execute("object.pattern.saveCopy", &json!({"name": "Dots"})).unwrap();
|
||||
s.execute("object.pattern.cancel", &json!({})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert!(d.pattern("Dots").is_some() && d.pattern("Spots").is_some());
|
||||
assert!(is_pattern(&node(&s, big).appearance.fill_paint(), "Spots"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_then_cancel_restores_definition() {
|
||||
let mut s = session();
|
||||
@@ -381,6 +561,187 @@ fn delete_pattern_unpaints_objects() {
|
||||
assert!(s.execute("pattern.delete", &json!({"name": "Dots"})).is_err());
|
||||
}
|
||||
|
||||
/// Puts paints of pattern `name` where the layers do not reach: a symbol definition, another
|
||||
/// pattern's tile, a graphic style, a character style, a type run, a color group's swatch and the
|
||||
/// fill new art takes.
|
||||
fn paint_outside_the_layers(s: &mut Session, name: &str) {
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 20, "y": 300, "text": "Hi"})).unwrap());
|
||||
let p = pattern_paint(name);
|
||||
let d = doc_mut(s);
|
||||
let shape = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0));
|
||||
let art = Node::path(d.alloc_id(), shape, vectorcraft_doc::Appearance::basic(p.clone(), Paint::None, 0.0));
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Sym".into(), art: Arc::new(art.clone()), dynamic: false });
|
||||
d.patterns.push(vectorcraft_doc::pattern::PatternDef::new("Other", vec![Arc::new(art.clone())]));
|
||||
d.graphic_styles.push(vectorcraft_doc::GraphicStyle {
|
||||
name: "Style".into(),
|
||||
appearance: (*art.appearance).clone(),
|
||||
id: 0,
|
||||
opacity: 1.0,
|
||||
blend: Default::default(),
|
||||
isolate: false,
|
||||
knockout: Default::default(),
|
||||
unit_box: true,
|
||||
});
|
||||
let attrs = json!({"fill": p});
|
||||
d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Char".into(), attrs: attrs.as_object().unwrap().clone() });
|
||||
if let Some(NodeKind::Text(text)) = d.node_mut(t).map(|n| &mut n.kind) {
|
||||
text.runs[0].style.fill = p.clone();
|
||||
}
|
||||
d.swatch_groups.push(vectorcraft_color::SwatchGroup {
|
||||
name: "Mine".into(),
|
||||
swatches: vec![vectorcraft_color::Swatch { name: "Grouped".into(), paint: p.clone(), global: false, spot: false }],
|
||||
});
|
||||
s.paint.fill = p;
|
||||
}
|
||||
|
||||
/// Delete Pattern clears the pattern's paints outside the layers too, and in the fill new art takes.
|
||||
#[test]
|
||||
fn delete_pattern_forgets_paints_outside_the_layers() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
paint_outside_the_layers(&mut s, "Dots");
|
||||
assert_eq!(pattern_uses(doc(&s), "Dots"), 7);
|
||||
let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap();
|
||||
assert_eq!(v["unpainted"], 5, "the symbol, the other tile, the graphic style, the character style and the type");
|
||||
assert_eq!(pattern_uses(doc(&s), "Dots"), 0);
|
||||
assert!(doc(&s).swatch("Grouped").is_none() && doc(&s).pattern("Other").is_some());
|
||||
assert!(s.paint.fill.is_none());
|
||||
}
|
||||
|
||||
/// A Pattern Options rename renames the pattern's paints outside the layers too, and in the fill
|
||||
/// new art takes.
|
||||
#[test]
|
||||
fn rename_pattern_reaches_paints_outside_the_layers() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
paint_outside_the_layers(&mut s, "Dots");
|
||||
s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
|
||||
assert_eq!(pattern_uses(doc(&s), "Dots"), 0);
|
||||
assert_eq!(pattern_uses(doc(&s), "Spots"), 7);
|
||||
assert!(is_pattern(&s.paint.fill, "Spots"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(is_pattern(&s.paint.fill, "Dots"), "Undo gives the fill its old name back");
|
||||
}
|
||||
|
||||
/// A new graph whose stored series fill and stroke and value label fill and stroke are pattern
|
||||
/// `name`, as a later redraw would give them to its marks and labels.
|
||||
fn graph_painted_with(s: &mut Session, name: &str) -> NodeId {
|
||||
let v = s.execute("graph.create", &json!({"type": "column", "x": 200, "y": 200, "width": 60, "height": 40, "csv": ",2024\nQ1,3\nQ2,5"}));
|
||||
let id = id_of(&v.unwrap());
|
||||
let p = pattern_paint(name);
|
||||
let mut extras = doc_mut(s).node_mut(id).unwrap().extras_mut();
|
||||
let spec = extras.graph.as_mut().unwrap();
|
||||
spec.series_paints = vec![vectorcraft_doc::SeriesPaint { fill: Some(p.clone()), stroke: Some(p.clone()), ..Default::default() }];
|
||||
spec.label_styles.values = Some(vectorcraft_doc::CharStyle { fill: p.clone(), stroke: p, ..Default::default() });
|
||||
id
|
||||
}
|
||||
|
||||
/// The series and label paints a graph stores, in order: series fill and stroke, then the value
|
||||
/// labels' fill and stroke.
|
||||
fn stored_graph_paints(s: &Session, id: NodeId) -> Vec<Paint> {
|
||||
let n = node(s, id);
|
||||
let g = n.extras().graph.as_deref().unwrap();
|
||||
let series = g.series_paints.iter().flat_map(|p| [p.fill.clone(), p.stroke.clone()]).flatten();
|
||||
series.chain(g.label_styles.values.iter().flat_map(|st| [st.fill.clone(), st.stroke.clone()])).collect()
|
||||
}
|
||||
|
||||
/// Delete Pattern clears the series and label paints a graph stores, and a rename renames them, so
|
||||
/// a later `graph.setData` draws no paint of a missing pattern.
|
||||
#[test]
|
||||
fn delete_and_rename_reach_a_graphs_stored_paints() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let g = graph_painted_with(&mut s, "Dots");
|
||||
s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
|
||||
assert!(stored_graph_paints(&s, g).iter().all(|p| is_pattern(p, "Spots")), "the rename reaches the graph");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(stored_graph_paints(&s, g).iter().all(|p| is_pattern(p, "Dots")));
|
||||
let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap();
|
||||
assert_eq!(v["unpainted"], 4, "the series fill and stroke and the value labels' fill and stroke");
|
||||
assert!(stored_graph_paints(&s, g).iter().all(Paint::is_none));
|
||||
s.execute("graph.setData", &json!({"id": g.0, "rows": [[2], [4]]})).unwrap();
|
||||
assert_eq!(pattern_uses(doc(&s), "Dots"), 0, "the redrawn graph holds no paint of the deleted pattern");
|
||||
}
|
||||
|
||||
/// Canceling a new pattern clears the series and label paints a graph stores.
|
||||
#[test]
|
||||
fn cancel_new_pattern_reaches_a_graphs_stored_paints() {
|
||||
let mut s = session();
|
||||
// The graph is made outside the editing layer, and its paints take the new pattern's name.
|
||||
let g = graph_painted_with(&mut s, "Tiles");
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[r]);
|
||||
s.execute("object.pattern.make", &json!({"name": "Tiles"})).unwrap();
|
||||
s.execute("object.pattern.cancel", &json!({})).unwrap();
|
||||
assert_eq!(stored_graph_paints(&s, g).len(), 4);
|
||||
assert!(stored_graph_paints(&s, g).iter().all(Paint::is_none));
|
||||
assert_eq!(pattern_uses(doc(&s), "Tiles"), 0);
|
||||
}
|
||||
|
||||
/// With New Art Has Basic Appearance off, Redo of Delete Pattern gives new art the paints the
|
||||
/// command left, the selected object's appearance taken after the pattern's fill was cleared.
|
||||
#[test]
|
||||
fn redo_of_delete_pattern_keeps_the_inherited_new_art_paints() {
|
||||
let mut s = session();
|
||||
let r = make_dots(&mut s);
|
||||
s.prefs.new_art_basic = false;
|
||||
sel(&mut s, &[r]);
|
||||
s.paint.fill = pattern_paint("Dots");
|
||||
s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap();
|
||||
let after = s.paint.clone();
|
||||
assert!(!is_pattern(&after.fill, "Dots"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.paint, after);
|
||||
}
|
||||
|
||||
/// A Pattern Options rename tried as a preview changes the fill new art takes only while the
|
||||
/// preview stands: another preview and Cancel start from the fill the interaction began with.
|
||||
/// Keeping the preview keeps the rename, and Undo takes it back.
|
||||
#[test]
|
||||
fn rename_previews_restore_the_fill_new_art_takes() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
s.paint.fill = pattern_paint("Dots");
|
||||
s.begin_interaction("Pattern Options").unwrap();
|
||||
s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
|
||||
assert!(is_pattern(&s.paint.fill, "Spots"));
|
||||
s.preview("pattern.options", &json!({"name": "Dots", "newName": "Tiles"})).unwrap();
|
||||
assert!(is_pattern(&s.paint.fill, "Tiles"));
|
||||
s.cancel_interaction().unwrap();
|
||||
assert!(is_pattern(&s.paint.fill, "Dots") && doc(&s).pattern("Dots").is_some());
|
||||
s.begin_interaction("Pattern Options").unwrap();
|
||||
s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
assert!(is_pattern(&s.paint.fill, "Spots") && doc(&s).pattern("Spots").is_some());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(is_pattern(&s.paint.fill, "Dots") && doc(&s).pattern("Dots").is_some());
|
||||
}
|
||||
|
||||
/// With New Art Has Basic Appearance off, a canceled rename preview leaves new art with the
|
||||
/// appearance of the selected object as the document has it again, so no paint of the previewed
|
||||
/// name is left.
|
||||
#[test]
|
||||
fn canceled_rename_preview_leaves_no_inherited_paint_of_the_new_name() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
|
||||
s.prefs.new_art_basic = false;
|
||||
sel(&mut s, &[big]);
|
||||
s.paint = vectorcraft_tools::PaintDefaults::default();
|
||||
s.begin_interaction("Pattern Options").unwrap();
|
||||
s.preview("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap();
|
||||
s.cancel_interaction().unwrap();
|
||||
assert!(doc(&s).pattern("Dots").is_some() && doc(&s).pattern("Spots").is_none());
|
||||
let items = s.paint.appearance.iter().flat_map(|a| a.items.iter()).map(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.clone(),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(k) => k.paint.clone(),
|
||||
});
|
||||
let paints: Vec<Paint> = [s.paint.fill.clone(), s.paint.stroke.clone()].into_iter().chain(items).collect();
|
||||
assert!(paints.iter().all(|p| !is_pattern(p, "Spots")), "{paints:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_patterns_only_moves_placement() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -235,8 +235,16 @@ fn live_effect_plugins_reshape_editable_art_and_expand() {
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "object.move", json!({"dx": 50, "dy": 0}));
|
||||
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
|
||||
// A parameter of another type than the manifest gives it is refused.
|
||||
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let wrong = json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": "4 pt"}});
|
||||
let e = s.execute("effect.apply", &wrong).unwrap_err().to_string();
|
||||
assert!(e.contains("`size` must be a number"), "{e}");
|
||||
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before);
|
||||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}}));
|
||||
assert_eq!(effected(&s), triangle());
|
||||
let e = s.execute("effect.setParams", &json!({"ids": [a.0], "index": 1, "params": {"size": true}})).unwrap_err().to_string();
|
||||
assert!(e.contains("`size` must be a number"), "{e}");
|
||||
assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4}));
|
||||
// Uninstalled: the geometry is drawn as it is.
|
||||
run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"}));
|
||||
|
||||
@@ -1190,6 +1190,32 @@ fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() {
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
}
|
||||
|
||||
/// `object.distort` with `angle` reads `from` as a box in the frame turned by it, as the Free
|
||||
/// Transform tool sends a turned bounding box: corners where that box already stands leave the art.
|
||||
#[test]
|
||||
fn distort_reads_from_in_a_turned_frame_and_rejects_a_bad_angle() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let original = bounds(&s, a);
|
||||
let bx = vectorcraft_doc::OrientedBox { angle: 90.0, rect: Rect::new(-100.0, 0.0, 0.0, 100.0) };
|
||||
let corners: Vec<serde_json::Value> = bx.corners().iter().map(|c| json!([c.x, c.y])).collect();
|
||||
let history = undo_len(&s);
|
||||
for p in [
|
||||
json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": "90", "ids": [a.0]}),
|
||||
json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": [90], "ids": [a.0]}),
|
||||
] {
|
||||
assert!(s.execute("object.distort", &p).is_err(), "invalid input: {p}");
|
||||
assert!(close(bounds(&s, a), original));
|
||||
assert_eq!(undo_len(&s), history);
|
||||
}
|
||||
s.execute("object.distort", &json!({"corners": corners, "from": [-100, 0, 0, 100], "angle": 90, "ids": [a.0]})).unwrap();
|
||||
assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a));
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
// Without `from`, the box is the objects' bounds in the turned frame: the same box here.
|
||||
s.execute("object.distort", &json!({"corners": corners, "angle": 90, "ids": [a.0]})).unwrap();
|
||||
assert!(close(bounds(&s, a), original), "{:?}", bounds(&s, a));
|
||||
}
|
||||
|
||||
/// `artboard.move` and `artboard.delete` take several artboards at once (#1139): one undo step
|
||||
/// each, the art on each moving with it, and one artboard always left.
|
||||
#[test]
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! (`.vcflattener`) and export preset (`.vcexportpresets`) libraries, shortcut sets and action sets other apps save as text (`.kys`, `.aia`), and native files (compressed, damaged, saved for older versions),
|
||||
//! must load as an error or as a document that then renders and exports, without a panic; nor may
|
||||
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
|
||||
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
|
||||
//! of every kind with random contents) opened, placed or pasted, nor PICT pictures (damaged files,
|
||||
//! opcodes with random contents) opened or placed, nor EPS and PostScript files
|
||||
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of
|
||||
//! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents
|
||||
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the
|
||||
@@ -2938,6 +2939,106 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- PICT pictures: opened and placed ----------
|
||||
|
||||
fn be16(v: &[i16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_be_bytes()).collect()
|
||||
}
|
||||
|
||||
/// A version 2 PICT file of a 200 × 100 pt frame holding `ops`, each padded to an even length.
|
||||
fn pict_of(ops: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut b = vec![0; 512];
|
||||
b.extend(be16(&[0, 0, 0, 100, 200]));
|
||||
b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]);
|
||||
b.extend(be16(&[-1, 0]));
|
||||
b.extend([0; 20]);
|
||||
for (op, data) in ops {
|
||||
b.extend(op.to_be_bytes());
|
||||
b.extend(data);
|
||||
if b.len() % 2 == 1 {
|
||||
b.push(0);
|
||||
}
|
||||
}
|
||||
b.extend([0x00, 0xFF]);
|
||||
b
|
||||
}
|
||||
|
||||
/// A picture with shapes, a region, text, a pattern and a packed pixel map.
|
||||
fn pict_sample() -> Vec<u8> {
|
||||
let region = [be16(&[36, 0, 0, 20, 20]), be16(&[0, 0, 20, 0x7FFF, 10, 10, 20, 0x7FFF, 20, 0, 10, 0x7FFF, 0x7FFF])].concat();
|
||||
let table = be16(&[0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1]);
|
||||
let pixmap = [
|
||||
be16(&[0x8008u16 as i16, 0, 0, 2, 8]),
|
||||
be16(&[0, 0, 0, 0, 72, 0, 72, 0, 0, 8, 1, 8, 0, 0, 0, 0, 0, 0]),
|
||||
table,
|
||||
be16(&[0, 0, 2, 8, 10, 20, 14, 36, 0]),
|
||||
vec![2, 0xF9, 0, 9, 7, 1, 0, 1, 0, 1, 0, 1, 0],
|
||||
]
|
||||
.concat();
|
||||
pict_of(&[
|
||||
(0x1A, be16(&[-1, 0, 0])),
|
||||
(0x07, be16(&[2, 2])),
|
||||
(0x31, be16(&[10, 20, 50, 80])),
|
||||
(0x30, be16(&[10, 20, 50, 80])),
|
||||
(0x0B, be16(&[8, 8])),
|
||||
(0x41, be16(&[0, 100, 40, 160])),
|
||||
(0x51, be16(&[50, 100, 90, 160])),
|
||||
(0x61, be16(&[0, 0, 60, 60, 30, 120])),
|
||||
(0x71, be16(&[22, 0, 0, 40, 40, 0, 0, 40, 40, 0, 40])),
|
||||
(0x81, region.clone()),
|
||||
(0x01, region),
|
||||
(0x20, be16(&[70, 10, 70, 190])),
|
||||
(0x21, be16(&[90, 100])),
|
||||
(0x0A, vec![0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55]),
|
||||
(0x34, be16(&[0, 0, 10, 10])),
|
||||
(0x2C, [be16(&[9, 3]), vec![6], b"Geneva".to_vec()].concat()),
|
||||
(0x0D, be16(&[12])),
|
||||
(0x28, [be16(&[80, 10]), vec![3], b"abc".to_vec()].concat()),
|
||||
(0x98, pixmap),
|
||||
])
|
||||
}
|
||||
|
||||
/// The sample the mutations start from opens with art and an image.
|
||||
#[test]
|
||||
fn the_pict_sample_opens() {
|
||||
let l = vectorcraft_engine::cmd::fileio::load("x.pct", &pict_sample()).unwrap();
|
||||
assert_eq!(l.format.id, "pict");
|
||||
let (mut paths, mut images, mut texts) = (0, 0, 0);
|
||||
l.doc.walk(|n| match n.kind {
|
||||
vectorcraft_doc::NodeKind::Path { .. } => paths += 1,
|
||||
vectorcraft_doc::NodeKind::Image(_) => images += 1,
|
||||
vectorcraft_doc::NodeKind::Text(_) => texts += 1,
|
||||
_ => {}
|
||||
});
|
||||
assert!(paths > 8 && images == 1 && texts == 1, "{paths} paths, {images} images, {texts} texts, warnings {:?}", l.warnings);
|
||||
}
|
||||
|
||||
/// PICT opcodes VectorCraft reads, and reserved ones it skips.
|
||||
const PICT_OPS: [u16; 40] = [
|
||||
0x01, 0x03, 0x04, 0x07, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x12, 0x13, 0x14, 0x1A, 0x1B, 0x20, 0x21, 0x22, 0x23, 0x28, 0x29, 0x2A, 0x2B, 0x2C, 0x30,
|
||||
0x31, 0x38, 0x41, 0x51, 0x61, 0x71, 0x81, 0x90, 0x91, 0x98, 0x99, 0x9A, 0x9B, 0xA1, 0x8200, 0x0024,
|
||||
];
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn mutated_pict_pictures_never_panic(cut in 0usize..4096, edits in prop::collection::vec((0usize..4096, any::<u8>()), 0..16)) {
|
||||
let mut bytes = pict_sample();
|
||||
let n = bytes.len();
|
||||
for &(at, b) in &edits {
|
||||
bytes[512 + at % (n - 512)] = b;
|
||||
}
|
||||
bytes.truncate(cut.max(512));
|
||||
survive_metafile("mutated PICT", "x.pct", &bytes)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_pict_opcodes_never_panic(ops in prop::collection::vec((prop::sample::select(PICT_OPS.to_vec()), arb_body()), 0..32)) {
|
||||
survive_metafile("PICT opcodes", "x.pct", &pict_of(&ops))?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- HP-GL plot files: opened and placed ----------
|
||||
|
||||
/// A plot file with every kind of instruction the reader plays, printer escapes around it.
|
||||
|
||||
@@ -3943,6 +3943,42 @@ fn korean_line_break_anywhere_is_reported_only_for_korean_paragraphs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text document holds no Mojikumi set: a paragraph with any of the predefined sets other
|
||||
/// than None is reported as left out, and reads back without the set; None and Line-end
|
||||
/// Punctuation Half Width are not reported.
|
||||
#[test]
|
||||
fn predefined_mojikumi_sets_are_reported_as_left_out() {
|
||||
use vectorcraft_doc::{Mojikumi, TextObject};
|
||||
for (set, reported) in [
|
||||
(Mojikumi::None, false),
|
||||
(Mojikumi::LineEndHalf, false),
|
||||
(Mojikumi::YakumonoHankaku, true),
|
||||
(Mojikumi::GyomatsuYakumonoHankaku, true),
|
||||
(Mojikumi::GyomatsuYakumonoZenkaku, true),
|
||||
(Mojikumi::YakumonoZenkaku, true),
|
||||
] {
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "「かな」。", style(14.0, Color::BLACK));
|
||||
t.para.mojikumi = set;
|
||||
let mut doc = Document::new(200.0, 200.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let n = text_node(&mut doc, t);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(!out.warnings.is_empty(), reported, "{set:?}: {:?}", out.warnings);
|
||||
let mut all = vec![];
|
||||
paths(&read_back(&doc).layers, &mut all);
|
||||
let sets: Vec<Mojikumi> = all
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.para_at(0).mojikumi),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(sets.len(), 1, "{set:?}");
|
||||
assert!(matches!(sets[0], Mojikumi::None | Mojikumi::LineEndHalf), "{set:?} reads back as {:?}", sets[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The text document has no Bunri-Kinshi: a paragraph with it on and an inseparable pair in it is
|
||||
/// reported as left out, and comes back off; with it off, or with no pair, nothing is reported.
|
||||
#[test]
|
||||
|
||||
@@ -945,6 +945,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step); for several rows it sends only the fields set in the dialog",
|
||||
),
|
||||
(
|
||||
"ui.layerDelete",
|
||||
"Delete Selection",
|
||||
"",
|
||||
"{ids?|id?, doc?, confirmed?: bool} Delete Selection as the Layers panel runs it (its trash button, rows dropped on the trash, its menu, Delete and Backspace), on the rows layer.delete takes (default: the highlighted rows, else the current layer). When a layer or sublayer among them holds art (an object other than a guide; hidden, locked and nested objects count) it asks first: dialog `confirm` with `message`; OK deletes those rows by id with layer.delete (one undo step), Cancel keeps them. OK runs ui.layerDelete again with the rows, `doc` (the document's uid) and `confirmed: true`: with another document active then, nothing is deleted. Other rows (an empty layer, groups and objects alone) and rows layer.delete refuses go to layer.delete at once → layer.delete's {deleted}, or null while the question is open",
|
||||
),
|
||||
(
|
||||
"ui.rename",
|
||||
"Rename",
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "vectorcraft-metafile"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "VectorCraft Windows metafiles: hand-written EMF and WMF writers and readers"
|
||||
description = "VectorCraft metafiles: hand-written EMF and WMF writers and readers, and a QuickDraw picture (PICT) reader"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -11,8 +11,8 @@ const MAX_SIDE: u32 = 8192;
|
||||
/// Most pixels an image the writers store holds.
|
||||
const MAX_PIXELS: u64 = 1 << 24;
|
||||
/// Most pixels along a side, and in all, of a bitmap the reader decodes.
|
||||
const MAX_READ_SIDE: u32 = 1 << 15;
|
||||
const MAX_READ_PIXELS: u64 = 1 << 26;
|
||||
pub(crate) const MAX_READ_SIDE: u32 = 1 << 15;
|
||||
pub(crate) const MAX_READ_PIXELS: u64 = 1 << 26;
|
||||
/// Most pixels the bitmaps of one file may decode to together (four of the largest one), so a small
|
||||
/// file of many image records can't cost minutes of decoding (#3600).
|
||||
pub(crate) const MAX_FILE_PIXELS: u64 = 1 << 28;
|
||||
@@ -164,7 +164,7 @@ fn png_without_metadata(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> {
|
||||
}
|
||||
|
||||
/// Take a bitmap of `w` × `h` from what is `left` of the file's budget, before it is decoded.
|
||||
fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> {
|
||||
pub(crate) fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> {
|
||||
let n = u64::from(w) * u64::from(h);
|
||||
*left = left.checked_sub(n).ok_or(OVER_BUDGET)?;
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The metafile reader: the format parsers (`emf`, `wmf`) play their records on a [`Player`], a
|
||||
//! small model of the device context they draw into (objects, transforms, the current position,
|
||||
//! the path bracket, the clip), which turns what they draw into document objects.
|
||||
//! The metafile reader: the format parsers (`emf`, `wmf`, and `pict` for QuickDraw pictures) play
|
||||
//! their records on a [`Player`], a small model of the device context they draw into (objects,
|
||||
//! transforms, the current position, the path bracket, the clip), which turns what they draw into
|
||||
//! document objects.
|
||||
//!
|
||||
//! Fills and strokes become paths with a fill and a stroke; lines drawn one after another with the
|
||||
//! same pen join into one path. Clips become clipping groups around what they clip. Images keep
|
||||
@@ -8,6 +9,7 @@
|
||||
//! doesn't know are counted and skipped, with one warning.
|
||||
|
||||
mod emf;
|
||||
pub(crate) mod pict;
|
||||
mod wmf;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -787,15 +789,25 @@ impl Player {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return;
|
||||
}
|
||||
let (w, h) = (f64::from(img.width), f64::from(img.height));
|
||||
self.image_xf(img, Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h), opacity);
|
||||
}
|
||||
|
||||
/// An image placed by `xf` (its pixels, from the top-left, → logical), at `opacity`. An image
|
||||
/// that `xf` flattens or sends past finite numbers is left out, and adds nothing to the
|
||||
/// document's images.
|
||||
pub(crate) fn image_xf(&mut self, img: Rgba, xf: Affine, opacity: f64) {
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return;
|
||||
}
|
||||
let xf = self.xf() * xf;
|
||||
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
|
||||
return;
|
||||
}
|
||||
let Ok(png) = img.to_png() else { return self.warn(BAD_IMAGE) };
|
||||
let blob = ImageBlob::new("image/png", png);
|
||||
let key = blob.content_key();
|
||||
self.doc.images.entry(key.clone()).or_insert(blob);
|
||||
let (w, h) = (f64::from(img.width), f64::from(img.height));
|
||||
let xf = self.xf() * Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h);
|
||||
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
|
||||
return;
|
||||
}
|
||||
let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None };
|
||||
let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im)));
|
||||
n.opacity = opacity.clamp(0.0, 1.0) as f32;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,13 +15,14 @@
|
||||
//!
|
||||
//! [`import`] reads either format back into a document of one artboard (the picture's frame) and
|
||||
//! one layer: paths with fills and strokes, clipping, images and text. Records it doesn't read are
|
||||
//! skipped, with one warning.
|
||||
//! skipped, with one warning. [`import_pict`] reads QuickDraw pictures (PICT, versions 1 and 2)
|
||||
//! into the same kind of document.
|
||||
//!
|
||||
//! - `bytes`: little-endian writing and bounds-checked reading.
|
||||
//! - `dib`: device-independent bitmaps, both ways.
|
||||
//! - `scene`: the document walk both writers share.
|
||||
//! - `emf`, `wmf`: the writers.
|
||||
//! - `import`: the reader.
|
||||
//! - `import`: the readers.
|
||||
|
||||
mod bytes;
|
||||
mod dib;
|
||||
@@ -107,5 +108,28 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
/// A QuickDraw picture read into a document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Picture {
|
||||
pub document: Document,
|
||||
/// What was approximated or skipped.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Do `bytes` hold a version 2 QuickDraw picture, after a PICT file's 512 bytes of application
|
||||
/// data or on their own? A version 1 picture is known by its file's name.
|
||||
pub fn is_pict(bytes: &[u8]) -> bool {
|
||||
import::pict::is_pict(bytes)
|
||||
}
|
||||
|
||||
/// Read a QuickDraw picture (version 1 or 2; a PICT file, or a picture on its own) into a document
|
||||
/// whose one artboard is the picture's frame.
|
||||
pub fn import_pict(bytes: &[u8]) -> Result<Picture, String> {
|
||||
let i = import::pict::play(bytes)?;
|
||||
let mut p = Picture { document: i.document, warnings: i.warnings };
|
||||
p.document.limit_depth_noting(&mut p.warnings);
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -12,6 +12,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
|
||||
use crate::*;
|
||||
|
||||
mod import;
|
||||
mod pict;
|
||||
|
||||
/// Add `n` (with a fresh id) on top of `d`'s first layer.
|
||||
pub(crate) fn add(d: &mut Document, mut n: Node) {
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
//! QuickDraw pictures read from hand-built opcodes: versions 1 and 2, with and without the file's
|
||||
//! application data; shapes, lines, regions, patterns, text, bitmaps, pixel maps and QuickTime
|
||||
//! JPEG images; and pictures that are cut short, damaged or never end.
|
||||
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::import_pict;
|
||||
|
||||
/// Big-endian words.
|
||||
fn be(v: &[i16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_be_bytes()).collect()
|
||||
}
|
||||
|
||||
/// A QuickDraw rectangle: top, left, bottom, right.
|
||||
fn rect(top: i16, left: i16, bottom: i16, right: i16) -> Vec<u8> {
|
||||
be(&[top, left, bottom, right])
|
||||
}
|
||||
|
||||
/// A version 2 PICT file: 512 bytes of application data, then a picture of `frame` (top, left,
|
||||
/// bottom, right) holding `ops`, each padded to an even length, and the end opcode.
|
||||
fn v2(frame: [i16; 4], ops: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut b = vec![0xA5; 512];
|
||||
b.extend([0, 0]);
|
||||
b.extend(be(&frame));
|
||||
b.extend([0x00, 0x11, 0x02, 0xFF, 0x0C, 0x00]);
|
||||
b.extend(be(&[-1, 0]));
|
||||
b.extend([0; 20]);
|
||||
for (op, data) in ops {
|
||||
b.extend(op.to_be_bytes());
|
||||
b.extend(data);
|
||||
if b.len() % 2 == 1 {
|
||||
b.push(0);
|
||||
}
|
||||
}
|
||||
b.extend([0x00, 0xFF]);
|
||||
b
|
||||
}
|
||||
|
||||
/// An RGB foreground color.
|
||||
fn fg(r: u16, g: u16, b: u16) -> (u16, Vec<u8>) {
|
||||
(0x1A, [r, g, b].iter().flat_map(|c| c.to_be_bytes()).collect())
|
||||
}
|
||||
|
||||
/// Every object of the imported document, depth first.
|
||||
fn all(d: &Document) -> Vec<Node> {
|
||||
let mut v = vec![];
|
||||
for l in &d.layers {
|
||||
for c in l.children().into_iter().flatten() {
|
||||
c.walk(&mut |n| v.push(n.clone()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn paths(d: &Document) -> Vec<Node> {
|
||||
all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { clipping: false, .. })).collect()
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect, tol: f64) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
fn fill_rgb(n: &Node) -> Option<[u8; 3]> {
|
||||
n.appearance.items.iter().find_map(|i| match i {
|
||||
AppearanceItem::Fill(f) => f.paint.color().map(|c| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn stroke(n: &Node) -> Option<([u8; 3], f64)> {
|
||||
n.appearance.items.iter().find_map(|i| match i {
|
||||
AppearanceItem::Stroke(s) => s.paint.color().map(|c| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
([r, g, b], s.width)
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The images of `d` with their pixels.
|
||||
fn images(d: &Document) -> Vec<(Node, image::RgbaImage)> {
|
||||
all(d)
|
||||
.into_iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
let png = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
Some((n.clone(), png))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_2_picture_opens_as_shapes_in_its_frame() {
|
||||
let bytes = v2(
|
||||
[10, 20, 110, 220],
|
||||
&[
|
||||
fg(0xFFFF, 0, 0),
|
||||
// paintRect
|
||||
(0x31, rect(20, 30, 60, 80)),
|
||||
(0x07, be(&[4, 4])),
|
||||
fg(0, 0, 0xFFFF),
|
||||
// frameRect: stroked inside the rectangle.
|
||||
(0x30, rect(20, 120, 80, 200)),
|
||||
// A line, and one from its end: one path.
|
||||
(0x20, be(&[90, 40, 90, 140])),
|
||||
(0x21, be(&[100, 140])),
|
||||
],
|
||||
);
|
||||
assert!(crate::is_pict(&bytes));
|
||||
let p = import_pict(&bytes).unwrap();
|
||||
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
|
||||
let a = p.document.artboards[0].rect;
|
||||
assert!(near(a, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-9), "{a:?}");
|
||||
let v = paths(&p.document);
|
||||
assert_eq!(v.len(), 3, "{v:?}");
|
||||
assert_eq!(fill_rgb(&v[0]), Some([255, 0, 0]));
|
||||
assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 60.0, 50.0), 1e-6));
|
||||
assert!(v[0].appearance.stroke().is_none());
|
||||
// The pen's outer edge is the rectangle.
|
||||
assert_eq!(stroke(&v[1]), Some(([0, 0, 255], 4.0)));
|
||||
assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(102.0, 12.0, 178.0, 68.0), 1e-6));
|
||||
assert!(fill_rgb(&v[1]).is_none());
|
||||
// The pen hangs below and to the right of the line.
|
||||
assert_eq!(stroke(&v[2]), Some(([0, 0, 255], 4.0)));
|
||||
assert!(near(v[2].geometric_bounds().unwrap(), Rect::new(22.0, 82.0, 122.0, 92.0), 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_picture_and_a_version_1_picture_open() {
|
||||
// A version 2 picture without the file's application data.
|
||||
let file = v2([0, 0, 50, 50], &[(0x31, rect(10, 10, 20, 20))]);
|
||||
let bare = &file[512..];
|
||||
assert!(crate::is_pict(bare));
|
||||
assert_eq!(paths(&import_pict(bare).unwrap().document).len(), 1);
|
||||
// Version 1: byte opcodes, known by name only.
|
||||
let mut v1 = vec![0, 0];
|
||||
v1.extend(rect(0, 0, 40, 60));
|
||||
v1.extend([0x11, 0x01, 0x31]);
|
||||
v1.extend(rect(5, 5, 15, 25));
|
||||
v1.extend([0xA0, 0x00, 0x05, 0x50]);
|
||||
v1.extend(rect(20, 20, 30, 30));
|
||||
v1.push(0xFF);
|
||||
assert!(!crate::is_pict(&v1));
|
||||
let p = import_pict(&v1).unwrap();
|
||||
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
|
||||
assert_eq!(p.document.artboards[0].rect.size(), (60.0, 40.0).into());
|
||||
let v = paths(&p.document);
|
||||
assert_eq!(v.len(), 2);
|
||||
assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 25.0, 15.0), 1e-6));
|
||||
// The default pen: one point, black.
|
||||
assert_eq!(stroke(&v[1]), Some(([0, 0, 0], 1.0)));
|
||||
// Inside a file's application data too.
|
||||
let mut file = vec![0; 512];
|
||||
file.extend(&v1);
|
||||
assert_eq!(paths(&import_pict(&file).unwrap().document).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_are_not_pictures_are_refused() {
|
||||
assert_eq!(import_pict(&[0; 600]).unwrap_err(), "not a PICT file");
|
||||
assert_eq!(import_pict(b"").unwrap_err(), "not a PICT file");
|
||||
// An empty frame.
|
||||
let mut b = vec![0, 0];
|
||||
b.extend(rect(10, 10, 10, 50));
|
||||
b.extend([0x11, 0x01, 0xFF]);
|
||||
assert!(import_pict(&b).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_rectangles_ovals_arcs_and_same_shapes() {
|
||||
let bytes = v2(
|
||||
[0, 0, 100, 100],
|
||||
&[
|
||||
(0x0B, be(&[20, 20])),
|
||||
(0x41, rect(0, 0, 50, 50)),
|
||||
(0x51, rect(50, 0, 100, 50)),
|
||||
// A wedge from 12 to 3 o'clock.
|
||||
(0x61, [rect(0, 50, 100, 150), be(&[0, 90])].concat()),
|
||||
fg(0, 0xFFFF, 0),
|
||||
// paintSameRect after paintRect: the same rectangle.
|
||||
(0x31, rect(60, 60, 70, 80)),
|
||||
(0x39, vec![]),
|
||||
],
|
||||
);
|
||||
let p = import_pict(&bytes).unwrap();
|
||||
let v = paths(&p.document);
|
||||
assert_eq!(v.len(), 5, "{v:?}");
|
||||
assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 50.0), 1e-6));
|
||||
assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(0.0, 50.0, 50.0, 100.0), 1e-3));
|
||||
assert!(near(v[2].geometric_bounds().unwrap(), Rect::new(100.0, 0.0, 150.0, 50.0), 1e-3), "{:?}", v[2].geometric_bounds());
|
||||
assert_eq!(v[3].geometric_bounds(), v[4].geometric_bounds());
|
||||
assert_eq!(fill_rgb(&v[4]), Some([0, 255, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygons_and_regions_fill_and_clip() {
|
||||
// An L: the top band 20 wide, the bottom band 10.
|
||||
let region = [be(&[36]), rect(0, 0, 20, 20), be(&[0, 0, 20, 0x7FFF, 10, 10, 20, 0x7FFF, 20, 0, 10, 0x7FFF, 0x7FFF])].concat();
|
||||
let poly = [be(&[22]), rect(0, 0, 40, 40), be(&[0, 0, 40, 40, 0, 40])].concat();
|
||||
let bytes = v2([0, 0, 100, 100], &[(0x81, region.clone()), (0x71, poly), (0x01, region), (0x31, rect(0, 0, 100, 100))]);
|
||||
let p = import_pict(&bytes).unwrap();
|
||||
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
|
||||
let v = paths(&p.document);
|
||||
assert_eq!(v.len(), 3);
|
||||
let NodeKind::Path { path, .. } = &v[0].kind else { panic!("a path") };
|
||||
assert_eq!(path.subpaths.len(), 2, "two bands");
|
||||
assert!(near(v[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 20.0, 20.0), 1e-9));
|
||||
assert!(near(v[1].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 40.0, 40.0), 1e-9));
|
||||
// The rectangle after the clip region is clipped to it.
|
||||
let layer = p.document.layers[0].children().unwrap();
|
||||
let NodeKind::Group { children, clip: true } = &layer[2].kind else { panic!("a clipping group: {:?}", layer[2].kind) };
|
||||
assert!(near(children[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 20.0, 20.0), 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_fill_with_their_average_color() {
|
||||
let gray = vec![0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55];
|
||||
let bytes = v2(
|
||||
[0, 0, 10, 10],
|
||||
&[(0x0A, gray), (0x34, rect(0, 0, 10, 10)), (0x12, [be(&[2]), vec![0; 8], be(&[0, -1, 0])].concat()), (0x32, rect(0, 0, 5, 5))],
|
||||
);
|
||||
let p = import_pict(&bytes).unwrap();
|
||||
let v = paths(&p.document);
|
||||
assert_eq!(fill_rgb(&v[0]), Some([128, 128, 128]));
|
||||
// eraseRect with an RGB background pattern.
|
||||
assert_eq!(fill_rgb(&v[1]), Some([0, 255, 0]));
|
||||
assert!(p.warnings.iter().any(|w| w.contains("average of their colors")), "{:?}", p.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_comes_in_as_point_type_in_the_named_font() {
|
||||
let name = [be(&[9, 300]), vec![6], b"Geneva".to_vec()].concat();
|
||||
let bytes = v2(
|
||||
[0, 0, 100, 200],
|
||||
&[(0x2C, name), (0x03, be(&[300])), (0x0D, be(&[18])), (0x04, vec![1]), (0x28, [be(&[50, 10]), vec![4], b"Caf\x8E".to_vec()].concat())],
|
||||
);
|
||||
let p = import_pict(&bytes).unwrap();
|
||||
let texts: Vec<_> = all(&p.document).into_iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).collect();
|
||||
let NodeKind::Text(t) = &texts[0].kind else { panic!("text") };
|
||||
assert_eq!(t.plain_text(), "Café");
|
||||
let st = &t.runs[0].style;
|
||||
assert_eq!((st.font_family.as_str(), st.font_style.as_str(), st.size), ("Geneva", "Bold", 18.0));
|
||||
// The text location is the baseline's start.
|
||||
assert_eq!(t.xf.translation(), Point::new(10.0, 50.0).to_vec2());
|
||||
}
|
||||
|
||||
/// A pixel map's fields after `rowBytes` and its bounds: version, packing, resolution, depth and
|
||||
/// components.
|
||||
fn pixmap_fields(pack: i16, size: i16, cmps: i16) -> Vec<u8> {
|
||||
be(&[0, pack, 0, 0, 72, 0, 72, 0, 0, size, cmps, 8, 0, 0, 0, 0, 0, 0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packed_indexed_pixel_map_becomes_an_image() {
|
||||
let table = be(&[0, 0, 0, 1, 0, -1, 0, 0, 1, 0, 0, -1]);
|
||||
// An 8 × 2 map: a run of red, then blue and red by turns.
|
||||
let rows = [vec![2, 0xF9, 0], vec![9, 7, 1, 0, 1, 0, 1, 0, 1, 0]].concat();
|
||||
let data =
|
||||
[be(&[0x8008u16 as i16]), rect(0, 0, 2, 8), pixmap_fields(0, 8, 1), table, rect(0, 0, 2, 8), rect(10, 20, 14, 36), be(&[0]), rows].concat();
|
||||
let p = import_pict(&v2([0, 0, 50, 50], &[(0x98, data)])).unwrap();
|
||||
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
|
||||
let imgs = images(&p.document);
|
||||
assert_eq!(imgs.len(), 1);
|
||||
let (n, px) = &imgs[0];
|
||||
assert_eq!(px.dimensions(), (8, 2));
|
||||
assert_eq!(px.get_pixel(0, 0).0, [255, 0, 0, 255]);
|
||||
assert_eq!(px.get_pixel(0, 1).0, [0, 0, 255, 255]);
|
||||
assert_eq!(px.get_pixel(1, 1).0, [255, 0, 0, 255]);
|
||||
// Drawn into its destination, twice its size.
|
||||
assert!(near(n.geometric_bounds().unwrap(), Rect::new(20.0, 10.0, 36.0, 14.0), 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_pixel_maps_and_masked_bitmaps() {
|
||||
// 32-bit, packed by component planes: red, then green.
|
||||
let row = vec![7, 5, 255, 0, 0, 255, 0, 0];
|
||||
let direct =
|
||||
[be(&[0, 0xFF, 0x8008u16 as i16]), rect(0, 0, 1, 2), pixmap_fields(4, 32, 3), rect(0, 0, 1, 2), rect(0, 0, 1, 2), be(&[0]), row].concat();
|
||||
// A one-bit bitmap ORed in (its clear bits left out), masked by a region.
|
||||
let mask = [be(&[10]), rect(0, 0, 1, 1)].concat();
|
||||
let bitmap = [be(&[2]), rect(0, 0, 1, 2), rect(0, 0, 1, 2), rect(5, 0, 6, 2), be(&[1]), mask, vec![0x80, 0]].concat();
|
||||
let p = import_pict(&v2([0, 0, 10, 10], &[(0x9A, direct), (0x91, bitmap)])).unwrap();
|
||||
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
|
||||
let imgs = images(&p.document);
|
||||
assert_eq!(imgs.len(), 2);
|
||||
assert_eq!(imgs[0].1.get_pixel(0, 0).0, [255, 0, 0, 255]);
|
||||
assert_eq!(imgs[0].1.get_pixel(1, 0).0, [0, 255, 0, 255]);
|
||||
assert_eq!(imgs[1].1.get_pixel(0, 0).0, [0, 0, 0, 255]);
|
||||
assert_eq!(imgs[1].1.get_pixel(1, 0).0[3], 0, "the clear bit is left out");
|
||||
let layer = p.document.layers[0].children().unwrap();
|
||||
assert!(matches!(layer[1].kind, NodeKind::Group { clip: true, .. }), "the mask region clips the bitmap");
|
||||
}
|
||||
|
||||
/// A pixel map's rows may be only a little longer than its pixels need: a map one pixel wide
|
||||
/// with rows of 16,383 bytes is left out, and a pixel pattern like it fills with its bitmap.
|
||||
#[test]
|
||||
fn rows_far_longer_than_their_pixels_are_refused() {
|
||||
let table = be(&[0, 0, 0, 1, 0, -1, 0, 0, 1, -1, 0, 0]);
|
||||
// A row of 16,383 ones: 127 runs of 128 and one of 127, after its byte count.
|
||||
let mut row = be(&[256]);
|
||||
(0..127).for_each(|_| row.extend([0x81, 1]));
|
||||
row.extend([0x82, 1]);
|
||||
let rows = row.repeat(4);
|
||||
let map = [be(&[0xBFFFu16 as i16]), rect(0, 0, 4, 1), pixmap_fields(0, 8, 1), table.clone()].concat();
|
||||
let data = [map, rect(0, 0, 4, 1), rect(0, 0, 4, 1), be(&[0]), rows].concat();
|
||||
let p = import_pict(&v2([0, 0, 10, 10], &[(0x98, data)])).unwrap();
|
||||
assert!(images(&p.document).is_empty());
|
||||
assert!(p.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", p.warnings);
|
||||
|
||||
let pattern = [be(&[1]), vec![0xFF; 8], be(&[0xBFFFu16 as i16]), rect(0, 0, 1, 1), pixmap_fields(0, 8, 1), table, row].concat();
|
||||
let p = import_pict(&v2([0, 0, 10, 10], &[(0x12, pattern), (0x32, rect(0, 0, 5, 5))])).unwrap();
|
||||
assert_eq!(fill_rgb(&paths(&p.document)[0]), Some([0, 0, 0]), "the pattern's bitmap in the foreground color");
|
||||
}
|
||||
|
||||
/// BitsRect rows are stored unpacked, also when a row is 8 bytes or longer.
|
||||
#[test]
|
||||
fn bitmap_rows_of_eight_bytes_or_more_are_read_unpacked() {
|
||||
let row = vec![0x80, 0, 0, 0, 0, 0, 0, 0];
|
||||
let bitmap = [be(&[8]), rect(0, 0, 1, 16), rect(0, 0, 1, 16), rect(0, 0, 1, 16), be(&[0]), row].concat();
|
||||
let p = import_pict(&v2([0, 0, 10, 20], &[(0x90, bitmap), (0x31, rect(2, 2, 4, 4))])).unwrap();
|
||||
assert!(p.warnings.is_empty(), "{:?}", p.warnings);
|
||||
let imgs = images(&p.document);
|
||||
assert_eq!(imgs.len(), 1);
|
||||
assert_eq!(imgs[0].1.dimensions(), (16, 1));
|
||||
assert_eq!(imgs[0].1.get_pixel(0, 0).0, [0, 0, 0, 255]);
|
||||
assert_eq!(imgs[0].1.get_pixel(1, 0).0, [255, 255, 255, 255]);
|
||||
assert_eq!(paths(&p.document).len(), 1, "the rectangle after it");
|
||||
}
|
||||
|
||||
/// An empty clip region clips away what follows it.
|
||||
#[test]
|
||||
fn an_empty_clip_region_hides_what_follows() {
|
||||
let empty = [be(&[10]), rect(0, 0, 0, 0)].concat();
|
||||
let p = import_pict(&v2([0, 0, 50, 50], &[(0x01, empty), (0x31, rect(0, 0, 10, 10))])).unwrap();
|
||||
let layer = p.document.layers[0].children().unwrap();
|
||||
let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) };
|
||||
let b = children[0].geometric_bounds().unwrap();
|
||||
assert_eq!((b.width(), b.height()), (0.0, 0.0), "{b:?}");
|
||||
}
|
||||
|
||||
/// A QuickTime image of `ctype` with `matrix` (a, b, c, d, tx, ty in points) and source rectangle
|
||||
/// `w` × `h`, holding `data`.
|
||||
fn quicktime(ctype: &[u8; 4], matrix: [f64; 6], w: i16, h: i16, data: &[u8]) -> Vec<u8> {
|
||||
let fixed = |v: f64| ((v * 65536.0) as i32).to_be_bytes();
|
||||
let [a, b, c, d, tx, ty] = matrix;
|
||||
let mut q = be(&[0]);
|
||||
for v in [a, b, 0.0, c, d, 0.0, tx, ty] {
|
||||
q.extend(fixed(v));
|
||||
}
|
||||
q.extend((1i32 << 30).to_be_bytes());
|
||||
q.extend([0; 4]);
|
||||
q.extend([0; 8]);
|
||||
q.extend(be(&[0]));
|
||||
q.extend(rect(0, 0, h, w));
|
||||
q.extend([0; 4]);
|
||||
q.extend([0; 4]);
|
||||
let mut desc = vec![];
|
||||
desc.extend(86u32.to_be_bytes());
|
||||
desc.extend(ctype);
|
||||
desc.extend([0; 24]);
|
||||
desc.extend(be(&[w, h]));
|
||||
desc.extend([0; 8]);
|
||||
desc.extend((data.len() as u32).to_be_bytes());
|
||||
desc.resize(86, 0);
|
||||
q.extend(desc);
|
||||
q.extend(data);
|
||||
[(q.len() as u32).to_be_bytes().to_vec(), q].concat()
|
||||
}
|
||||
|
||||
fn red_jpeg() -> Vec<u8> {
|
||||
let img = image::RgbImage::from_pixel(4, 2, image::Rgb([255, 0, 0]));
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
img.write_to(&mut out, image::ImageFormat::Jpeg).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quicktime_jpeg_images_are_placed_by_their_matrix() {
|
||||
let jpeg = red_jpeg();
|
||||
let ops = [
|
||||
(0x8200, quicktime(b"jpeg", [2.0, 0.0, 0.0, 2.0, 10.0, 5.0], 4, 2, &jpeg)),
|
||||
(0x8200, quicktime(b"rle ", [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], 4, 2, &[1, 2, 3])),
|
||||
];
|
||||
let p = import_pict(&v2([0, 0, 50, 50], &ops)).unwrap();
|
||||
let imgs = images(&p.document);
|
||||
assert_eq!(imgs.len(), 1);
|
||||
let (n, px) = &imgs[0];
|
||||
assert_eq!(px.dimensions(), (4, 2));
|
||||
let [r, g, b, _] = px.get_pixel(1, 1).0;
|
||||
assert!(r > 240 && g < 16 && b < 16, "{r} {g} {b}");
|
||||
assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 5.0, 18.0, 9.0), 1e-9));
|
||||
assert!(p.warnings.iter().any(|w| w.contains("other than JPEG")), "{:?}", p.warnings);
|
||||
}
|
||||
|
||||
/// An image whose matrix flattens it is left out, and adds nothing to the document's images.
|
||||
#[test]
|
||||
fn a_flattened_image_adds_no_image_data() {
|
||||
let p = import_pict(&v2([0, 0, 50, 50], &[(0x8200, quicktime(b"jpeg", [0.0; 6], 4, 2, &red_jpeg()))])).unwrap();
|
||||
assert!(images(&p.document).is_empty());
|
||||
assert!(p.document.images.is_empty(), "{:?}", p.document.images.keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_cut_short_keep_what_came_before() {
|
||||
let full = v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10)), (0x31, rect(20, 20, 30, 30))]);
|
||||
// Cut inside the second rectangle.
|
||||
let cut = &full[..full.len() - 6];
|
||||
let p = import_pict(cut).unwrap();
|
||||
assert_eq!(paths(&p.document).len(), 1);
|
||||
assert!(p.warnings.iter().any(|w| w.contains("cut short")), "{:?}", p.warnings);
|
||||
// A long comment longer than the file.
|
||||
let p = import_pict(&v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10)), (0xA1, be(&[100, 5000]))])).unwrap();
|
||||
assert_eq!(paths(&p.document).len(), 1);
|
||||
assert!(p.warnings.iter().any(|w| w.contains("cut short")), "{:?}", p.warnings);
|
||||
// Nothing drawn.
|
||||
let p = import_pict(&v2([0, 0, 50, 50], &[(0xA0, be(&[100]))])).unwrap();
|
||||
assert!(p.warnings.iter().any(|w| w.contains("draws nothing")), "{:?}", p.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_opcodes_are_skipped_by_their_lengths() {
|
||||
let ops = [
|
||||
// A 16-bit length, a fixed length for Apple's reserved words, and a 32-bit length.
|
||||
(0x0024, be(&[4, 1, 2])),
|
||||
(0x0200, be(&[1, 2])),
|
||||
(0x00D0, [8u32.to_be_bytes().to_vec(), vec![9; 8]].concat()),
|
||||
(0x8201, [2u32.to_be_bytes().to_vec(), vec![9; 2]].concat()),
|
||||
(0x31, rect(0, 0, 10, 10)),
|
||||
];
|
||||
let p = import_pict(&v2([0, 0, 50, 50], &ops)).unwrap();
|
||||
assert_eq!(paths(&p.document).len(), 1, "{:?}", p.warnings);
|
||||
}
|
||||
|
||||
/// A picture of nothing but no-ops stops at the opcode limit.
|
||||
#[test]
|
||||
fn a_picture_that_never_ends_stops_at_the_opcode_limit() {
|
||||
let mut b = v2([0, 0, 50, 50], &[(0x31, rect(0, 0, 10, 10))]);
|
||||
b.truncate(b.len() - 2);
|
||||
b.resize(b.len() + 2 * ((1 << 22) + 1), 0);
|
||||
let p = import_pict(&b).unwrap();
|
||||
assert_eq!(paths(&p.document).len(), 1);
|
||||
assert!(p.warnings.iter().any(|w| w.contains("more opcodes")), "{:?}", p.warnings);
|
||||
}
|
||||
|
||||
/// Regions share one budget of inversion points per picture: past it, a region is left out.
|
||||
#[test]
|
||||
fn regions_past_the_picture_budget_are_left_out() {
|
||||
// One scanline of 16,000 inversion points: 8,000 rectangles.
|
||||
let mut words = vec![0];
|
||||
words.extend(0..16_000i16);
|
||||
words.extend([0x7FFF, 1, 0x7FFF, 0x7FFF]);
|
||||
let body = [rect(0, 0, 1, 16_000), be(&words)].concat();
|
||||
let region = [be(&[(body.len() + 2) as i16]), body].concat();
|
||||
let p = import_pict(&v2([0, 0, 300, 300], &[(0x81, region.clone()), (0x81, region.clone()), (0x81, region)])).unwrap();
|
||||
assert!(p.warnings.iter().any(|w| w.contains("regions larger")), "{:?}", p.warnings);
|
||||
let v = paths(&p.document);
|
||||
assert_eq!(v.len(), 2);
|
||||
let NodeKind::Path { path, .. } = &v[0].kind else { panic!("a path") };
|
||||
assert_eq!(path.subpaths.len(), 8_000);
|
||||
}
|
||||
@@ -90,6 +90,17 @@ fn japanese_type_with_mojikumi_is_laid_out_again() {
|
||||
assert_eq!(layouts_again(&none, true), 0);
|
||||
}
|
||||
|
||||
/// A middle dot is a character the Mojikumi sets space: type holding only middle dots is laid out
|
||||
/// again with each set that spaces them.
|
||||
#[test]
|
||||
fn middle_dots_with_mojikumi_are_laid_out_again() {
|
||||
for set in [Mojikumi::YakumonoHankaku, Mojikumi::GyomatsuYakumonoHankaku] {
|
||||
let mut t = point("・・");
|
||||
t.para.mojikumi = set;
|
||||
assert_eq!(layouts_again(&t, true), 1, "{set:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_japanese_run_or_paragraph_lays_the_whole_object_out_again() {
|
||||
let mut runs = point("Latin ");
|
||||
|
||||
@@ -167,6 +167,8 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g
|
||||
g.sx *= g_des;
|
||||
g.dx *= g_des;
|
||||
g.kern_shift = (g.kern_shift - g.aki_shift) * g_des + g.aki_shift;
|
||||
// The space Mojikumi took off before the glyph scales with it.
|
||||
g.lead *= g_des;
|
||||
}
|
||||
f.width = g.adv - aki;
|
||||
f.scale = g_des;
|
||||
|
||||
@@ -4,11 +4,13 @@ use super::breaking::{
|
||||
roman_share, set_width, squeeze_em, tab_advance, word_width,
|
||||
};
|
||||
use super::region::Region;
|
||||
use super::vertical::{EM_CENTER, align_shift, compress_punctuation, glyph_em, punct_half, space_japanese_and_latin};
|
||||
use super::vertical::{
|
||||
EM_CENTER, MojikumiRules, align_shift, compress_punctuation, dot_quarter, glyph_em, halve_punctuation, punct_half, space_japanese_and_latin,
|
||||
};
|
||||
use super::{
|
||||
Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, Kinsoku, KinsokuShoriType, LeadingModel, LineFit, LineInfo,
|
||||
ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose,
|
||||
fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics,
|
||||
ListMarker, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line,
|
||||
hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics,
|
||||
};
|
||||
use crate::justify::is_format_control;
|
||||
use crate::shape::{keep_inseparable_pairs, keep_korean_words};
|
||||
@@ -76,7 +78,7 @@ fn compose_para(
|
||||
// The same rule as the line loop below: an opening bracket starting a wrapped line gives up
|
||||
// the space before it.
|
||||
let start_credit = |i: usize| {
|
||||
let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf;
|
||||
let flush = !rtl && MojikumiRules::of(para.mojikumi).flush_start;
|
||||
sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0)
|
||||
};
|
||||
let mut sim = pen.clone();
|
||||
@@ -195,7 +197,8 @@ struct ParagraphData<'a, 'text> {
|
||||
wakan: Vec<f64>,
|
||||
/// How much narrower a line ending with each glyph is set ([`line_end_trim`]), for measuring
|
||||
/// where lines break; empty when the paragraph's lines are measured at full width (Mojikumi
|
||||
/// None, or right-to-left text).
|
||||
/// None or YakumonoZenkaku, or right-to-left text with a set that adds no space between
|
||||
/// Japanese and Latin text).
|
||||
line_end: Vec<f64>,
|
||||
flex: Vec<Flex>,
|
||||
marker: usize,
|
||||
@@ -234,14 +237,26 @@ fn prepare_paragraph<'a, 'text>(
|
||||
if para.bunri_kinshi && para.kinsoku != Kinsoku::None {
|
||||
keep_inseparable_pairs(text, &mut sg);
|
||||
}
|
||||
// Japanese composition: consecutive punctuation shares one half-em space.
|
||||
let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] };
|
||||
// Japanese composition (the paragraph's Mojikumi set): every punctuation mark set half width,
|
||||
// or consecutive punctuation sharing one half-em space…
|
||||
let rules = MojikumiRules::of(para.mojikumi);
|
||||
let mut compressed = if rules.half {
|
||||
halve_punctuation(&mut sg)
|
||||
} else if rules.share {
|
||||
compress_punctuation(&mut sg)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
// …and a quarter em between Japanese and Latin text.
|
||||
let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] };
|
||||
let mut wakan = if rules.wakan { space_japanese_and_latin(&mut sg) } else { vec![] };
|
||||
// Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how
|
||||
// far a justified line may move each one from it (Dynamic Text's Block on area type sets the
|
||||
// Desired word spacing to its word gap).
|
||||
let mut flex = apply_desired(&mut sg, &t.layout_justification(para), !cx.vertical);
|
||||
// The Japanese–Latin space is part of the advance Desired glyph scaling scaled.
|
||||
for (w, f) in wakan.iter_mut().zip(&flex) {
|
||||
*w *= f.scale;
|
||||
}
|
||||
// Threaded text: a first paragraph that continues from the frame before has its first
|
||||
// line, first-line indent and list marker there.
|
||||
let continued = pi == 0 && t.first_para_continues;
|
||||
@@ -277,11 +292,9 @@ fn prepare_paragraph<'a, 'text>(
|
||||
pen.pending += para.space_before;
|
||||
}
|
||||
// A line fits when it fits as it is set: with its last character narrowed by Mojikumi.
|
||||
let line_end = if para.mojikumi == Mojikumi::LineEndHalf && !rtl {
|
||||
(0..sg.len()).map(|k| line_end_trim(&sg, &compressed, &wakan, k)).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let end_rules = rules.at_line_end(rtl);
|
||||
let line_end =
|
||||
if end_rules.trims_line_end() { (0..sg.len()).map(|k| line_end_trim(&sg, &compressed, &wakan, end_rules, k)).collect() } else { vec![] };
|
||||
let composed = compose_para(cx, &sg, &flex, &line_end, para, pen, rtl, continued);
|
||||
// Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the
|
||||
// Hyphen Limit counts the lines in a row that end with a hyphen.
|
||||
@@ -369,7 +382,7 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
|
||||
// space before it goes), and the line has that much more room. At a paragraph's start
|
||||
// that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①).
|
||||
if !*rtl
|
||||
&& para.mojikumi == Mojikumi::LineEndHalf
|
||||
&& MojikumiRules::of(para.mojikumi).flush_start
|
||||
&& let Some(glyph) = sg.get_mut(*i)
|
||||
&& glyph.lead <= 0.0
|
||||
&& let Some(half) = punct_half(glyph, Punct::Opening)
|
||||
@@ -536,14 +549,17 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
|
||||
true
|
||||
}
|
||||
|
||||
/// Mojikumi (Line-end Punctuation Half Width): how much narrower a line ending with glyph `k` of
|
||||
/// `sg` is set. A closing bracket, comma or full stop ending the line is set half width, unless it
|
||||
/// already lost that half to the punctuation after it (`compressed`), and no Japanese–Latin space
|
||||
/// (`wakan`) is left after the line's last character. Never more than the glyph's advance, and 0
|
||||
/// for an advance or a space that is not finite.
|
||||
pub(super) fn line_end_trim(sg: &[SGlyph], compressed: &[bool], wakan: &[f64], k: usize) -> f64 {
|
||||
/// Mojikumi: how much narrower a line ending with glyph `k` of `sg` is set by the paragraph's set
|
||||
/// `rules`. With [`MojikumiRules::half_end`], a closing bracket, comma or full stop ending the line
|
||||
/// is set half width, and with [`MojikumiRules::dot_end`] a middle dot loses the quarter em after
|
||||
/// it, unless the glyph already lost that space (`compressed`); no Japanese–Latin space (`wakan`)
|
||||
/// is left after the line's last character. Never more than the glyph's advance, and 0 for an
|
||||
/// advance or a space that is not finite.
|
||||
pub(super) fn line_end_trim(sg: &[SGlyph], compressed: &[bool], wakan: &[f64], rules: MojikumiRules, k: usize) -> f64 {
|
||||
let Some(g) = sg.get(k) else { return 0.0 };
|
||||
let half = if compressed.get(k).copied().unwrap_or(false) { None } else { punct_half(g, Punct::Closing) };
|
||||
let lost = compressed.get(k).copied().unwrap_or(false);
|
||||
let half = (!lost && rules.half_end).then(|| punct_half(g, Punct::Closing)).flatten();
|
||||
let half = half.or_else(|| (!lost && rules.dot_end).then(|| dot_quarter(g)).flatten());
|
||||
let trim = half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0);
|
||||
if trim.is_finite() && g.adv.is_finite() { trim.min(g.adv).max(0.0) } else { 0.0 }
|
||||
}
|
||||
@@ -563,10 +579,11 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
|
||||
trimmed -= 1;
|
||||
}
|
||||
let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1]));
|
||||
let rules = MojikumiRules::of(para.mojikumi).at_line_end(paragraph.rtl);
|
||||
let end_trim = trimmed
|
||||
.checked_sub(1)
|
||||
.filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf)
|
||||
.map_or(0.0, |k| line_end_trim(sg, ¶graph.compressed, ¶graph.wakan, k));
|
||||
.filter(|&k| k >= index && rules.trims_line_end())
|
||||
.map_or(0.0, |k| line_end_trim(sg, ¶graph.compressed, ¶graph.wakan, rules, k));
|
||||
let measured_width = set_width(sg, index, trimmed, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim;
|
||||
let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte);
|
||||
let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::flow::flow;
|
||||
use super::region::Region;
|
||||
use super::{Affine, Ctx, Punct, Range, Rect, SGlyph, Tcy, TextLayout, TextObject, VerticalAlign, continues_cluster, is_cjk, punct};
|
||||
use super::{Affine, Ctx, Mojikumi, Punct, Range, Rect, SGlyph, Tcy, TextLayout, TextObject, VerticalAlign, continues_cluster, is_cjk, punct};
|
||||
|
||||
/// System Layout on or Fractional Widths off: each character that takes space advances by a whole
|
||||
/// number of points, at least one, so small type does not collapse onto itself. A zero advance (a
|
||||
@@ -126,6 +126,84 @@ pub(super) fn punct_half(g: &SGlyph, kind: Punct) -> Option<f64> {
|
||||
((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.5 * g.sx)
|
||||
}
|
||||
|
||||
/// A quarter em of `g`'s size when it is a full-width Japanese middle dot (・, its advance an em,
|
||||
/// give or take a tenth): the space on each side of the dot that a Mojikumi set can take off.
|
||||
pub(super) fn dot_quarter(g: &SGlyph) -> Option<f64> {
|
||||
if g.ch != '・' || g.tcy.is_some() || g.proportional {
|
||||
return None;
|
||||
}
|
||||
let em = g.face.units_per_em();
|
||||
((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.25 * g.sx)
|
||||
}
|
||||
|
||||
/// What a paragraph's Mojikumi set does to its characters. The five predefined sets other than
|
||||
/// Line-end Punctuation Half Width follow VectorCraft's own spacing rules, built on the help
|
||||
/// page's one-line description of each set
|
||||
/// (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html>).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) struct MojikumiRules {
|
||||
/// Consecutive punctuation shares one half-em space ([`compress_punctuation`]).
|
||||
pub share: bool,
|
||||
/// Every opening and closing bracket, comma and full stop is set half width, and a middle dot
|
||||
/// loses the quarter em on each side of it ([`halve_punctuation`]).
|
||||
pub half: bool,
|
||||
/// An opening bracket starting a line is set flush with the line's start.
|
||||
pub flush_start: bool,
|
||||
/// A closing bracket, comma or full stop ending a line is set half width.
|
||||
pub half_end: bool,
|
||||
/// A middle dot ending a line loses the quarter em after it.
|
||||
pub dot_end: bool,
|
||||
/// A quarter em between Japanese and Latin letters or digits ([`space_japanese_and_latin`]),
|
||||
/// taken off again at a line's end.
|
||||
pub wakan: bool,
|
||||
}
|
||||
|
||||
impl MojikumiRules {
|
||||
pub(super) fn of(set: Mojikumi) -> Self {
|
||||
let none = Self::default();
|
||||
match set {
|
||||
Mojikumi::None | Mojikumi::YakumonoZenkaku => none,
|
||||
Mojikumi::LineEndHalf => Self { share: true, flush_start: true, half_end: true, wakan: true, ..none },
|
||||
Mojikumi::YakumonoHankaku => Self { half: true, wakan: true, ..none },
|
||||
Mojikumi::GyomatsuYakumonoHankaku => Self { flush_start: true, half_end: true, dot_end: true, wakan: true, ..none },
|
||||
Mojikumi::GyomatsuYakumonoZenkaku => Self { wakan: true, ..none },
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules a line's end follows: a right-to-left line keeps its punctuation's width there
|
||||
/// and only loses the space between Japanese and Latin text.
|
||||
pub(super) fn at_line_end(self, rtl: bool) -> Self {
|
||||
if rtl { Self { half_end: false, dot_end: false, ..self } } else { self }
|
||||
}
|
||||
|
||||
/// Whether a line ending at some character can be set narrower than its characters' advances.
|
||||
pub(super) fn trims_line_end(&self) -> bool {
|
||||
self.half_end || self.dot_end || self.wakan
|
||||
}
|
||||
}
|
||||
|
||||
/// YakumonoHankaku: every full-width opening bracket loses the half-em space before it, every
|
||||
/// closing bracket, comma and full stop the half-em space after it, and every middle dot a quarter
|
||||
/// em on each side. Returns which glyphs lost the space after them (a line ending in one takes
|
||||
/// nothing more off).
|
||||
pub(super) fn halve_punctuation(sg: &mut [SGlyph]) -> Vec<bool> {
|
||||
let mut lost_after = vec![false; sg.len()];
|
||||
for (g, lost) in sg.iter_mut().zip(lost_after.iter_mut()) {
|
||||
if let Some(h) = punct_half(g, Punct::Closing) {
|
||||
g.adv -= h;
|
||||
*lost = true;
|
||||
} else if let Some(h) = punct_half(g, Punct::Opening) {
|
||||
g.adv -= h;
|
||||
g.lead += h;
|
||||
} else if let Some(q) = dot_quarter(g) {
|
||||
g.adv -= 2.0 * q;
|
||||
g.lead += q;
|
||||
*lost = true;
|
||||
}
|
||||
}
|
||||
lost_after
|
||||
}
|
||||
|
||||
/// Mojikumi (JLREQ 3.1.4): consecutive punctuation shares one half-em space. A closing bracket,
|
||||
/// comma or full stop followed by punctuation loses the space after it; an opening bracket after
|
||||
/// another loses the space before it. Returns which glyphs lost the space after them (a line
|
||||
@@ -154,7 +232,7 @@ pub(super) fn compress_punctuation(sg: &mut [SGlyph]) -> Vec<bool> {
|
||||
}
|
||||
|
||||
/// The space between Japanese and Latin letters or digits (JLREQ 3.2.2: a quarter em of the
|
||||
/// Japanese characters' size), with Line-end Punctuation Half Width.
|
||||
/// Japanese characters' size), with the Mojikumi sets that add it ([`MojikumiRules::wakan`]).
|
||||
const WAKAN_AKI: f64 = 0.25;
|
||||
|
||||
/// A Japanese character for the space next to Latin text: kana and kanji (and full-width
|
||||
@@ -174,12 +252,12 @@ fn is_japanese_letter(c: char) -> bool {
|
||||
}
|
||||
|
||||
impl TextLayout {
|
||||
/// Whether Line-end Punctuation Half Width (Mojikumi) can set type laid out as this layout
|
||||
/// differently from Mojikumi None: `text`, the type's plain text, or one of the layout's list
|
||||
/// markers holds Japanese punctuation or a Japanese letter, the only characters it spaces.
|
||||
/// When it holds neither, the type lays out the same with either setting.
|
||||
/// Whether a Mojikumi set can set type laid out as this layout differently from Mojikumi
|
||||
/// None: `text`, the type's plain text, or one of the layout's list markers holds Japanese
|
||||
/// punctuation, a middle dot or a Japanese letter, the only characters the sets space. When it
|
||||
/// holds none of them, the type lays out the same with any set.
|
||||
pub fn mojikumi_applies(&self, text: &str) -> bool {
|
||||
let spaced = |c: char| punct(c).is_some() || is_japanese_letter(c);
|
||||
let spaced = |c: char| punct(c).is_some() || c == '・' || is_japanese_letter(c);
|
||||
text.chars().any(spaced) || self.markers.iter().any(|m| m.text.chars().any(spaced))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,7 +453,7 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option<Vec<u8>> {
|
||||
pub const FULL_WIDTH_FAMILY: &str = "Fullwidthtest Sans";
|
||||
|
||||
/// The ideographs and Japanese punctuation [`full_width_font`] sets one em wide.
|
||||
pub const FULL_WIDTH_CHARS: &str = "一二三四五六「」、。";
|
||||
pub const FULL_WIDTH_CHARS: &str = "一二三四五六「」、。・";
|
||||
|
||||
/// A test-only cmap over bundled Source Sans 3: [`FULL_WIDTH_CHARS`] use the one-em-wide glyph of
|
||||
/// U+2318, so Japanese composition is tested without OS fonts; Latin letters and the space keep
|
||||
|
||||
@@ -1145,6 +1145,249 @@ fn mojikumi_line_end_half_width_counts_when_a_line_breaks() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The predefined Mojikumi sets space punctuation by their own rules, horizontal and vertical:
|
||||
/// YakumonoHankaku sets every bracket, comma and full stop half width and a middle dot a half em
|
||||
/// (a quarter off each side, drawn a quarter em earlier); GyomatsuYakumonoHankaku keeps them full
|
||||
/// width inside the line, sets an opening bracket starting the line flush and a closing mark ending
|
||||
/// it half width, and takes the quarter em after a middle dot ending the line off;
|
||||
/// GyomatsuYakumonoZenkaku and YakumonoZenkaku keep every mark full width. All but YakumonoZenkaku
|
||||
/// put a quarter em between Japanese and Latin letters. Uses a test font whose ideographs and
|
||||
/// punctuation are one em.
|
||||
#[test]
|
||||
fn mojikumi_predefined_sets_space_punctuation_by_their_rules() {
|
||||
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(full_width_font().expect("test font"));
|
||||
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
|
||||
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.vertical = vertical_type;
|
||||
t.para.mojikumi = m;
|
||||
layout(&db, &t)
|
||||
};
|
||||
let round = |v: Vec<f64>| v.into_iter().map(|x| (x * 100.0).round() / 100.0).collect::<Vec<_>>();
|
||||
for vertical_type in [false, true] {
|
||||
let adv = |text: &str, m| round(lay(text, m, vertical_type).glyphs.iter().map(|g| g.advance).collect());
|
||||
let at = |m: Mojikumi| format!("vertical {vertical_type} {m:?}");
|
||||
let middle = "一「二」三・四、五。";
|
||||
let cases = [
|
||||
(Mojikumi::None, [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0]),
|
||||
(Mojikumi::YakumonoHankaku, [20.0, 10.0, 20.0, 10.0, 20.0, 10.0, 20.0, 10.0, 20.0, 10.0]),
|
||||
(Mojikumi::GyomatsuYakumonoHankaku, [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 10.0]),
|
||||
(Mojikumi::GyomatsuYakumonoZenkaku, [20.0; 10]),
|
||||
(Mojikumi::YakumonoZenkaku, [20.0; 10]),
|
||||
];
|
||||
for (m, want) in cases {
|
||||
assert_eq!(adv(middle, m), want, "{}", at(m));
|
||||
}
|
||||
// Consecutive punctuation: YakumonoHankaku halves both marks, GyomatsuYakumonoHankaku
|
||||
// neither (Line-end Punctuation Half Width only the first).
|
||||
assert_eq!(adv("一」「二", Mojikumi::YakumonoHankaku), [20.0, 10.0, 10.0, 20.0], "vertical {vertical_type}");
|
||||
assert_eq!(adv("一」「二", Mojikumi::GyomatsuYakumonoHankaku), [20.0; 4], "vertical {vertical_type}");
|
||||
assert_eq!(adv("一」「二", Mojikumi::LineEndHalf), [20.0, 10.0, 20.0, 20.0], "vertical {vertical_type}");
|
||||
// A line's start and end: the opening bracket flush and the middle dot's outer quarter off.
|
||||
assert_eq!(adv("「一・", Mojikumi::GyomatsuYakumonoHankaku), [10.0, 20.0, 15.0], "vertical {vertical_type}");
|
||||
assert_eq!(adv("「一・", Mojikumi::YakumonoHankaku), [10.0, 20.0, 10.0], "vertical {vertical_type}");
|
||||
assert_eq!(adv("「一・", Mojikumi::GyomatsuYakumonoZenkaku), [20.0; 3], "vertical {vertical_type}");
|
||||
// YakumonoHankaku draws an opening bracket half an em and a middle dot a quarter em
|
||||
// earlier than its pen.
|
||||
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
|
||||
let (on, off) = (lay("一「二", Mojikumi::YakumonoHankaku, vertical_type), lay("一「二", Mojikumi::None, vertical_type));
|
||||
let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box());
|
||||
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: 「 drawn half an em earlier ({moved})");
|
||||
let (on, off) = (lay("一・二", Mojikumi::YakumonoHankaku, vertical_type), lay("一・二", Mojikumi::None, vertical_type));
|
||||
let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box());
|
||||
assert!((moved - 5.0).abs() < 0.01, "vertical {vertical_type}: ・ drawn a quarter em earlier ({moved})");
|
||||
// The space between Japanese and Latin letters.
|
||||
let none = adv("一A二", Mojikumi::None);
|
||||
for (m, extra) in [
|
||||
(Mojikumi::YakumonoHankaku, 5.0),
|
||||
(Mojikumi::GyomatsuYakumonoHankaku, 5.0),
|
||||
(Mojikumi::GyomatsuYakumonoZenkaku, 5.0),
|
||||
(Mojikumi::YakumonoZenkaku, 0.0),
|
||||
] {
|
||||
let got = adv("一A二", m);
|
||||
assert!((got[0] - none[0] - extra).abs() < 0.01 && (got[1] - none[1] - extra).abs() < 0.01, "{}: {got:?} {none:?}", at(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The predefined Mojikumi sets measure a line as they set it, with both composers, horizontal
|
||||
/// and vertical: in a measure of three and a half ems, a closing bracket that fits only half width
|
||||
/// ends the line with YakumonoHankaku and GyomatsuYakumonoHankaku, and goes to the next line with
|
||||
/// the character before it (kinsoku) with GyomatsuYakumonoZenkaku and YakumonoZenkaku. A middle dot
|
||||
/// that fits only without the quarter em after it ends the line with GyomatsuYakumonoHankaku.
|
||||
#[test]
|
||||
fn mojikumi_predefined_sets_count_when_a_line_breaks() {
|
||||
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
|
||||
use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi};
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(full_width_font().expect("test font"));
|
||||
let lay = |text: &str, m: Mojikumi, composer: Composer, vertical_type: bool, measure: f64| {
|
||||
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.vertical = vertical_type;
|
||||
t.para.mojikumi = m;
|
||||
t.para.composer = composer;
|
||||
t.para.burasagari = Burasagari::None;
|
||||
t.para.kinsoku = Kinsoku::Hard;
|
||||
t.para.kinsoku_type = KinsokuShoriType::PushOutOnly;
|
||||
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, measure) } else { Rect::new(0.0, 0.0, measure, 200.0) };
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
||||
layout(&db, &t)
|
||||
};
|
||||
let first = |l: &TextLayout, text: &str| l.lines.first().map(|line| text[line.start..line.end].to_string()).unwrap_or_default();
|
||||
let widest = |l: &TextLayout| {
|
||||
l.lines.iter().map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::<f64>()).fold(0.0, f64::max)
|
||||
};
|
||||
for vertical_type in [false, true] {
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
let text = "一二三」四五六";
|
||||
for (m, line) in [
|
||||
(Mojikumi::YakumonoHankaku, "一二三」"),
|
||||
(Mojikumi::GyomatsuYakumonoHankaku, "一二三」"),
|
||||
(Mojikumi::GyomatsuYakumonoZenkaku, "一二"),
|
||||
(Mojikumi::YakumonoZenkaku, "一二"),
|
||||
] {
|
||||
let l = lay(text, m, composer, vertical_type, 70.0);
|
||||
assert_eq!(first(&l, text), line, "vertical {vertical_type} {composer:?} {m:?}");
|
||||
assert!(widest(&l) <= 70.0 + 1e-9, "vertical {vertical_type} {composer:?} {m:?}: {}", widest(&l));
|
||||
}
|
||||
let text = "一二三・四五六";
|
||||
for (m, line) in [(Mojikumi::GyomatsuYakumonoHankaku, "一二三・"), (Mojikumi::GyomatsuYakumonoZenkaku, "一二"), (Mojikumi::None, "一二")]
|
||||
{
|
||||
let l = lay(text, m, composer, vertical_type, 76.0);
|
||||
assert_eq!(first(&l, text), line, "vertical {vertical_type} {composer:?} {m:?}");
|
||||
assert!(widest(&l) <= 76.0 + 1e-9, "vertical {vertical_type} {composer:?} {m:?}: {}", widest(&l));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A right-to-left paragraph measures its lines at full width with every Mojikumi set, and draws
|
||||
/// them as it measures them: a closing bracket ending a line keeps its full em, with both composers.
|
||||
#[test]
|
||||
fn mojikumi_right_to_left_lines_are_drawn_as_measured() {
|
||||
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
|
||||
use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi, ParaDirection};
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(full_width_font().expect("test font"));
|
||||
let lay = |text: &str, m: Mojikumi, composer: Composer, measure: f64| {
|
||||
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.para.direction = Some(ParaDirection::RightToLeft);
|
||||
t.para.mojikumi = m;
|
||||
t.para.composer = composer;
|
||||
t.para.burasagari = Burasagari::None;
|
||||
t.para.kinsoku = Kinsoku::Hard;
|
||||
t.para.kinsoku_type = KinsokuShoriType::PushOutOnly;
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, measure, 200.0).to_path(0.1)) };
|
||||
layout(&db, &t)
|
||||
};
|
||||
let text = "一二三」四五六";
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
for m in [Mojikumi::LineEndHalf, Mojikumi::GyomatsuYakumonoHankaku] {
|
||||
let at = format!("{composer:?} {m:?}");
|
||||
// Three and a half ems: the bracket does not fit at full width, so kinsoku takes 三 on.
|
||||
let narrow = lay(text, m, composer, 70.0);
|
||||
let first = narrow.lines.first().map(|line| &text[line.start..line.end]);
|
||||
assert_eq!(first, Some("一二"), "{at}");
|
||||
// Four and a quarter ems: the line is measured four ems wide and drawn so.
|
||||
let l = lay(text, m, composer, 85.0);
|
||||
let first = l.lines.first().map(|line| &text[line.start..line.end]);
|
||||
assert_eq!(first, Some("一二三」"), "{at}");
|
||||
let bracket = text.find('」').and_then(|byte| l.glyphs.iter().find(|g| g.byte == byte)).map(|g| g.advance);
|
||||
assert_eq!(bracket.map(|a| (a * 100.0).round() / 100.0), Some(20.0), "{at}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A right-to-left line ending between a Japanese and a Latin character does not keep the quarter
|
||||
/// em the set adds there: in three ems, 一二三 fits before Latin text, measured and drawn three ems
|
||||
/// wide, with both composers and every set that adds the space.
|
||||
#[test]
|
||||
fn mojikumi_right_to_left_lines_end_without_the_japanese_latin_space() {
|
||||
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
|
||||
use vectorcraft_doc::{Composer, Mojikumi, ParaDirection};
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(full_width_font().expect("test font"));
|
||||
let text = "一二三AB";
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
for m in [Mojikumi::LineEndHalf, Mojikumi::YakumonoHankaku, Mojikumi::GyomatsuYakumonoHankaku, Mojikumi::GyomatsuYakumonoZenkaku] {
|
||||
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.para.direction = Some(ParaDirection::RightToLeft);
|
||||
t.para.mojikumi = m;
|
||||
t.para.composer = composer;
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 60.0, 200.0).to_path(0.1)) };
|
||||
let l = layout(&db, &t);
|
||||
let line = l.lines.first();
|
||||
assert_eq!(line.map(|line| &text[line.start..line.end]), Some("一二三"), "{composer:?} {m:?}");
|
||||
let drawn = line.map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::<f64>());
|
||||
assert_eq!(drawn.map(|w| (w * 100.0).round() / 100.0), Some(60.0), "{composer:?} {m:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// With a Desired glyph scaling other than 100%, the space a Mojikumi set takes off before an
|
||||
/// opening bracket or a middle dot scales with the glyph: the glyph is drawn that much earlier,
|
||||
/// with YakumonoHankaku and with Line-end Punctuation Half Width's consecutive brackets.
|
||||
#[test]
|
||||
fn mojikumi_space_taken_off_scales_with_desired_glyph_scaling() {
|
||||
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(full_width_font().expect("test font"));
|
||||
let lay = |text: &str, m: Mojikumi| {
|
||||
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.para.mojikumi = m;
|
||||
t.para.justification.glyph_scaling = [50.0, 50.0, 100.0];
|
||||
layout(&db, &t)
|
||||
};
|
||||
for (text, m, k, want) in
|
||||
[("一「二", Mojikumi::YakumonoHankaku, 1, 5.0), ("一・二", Mojikumi::YakumonoHankaku, 1, 2.5), ("一「「二", Mojikumi::LineEndHalf, 2, 5.0)]
|
||||
{
|
||||
let (on, off) = (lay(text, m), lay(text, Mojikumi::None));
|
||||
let pen = |l: &TextLayout| l.glyphs[..k].iter().map(|g| g.advance).sum::<f64>();
|
||||
let drawn = |l: &TextLayout| l.glyphs[k].outline.bounding_box().x0 - pen(l);
|
||||
let moved = drawn(&off) - drawn(&on);
|
||||
assert!((moved - want).abs() < 0.01, "{text} {m:?}: drawn {moved} earlier, want {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With a Desired glyph scaling other than 100%, a line ending before Latin text is measured
|
||||
/// without the scaled quarter em the set added there, not an unscaled one: at 50%, 一二三 is 30 pt
|
||||
/// wide and does not fit 29 pt, with both composers and every set that adds the space.
|
||||
#[test]
|
||||
fn mojikumi_line_end_japanese_latin_space_scales_with_desired_glyph_scaling() {
|
||||
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
|
||||
use vectorcraft_doc::{Composer, Mojikumi};
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(full_width_font().expect("test font"));
|
||||
let text = "一二三AB";
|
||||
for composer in [Composer::SingleLine, Composer::EveryLine] {
|
||||
for m in [Mojikumi::LineEndHalf, Mojikumi::YakumonoHankaku, Mojikumi::GyomatsuYakumonoHankaku, Mojikumi::GyomatsuYakumonoZenkaku] {
|
||||
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.para.mojikumi = m;
|
||||
t.para.composer = composer;
|
||||
t.para.justification.glyph_scaling = [50.0, 50.0, 100.0];
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 29.0, 200.0).to_path(0.1)) };
|
||||
let l = layout(&db, &t);
|
||||
let first = l.lines.first().map(|line| &text[line.start..line.end]);
|
||||
assert_eq!(first, Some("一二"), "{composer:?} {m:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mojikumi: in a frame that is narrow at the top, and in a frame a wrap object narrows at the top,
|
||||
/// a line's first word is measured as the line sets it. A word ending with a closing bracket that
|
||||
/// fits the narrow lines only with the bracket half width, and an opening bracket that fits them
|
||||
|
||||
@@ -573,12 +573,15 @@ impl Tool for SmartPenTool {
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = vec![];
|
||||
if cx.pen_rubber_band
|
||||
&& self.press.is_none()
|
||||
// The rubber band preference hides only the preview segment; Measurement Labels decides the readout.
|
||||
if self.press.is_none()
|
||||
&& let (Some((id, _, sp)), Some(h)) = (self.active(cx), self.hover)
|
||||
&& let Some(path) = self.rubber_band(sp, h)
|
||||
{
|
||||
o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
|
||||
if cx.pen_rubber_band
|
||||
&& let Some(path) = self.rubber_band(sp, h)
|
||||
{
|
||||
o.push(Overlay::Path { path, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
|
||||
}
|
||||
if let Some(last) = sp.anchors.last().map(|a| a.p)
|
||||
&& cx.measurement_labels
|
||||
&& last.distance(h) > 1e-9
|
||||
|
||||
@@ -315,6 +315,35 @@ fn the_rubber_band_shows_the_next_segment_and_ending_the_path() {
|
||||
assert!(t.overlays(&cx).is_empty());
|
||||
}
|
||||
|
||||
/// With Enable Rubber Band for the Pen off, the preview segment is hidden but Measurement Labels
|
||||
/// still shows the readout from the last anchor to the pointer.
|
||||
#[test]
|
||||
fn the_measurement_label_shows_with_the_rubber_band_off() {
|
||||
let (d, _, s) = doc(line());
|
||||
let p = paint();
|
||||
let on = plain(&d, &s, &p);
|
||||
let off = ToolContext { pen_rubber_band: false, ..plain(&d, &s, &p) };
|
||||
let (from, to) = (Point::new(200.0, 100.0), Point::new(300.0, 160.0));
|
||||
let measure =
|
||||
|o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::<Vec<_>>();
|
||||
let band = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { .. }));
|
||||
let mut t = drawing(&off, from);
|
||||
t.pointer(&off, &ev(PointerKind::Move, to.x, to.y, NO));
|
||||
let o = t.overlays(&off);
|
||||
assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}");
|
||||
assert!(!band(&o), "{o:?}");
|
||||
let o = t.overlays(&on);
|
||||
assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}");
|
||||
assert!(band(&o), "{o:?}");
|
||||
for rubber in [true, false] {
|
||||
let quiet = ToolContext { pen_rubber_band: rubber, measurement_labels: false, ..plain(&d, &s, &p) };
|
||||
assert!(measure(&t.overlays(&quiet)).is_empty());
|
||||
}
|
||||
// Hovering exactly on the last anchor measures no segment.
|
||||
t.pointer(&off, &ev(PointerKind::Move, from.x, from.y, NO));
|
||||
assert!(measure(&t.overlays(&off)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_drag_away_from_paths_selects_the_points_inside_a_marquee() {
|
||||
let (d, id, s) = doc(SubPath::polyline(&[Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(300.0, 100.0)], false));
|
||||
|
||||
@@ -718,38 +718,44 @@ impl Tool for CurvatureTool {
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = self.snap.guides().to_vec();
|
||||
let band = cx.curvature_rubber_band && self.drag.is_none();
|
||||
// The rubber band preference hides only the preview curve; Measurement Labels decides the readout.
|
||||
let idle = self.drag.is_none();
|
||||
let band = cx.curvature_rubber_band;
|
||||
let mut bp = BezPath::new();
|
||||
if self.active(cx).is_none() {
|
||||
// The curve on from the current end of a selected path: its last segment and the next.
|
||||
if band
|
||||
if idle
|
||||
&& let (Some(e), Some(h)) = (current_end(cx), self.hover)
|
||||
&& let Some(sp) = subpath(cx, e.id, e.si)
|
||||
{
|
||||
let n = sp.anchors.len();
|
||||
let near = if e.start {
|
||||
sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect()
|
||||
} else {
|
||||
sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect()
|
||||
};
|
||||
let mut tail = SubPath::new(near, false);
|
||||
curvature_extend(&mut tail, false, h, self.how(&e), false);
|
||||
tail.to_bezpath_into(&mut bp);
|
||||
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
|
||||
if band {
|
||||
let n = sp.anchors.len();
|
||||
let near = if e.start {
|
||||
sp.anchors.iter().take(2).rev().map(Anchor::reversed).collect()
|
||||
} else {
|
||||
sp.anchors.iter().skip(n.saturating_sub(2)).copied().collect()
|
||||
};
|
||||
let mut tail = SubPath::new(near, false);
|
||||
curvature_extend(&mut tail, false, h, self.how(&e), false);
|
||||
tail.to_bezpath_into(&mut bp);
|
||||
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
|
||||
}
|
||||
if cx.measurement_labels && e.p.distance(h) > 1e-9 {
|
||||
o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(e.p, h) });
|
||||
}
|
||||
}
|
||||
return o;
|
||||
}
|
||||
if band
|
||||
if idle
|
||||
&& !self.closed
|
||||
&& let Some(h) = self.hover
|
||||
{
|
||||
let mut pts = self.pts.clone();
|
||||
pts.push((h, false));
|
||||
curvature_spline(&pts, false).to_bezpath_into(&mut bp);
|
||||
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
|
||||
if band {
|
||||
let mut pts = self.pts.clone();
|
||||
pts.push((h, false));
|
||||
curvature_spline(&pts, false).to_bezpath_into(&mut bp);
|
||||
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
|
||||
}
|
||||
if let Some((from, _)) = self.pts.last()
|
||||
&& cx.measurement_labels
|
||||
&& from.distance(h) > 1e-9
|
||||
|
||||
@@ -539,3 +539,51 @@ fn clicks_go_on_from_a_current_end_and_the_other_end_closes() {
|
||||
let a = press(&mut t, &quiet(&d, &s, &p), -50.0, 150.0, false);
|
||||
assert!(matches!(edit_op(&a[0]), Some(("extend", v)) if v["end"] == "start" && v["from"] == "keep"), "{a:?}");
|
||||
}
|
||||
|
||||
/// With Enable Rubber Band for the Curvature tool off, the preview curve is hidden but
|
||||
/// Measurement Labels still shows the readout, both while drawing a path and going on from the
|
||||
/// current end of a selected one.
|
||||
#[test]
|
||||
fn the_measurement_label_shows_with_the_rubber_band_off() {
|
||||
let p = paint();
|
||||
let measure =
|
||||
|o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::<Vec<_>>();
|
||||
let band = |o: &[Overlay]| o.iter().any(|o| matches!(o, Overlay::Path { .. }));
|
||||
let to = pt(650.0, 60.0);
|
||||
// Going on from the current end of a selected path.
|
||||
let (d, id) = doc_with(wave());
|
||||
let s = selected(id, Some(4));
|
||||
let on = quiet(&d, &s, &p);
|
||||
let off = ToolContext { curvature_rubber_band: false, ..quiet(&d, &s, &p) };
|
||||
let mut t = CurvatureTool::default();
|
||||
t.pointer(&off, &PointerEvent::new(PointerKind::Move, to.x, to.y));
|
||||
let from = pt(450.0, 60.0);
|
||||
let o = t.overlays(&off);
|
||||
assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}");
|
||||
assert!(!band(&o), "{o:?}");
|
||||
let o = t.overlays(&on);
|
||||
assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}");
|
||||
assert!(band(&o), "{o:?}");
|
||||
// A path being drawn.
|
||||
let (d, id) = doc_with(SubPath::polyline(&[pt(100.0, 100.0), pt(200.0, 150.0)], false));
|
||||
let s = selected(id, None);
|
||||
let on = quiet(&d, &s, &p);
|
||||
let off = ToolContext { curvature_rubber_band: false, ..quiet(&d, &s, &p) };
|
||||
let mut t = CurvatureTool { id: Some(id), pts: vec![(pt(100.0, 100.0), false), (pt(200.0, 150.0), false)], ..CurvatureTool::default() };
|
||||
assert!(t.active(&off).is_some());
|
||||
t.pointer(&off, &PointerEvent::new(PointerKind::Move, to.x, to.y));
|
||||
let from = pt(200.0, 150.0);
|
||||
let o = t.overlays(&off);
|
||||
assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}");
|
||||
assert!(!band(&o), "{o:?}");
|
||||
let o = t.overlays(&on);
|
||||
assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}");
|
||||
assert!(band(&o), "{o:?}");
|
||||
for rubber in [true, false] {
|
||||
let unlabeled = ToolContext { curvature_rubber_band: rubber, measurement_labels: false, ..quiet(&d, &s, &p) };
|
||||
assert!(measure(&t.overlays(&unlabeled)).is_empty());
|
||||
}
|
||||
// Hovering exactly on the last point measures no segment.
|
||||
t.pointer(&off, &PointerEvent::new(PointerKind::Move, from.x, from.y));
|
||||
assert!(measure(&t.overlays(&off)).is_empty());
|
||||
}
|
||||
|
||||
+11
-9
@@ -495,19 +495,21 @@ impl Tool for PenTool {
|
||||
return t.overlays(cx);
|
||||
}
|
||||
let mut o = vec![];
|
||||
if cx.pen_rubber_band
|
||||
&& self.drag.is_none()
|
||||
// The rubber band preference hides only the preview segment; Measurement Labels decides the readout.
|
||||
if self.drag.is_none()
|
||||
&& self.handle.is_none()
|
||||
&& let (Some((id, _, _, last, out)), Some(h)) = (self.active(cx), self.hover)
|
||||
{
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(last);
|
||||
if out.distance(last) > 1e-9 {
|
||||
bp.quad_to(out, h);
|
||||
} else {
|
||||
bp.line_to(h);
|
||||
if cx.pen_rubber_band {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(last);
|
||||
if out.distance(last) > 1e-9 {
|
||||
bp.quad_to(out, h);
|
||||
} else {
|
||||
bp.line_to(h);
|
||||
}
|
||||
o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
|
||||
}
|
||||
o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
|
||||
if cx.measurement_labels && last.distance(h) > 1e-9 {
|
||||
o.push(Overlay::Measure { p: h + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.segment_measure_label(last, h) });
|
||||
}
|
||||
|
||||
@@ -773,6 +773,48 @@ fn anchors_snap_to_smart_guides() {
|
||||
assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]})));
|
||||
}
|
||||
|
||||
/// With Enable Rubber Band off, the preview segment is hidden but Measurement Labels still shows
|
||||
/// the D / Angle / dX / dY readout at the snapped point before the next click.
|
||||
#[test]
|
||||
fn measurement_label_shows_with_the_rubber_band_off() {
|
||||
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
|
||||
let p = paint();
|
||||
let on = cx(&d, &s, &p);
|
||||
let off = ToolContext { pen_rubber_band: false, ..cx(&d, &s, &p) };
|
||||
let from = Point::new(60.0, 300.0);
|
||||
let to = Point::new(150.0, 318.0);
|
||||
let measure =
|
||||
|o: &[Overlay]| o.iter().filter_map(|o| if let Overlay::Measure { text, .. } = o { Some(text.clone()) } else { None }).collect::<Vec<_>>();
|
||||
let preview = |o: &[Overlay]| {
|
||||
o.iter().any(|o| matches!(o, Overlay::Path { path, .. } if path.elements().first().and_then(|e| e.end_point()) == Some(from)))
|
||||
};
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
t.pointer(&off, &PointerEvent::new(PointerKind::Move, 152.0, 318.0));
|
||||
let o = t.overlays(&off);
|
||||
assert_eq!(measure(&o), vec![off.segment_measure_label(from, to)], "{o:?}");
|
||||
assert!(!preview(&o), "{o:?}");
|
||||
// The rubber band on still shows both.
|
||||
let o = t.overlays(&on);
|
||||
assert_eq!(measure(&o), vec![on.segment_measure_label(from, to)], "{o:?}");
|
||||
assert!(preview(&o), "{o:?}");
|
||||
// Measurement Labels off shows no readout either way.
|
||||
for band in [true, false] {
|
||||
let quiet = ToolContext { pen_rubber_band: band, measurement_labels: false, ..cx(&d, &s, &p) };
|
||||
assert!(measure(&t.overlays(&quiet)).is_empty());
|
||||
}
|
||||
// The click places the same anchor as with the rubber band on.
|
||||
let a = click(&mut t, &off, 152.0, 318.0, Mods::default());
|
||||
assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0, "subpath": 0})));
|
||||
// Hovering exactly on the last anchor measures no segment.
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
t.pointer(&off, &PointerEvent::new(PointerKind::Move, 60.0, 300.0));
|
||||
assert!(measure(&t.overlays(&off)).is_empty());
|
||||
// With no path being drawn there is nothing to measure from.
|
||||
let mut t = PenTool::default();
|
||||
t.pointer(&off, &PointerEvent::new(PointerKind::Move, 152.0, 318.0));
|
||||
assert!(measure(&t.overlays(&off)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_over_a_shown_handle_or_an_anchor_converts() {
|
||||
// The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100).
|
||||
|
||||
@@ -1383,9 +1383,7 @@ pub(crate) fn release_guide_at(cx: &ToolContext, p: Point, m: Mods) -> Option<Ac
|
||||
/// handle that sits where `h` appears on screen, in 45° steps. In a turned view, `angle` is the
|
||||
/// box's own angle less [`ToolContext::view_angle`].
|
||||
pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
||||
let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 };
|
||||
let h = Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h);
|
||||
match h {
|
||||
match screen_handle(h, angle) {
|
||||
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
||||
Handle::Left | Handle::Right => Cursor::ResizeH,
|
||||
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
|
||||
@@ -1393,6 +1391,13 @@ pub(crate) fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
||||
}
|
||||
}
|
||||
|
||||
/// The handle of an upright box that sits where handle `h` of a box turned by `angle`
|
||||
/// (counter-clockwise degrees) appears, in 45° steps.
|
||||
pub(crate) fn screen_handle(h: Handle, angle: f64) -> Handle {
|
||||
let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 };
|
||||
Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
//! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle
|
||||
//! shears along that side. A scale snaps as the Selection tool's does. The Perspective Distort and Free Distort modes (tool option `mode`, or
|
||||
//! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the
|
||||
//! box and preview `object.distort`.
|
||||
//! box and preview `object.distort`. After a rotation the box stays turned with the selection, as
|
||||
//! the Selection tool's does: its handles scale, shear and distort along the selection's own sides.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::OrientedBox;
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::{BLUE, polygon, rect_corners};
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::guides::{PointSnap, Targets};
|
||||
use crate::select::{matrix_json, selection_bounds};
|
||||
use crate::select::{handle_cursor, matrix_json, screen_handle, selection_box};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -69,7 +71,8 @@ impl Op {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Drag {
|
||||
op: Op,
|
||||
rect: Rect,
|
||||
/// The selection's bounding box at the press, turned with the selection.
|
||||
bx: OrientedBox,
|
||||
start: Point,
|
||||
began: bool,
|
||||
}
|
||||
@@ -170,28 +173,37 @@ impl FreeTransformTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(&self, cx: &ToolContext, r: Rect, p: Point, m: Mods) -> Option<Op> {
|
||||
let tol = cx.tol(5.0);
|
||||
if let Some(h) = hit_handle(r, p, tol) {
|
||||
/// What a press at `p` on box `bx` does, read in the box's own frame.
|
||||
fn classify(&self, cx: &ToolContext, bx: &OrientedBox, p: Point, m: Mods) -> Option<Op> {
|
||||
let (tol, r, lp) = (cx.tol(5.0), bx.rect, bx.to_local(p));
|
||||
if let Some(h) = hit_handle(r, lp, tol) {
|
||||
return Some(self.handle_op(h, m));
|
||||
}
|
||||
if self.mode == DistortMode::Free && in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() {
|
||||
if self.mode == DistortMode::Free && in_rotate_zone(r, lp, tol, cx.tol(18.0)).is_some() {
|
||||
return Some(Op::Rotate);
|
||||
}
|
||||
r.contains(p).then_some(Op::Move)
|
||||
r.contains(lp).then_some(Op::Move)
|
||||
}
|
||||
|
||||
fn preview(&mut self, cx: &ToolContext, d: Drag, p: Point, m: Mods) -> Action {
|
||||
let shift = m.shift || self.constrain;
|
||||
let bx = d.bx;
|
||||
let delta = p - d.start;
|
||||
// The drag along the box's own sides.
|
||||
let local = bx.to_local(p) - bx.to_local(d.start);
|
||||
self.guides.clear();
|
||||
let xf = match d.op {
|
||||
Op::Corners(h, mode) => {
|
||||
let q = distort_quad(d.rect, h, delta, mode);
|
||||
let q = distort_quad(bx.rect, h, local, mode).map(|c| bx.to_doc() * c);
|
||||
self.quad = Some(q);
|
||||
self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(delta.x, delta.y)));
|
||||
let corners: Vec<Value> = q.iter().map(|c| json!([c.x, c.y])).collect();
|
||||
return Action::Preview("object.distort".into(), json!({ "corners": corners, "from": [d.rect.x0, d.rect.y0, d.rect.x1, d.rect.y1] }));
|
||||
let r = bx.rect;
|
||||
let mut params = json!({ "corners": corners, "from": [r.x0, r.y0, r.x1, r.y1] });
|
||||
if bx.angle != 0.0 {
|
||||
params["angle"] = json!(bx.angle);
|
||||
}
|
||||
return Action::Preview("object.distort".into(), params);
|
||||
}
|
||||
Op::Move => {
|
||||
let v = move_delta(d.start, p, shift, cx.constrain_angle);
|
||||
@@ -199,22 +211,23 @@ impl FreeTransformTool {
|
||||
Affine::translate(v)
|
||||
}
|
||||
Op::Scale(h) => {
|
||||
let mut a = scale_for_drag(d.rect, h, p, shift, m.alt);
|
||||
// Scaled in the box's own frame, then turned back onto the page.
|
||||
let mut a = scale_for_drag(bx.rect, h, bx.to_local(p), shift, m.alt);
|
||||
if let Some(t) = &self.targets {
|
||||
(a, self.guides) = t.snap_scale(cx, &vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt);
|
||||
(a, self.guides) = t.snap_scale(cx, &bx, h, a, shift, m.alt);
|
||||
}
|
||||
let nr = a.transform_rect_bbox(d.rect);
|
||||
let nr = a.transform_rect_bbox(bx.rect);
|
||||
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
|
||||
a
|
||||
bx.conjugate(a)
|
||||
}
|
||||
Op::Shear(h) => shear_for_side(d.rect, h, delta, m.alt),
|
||||
Op::Shear(h) => bx.conjugate(shear_for_side(bx.rect, h, local, m.alt)),
|
||||
Op::Rotate => {
|
||||
let (a, deg) = rotate_for_drag(d.rect.center(), d.start, p, shift);
|
||||
let (a, deg) = rotate_for_drag(bx.center(), d.start, p, shift);
|
||||
self.measure = cx.transform_tools_guides.then(|| (p, format!("{:.1}°", -deg)));
|
||||
a
|
||||
}
|
||||
};
|
||||
self.quad = Some(rect_corners(d.rect).map(|c| xf * c));
|
||||
self.quad = Some(bx.corners().map(|c| xf * c));
|
||||
Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(xf), "copy": false }))
|
||||
}
|
||||
}
|
||||
@@ -233,10 +246,10 @@ impl Tool for FreeTransformTool {
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
self.drag = None;
|
||||
let Some(r) = selection_bounds(cx) else { return vec![] };
|
||||
if let Some(op) = self.classify(cx, r, p, ev.mods) {
|
||||
let Some(bx) = selection_box(cx) else { return vec![] };
|
||||
if let Some(op) = self.classify(cx, &bx, p, ev.mods) {
|
||||
self.targets = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::for_scale(cx)));
|
||||
self.drag = Some(Drag { op, rect: r, start: p, began: false });
|
||||
self.drag = Some(Drag { op, bx, start: p, began: false });
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
@@ -292,10 +305,12 @@ impl Tool for FreeTransformTool {
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = vec![];
|
||||
let quad = match (self.quad, selection_bounds(cx)) {
|
||||
(Some(q), _) => q,
|
||||
(None, Some(r)) => rect_corners(r),
|
||||
_ => return o,
|
||||
let quad = match self.quad {
|
||||
Some(q) => q,
|
||||
None => match selection_box(cx) {
|
||||
Some(bx) => bx.corners(),
|
||||
None => return o,
|
||||
},
|
||||
};
|
||||
o.push(Overlay::Path { path: polygon(&quad, true), color: BLUE, width: 1.0, dashed: false });
|
||||
for i in 0..4 {
|
||||
@@ -313,19 +328,17 @@ impl Tool for FreeTransformTool {
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
let Some(r) = selection_bounds(cx) else { return Cursor::Arrow };
|
||||
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
|
||||
Some(Op::Rotate) => Cursor::rotate_about_in_view(r.center(), p, cx.view_angle()),
|
||||
let Some(bx) = self.drag.map(|d| d.bx).or_else(|| selection_box(cx)) else { return Cursor::Arrow };
|
||||
// The box's angle as it shows on screen.
|
||||
let shown = bx.angle - cx.view_angle();
|
||||
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, &bx, p, m)) {
|
||||
Some(Op::Rotate) => Cursor::rotate_about_in_view(bx.center(), p, cx.view_angle()),
|
||||
Some(Op::Move) => Cursor::Move,
|
||||
Some(Op::Shear(Handle::Top | Handle::Bottom)) => Cursor::ShearH,
|
||||
Some(Op::Shear(_)) => Cursor::ShearV,
|
||||
Some(Op::Scale(h)) => match h {
|
||||
Handle::Top | Handle::Bottom => Cursor::ResizeV,
|
||||
Handle::Left | Handle::Right => Cursor::ResizeH,
|
||||
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
|
||||
Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw,
|
||||
}
|
||||
.turned(cx.view_angle()),
|
||||
Some(Op::Shear(h)) => match screen_handle(h, shown) {
|
||||
Handle::Top | Handle::Bottom | Handle::TopLeft | Handle::BottomRight => Cursor::ShearH,
|
||||
_ => Cursor::ShearV,
|
||||
},
|
||||
Some(Op::Scale(h)) => handle_cursor(h, shown),
|
||||
Some(Op::Corners(..)) => Cursor::Crosshair,
|
||||
None => Cursor::Arrow,
|
||||
}
|
||||
@@ -422,6 +435,43 @@ mod tests {
|
||||
assert_eq!(t.cursor(&c, Point::new(207.0, 93.0), Mods::default()), Cursor::RotateToward(3), "out from the top right");
|
||||
}
|
||||
|
||||
/// After a 30° turn the box, its handles and their cursors turn with the selection, and a side
|
||||
/// handle scales along the selection's own width.
|
||||
#[test]
|
||||
fn the_box_and_its_handles_turn_with_the_selection() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let c = Vec2::new(150.0, 150.0);
|
||||
let turn = Affine::translate(c) * Affine::rotate(-30f64.to_radians()) * Affine::translate(-c);
|
||||
d.node_mut(id).unwrap().transform(turn, false);
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let bx = selection_box(&cx).unwrap();
|
||||
assert!((bx.angle - 30.0).abs() < 1e-9, "{bx:?}");
|
||||
let mut t = FreeTransformTool::default();
|
||||
let shown: Vec<Point> = t.overlays(&cx).iter().filter_map(|o| if let Overlay::Anchor { p, .. } = o { Some(*p) } else { None }).collect();
|
||||
for corner in bx.corners() {
|
||||
assert!(shown.iter().any(|q| q.distance(corner) < 1e-9), "{corner:?} not in {shown:?}");
|
||||
}
|
||||
let top = bx.to_doc() * Handle::Top.pos(bx.rect);
|
||||
assert_eq!(t.cursor(&cx, top, Mods::default()), Cursor::ResizeNwSe, "the top handle, turned 30°");
|
||||
// The Right handle dragged 100 pt out along the selection's own x axis doubles its width.
|
||||
let right = bx.to_doc() * Handle::Right.pos(bx.rect);
|
||||
let to = bx.to_doc() * (Handle::Right.pos(bx.rect) + Vec2::new(100.0, 0.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Down, right.x, right.y));
|
||||
let a = t.pointer(&cx, &ev(PointerKind::Drag, to.x, to.y));
|
||||
assert_eq!(a[0], Action::Begin("Scale".into()));
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
let m = Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]);
|
||||
let local = bx.to_doc().inverse() * m * bx.to_doc();
|
||||
let want = Affine::translate((bx.rect.x0, 0.0)) * Affine::scale_non_uniform(2.0, 1.0) * Affine::translate((-bx.rect.x0, 0.0));
|
||||
for (got, want) in local.as_coeffs().iter().zip(want.as_coeffs()) {
|
||||
assert!((got - want).abs() < 1e-9, "{local:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_side_handle_shears() {
|
||||
let (d, id) = doc_with_rect();
|
||||
|
||||
@@ -942,27 +942,23 @@ pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key under which the dialog keeps its footer's height for the next frame: the dialog's own
|
||||
/// id, so the stored value is found on every frame.
|
||||
fn footer_id(ui: &egui::Ui) -> egui::Id {
|
||||
ui.layer_id().id.with("char-style-footer")
|
||||
/// The key under which the dialog keeps the height its section content was drawn at, for the next
|
||||
/// frame.
|
||||
fn content_id(ui: &egui::Ui) -> egui::Id {
|
||||
ui.layer_id().id.with("char-style-content")
|
||||
}
|
||||
|
||||
/// The dialog's body: the section list beside the selected section's content, which scrolls when it
|
||||
/// is taller than the window (General lists every attribute of the style and OpenType every
|
||||
/// feature), and the footer below it - Reset Panel, the Preview checkbox and an invalid value's
|
||||
/// error. The content's height is the space the footer leaves, measured last frame, so the buttons
|
||||
/// stay on screen at any interface scale; the section list is outside the scroll area and stays put
|
||||
/// while the content scrolls. Returns whether the dialog asks to be cancelled (never: Cancel comes
|
||||
/// from the button row in `dialogs::show`).
|
||||
/// The dialog's body: the section list beside the selected section's content, and below them
|
||||
/// Reset Panel, the Preview checkbox and an invalid value's error. The content is at most 420 points
|
||||
/// tall, and scrolls when it is taller than that or than the screen less the rest of the window
|
||||
/// (as drawn last frame). The section list stays put while the content scrolls. Returns whether
|
||||
/// the dialog asks to be canceled (never: Cancel comes from the button row in `dialogs::show`).
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
// The content scrolls inside a shorter window: General lists every attribute of the style and
|
||||
// OpenType every feature, so the window stays within the screen and its buttons stay visible.
|
||||
// The footer was measured last frame, so the room left for the content keeps it on screen at any
|
||||
// interface scale; the section list stays put while the content scrolls.
|
||||
let footer = ui.ctx().data(|m| m.get_temp::<f32>(footer_id(ui)).unwrap_or(120.0));
|
||||
let room = (ui.available_height() - footer - 8.0).clamp(0.0, 420.0);
|
||||
ui.horizontal(|ui| {
|
||||
let window = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)).map_or(0.0, |r| r.height());
|
||||
let content = ui.ctx().data(|m| m.get_temp::<f32>(content_id(ui))).unwrap_or(0.0);
|
||||
let rest = (window - content).max(0.0);
|
||||
let room = (ui.ctx().content_rect().height() - rest - 2.0 * super::EDGE_GAP).clamp(0.0, 420.0);
|
||||
let drawn = ui.horizontal(|ui| {
|
||||
section_list(ui, d);
|
||||
ui.separator();
|
||||
// The section list handles a click before this frame's content is laid out, so the section
|
||||
@@ -971,9 +967,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let content_w = ui.available_width();
|
||||
// Long sections scroll both ways: at a large interface scale the window has fewer points, and
|
||||
// a wide row (a character-format grid) must stay inside it rather than widen the window.
|
||||
egui::ScrollArea::both().id_salt(("char-style-sections", section)).max_height(room).max_width(content_w).auto_shrink([false, true]).show(
|
||||
ui,
|
||||
|ui| {
|
||||
let shown = egui::ScrollArea::both()
|
||||
.id_salt(("char-style-sections", section))
|
||||
.max_height(room)
|
||||
.min_scrolled_height(room)
|
||||
.max_width(content_w)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
if d.str("section") == "basic" {
|
||||
basic_body(ui, d);
|
||||
@@ -996,28 +996,24 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
// `inner_rect` is the height offered before a short section shrinks the area to its content.
|
||||
shown.inner_rect.height().min(shown.content_size.y)
|
||||
});
|
||||
let footer_h = ui
|
||||
.scope(|ui| {
|
||||
reset_button(ui, d);
|
||||
match edited_attrs(d) {
|
||||
Ok(attrs) => {
|
||||
let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs});
|
||||
form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params);
|
||||
}
|
||||
Err(why) => attrs_error(ui, &why),
|
||||
}
|
||||
})
|
||||
.response
|
||||
.rect
|
||||
.height();
|
||||
let was = ui.ctx().data(|m| m.get_temp::<f32>(footer_id(ui)));
|
||||
ui.ctx().data_mut(|m| m.insert_temp(footer_id(ui), footer_h));
|
||||
if was != Some(footer_h) {
|
||||
let content_h = drawn.inner;
|
||||
let was = ui.ctx().data(|m| m.get_temp::<f32>(content_id(ui)));
|
||||
ui.ctx().data_mut(|m| m.insert_temp(content_id(ui), content_h));
|
||||
if was.is_none_or(|was| (was - content_h).abs() > 0.5) {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
reset_button(ui, d);
|
||||
match edited_attrs(d) {
|
||||
Ok(attrs) => {
|
||||
let params = json!({"name": d.str("__style"), "to": d.str("name"), "attrs": attrs});
|
||||
form::preview(app, ui, d, tl!("Character Style Options"), "charStyle.edit", params);
|
||||
}
|
||||
Err(why) => attrs_error(ui, &why),
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1127,6 +1123,98 @@ mod tests {
|
||||
let basic = ctx.memory(|m| m.area_rect(dialog)).expect("the dialog is drawn").width();
|
||||
assert!((basic - width).abs() < 0.5, "the width does not change with the section: {width} -> {basic}");
|
||||
}
|
||||
|
||||
/// On a 1920 by 1080 screen the dialog opens with its content 420 points tall when the General
|
||||
/// list is longer than that: the first frame that draws it, with no input, already has the full
|
||||
/// height, and later frames keep it.
|
||||
#[test]
|
||||
fn the_sections_open_at_their_full_height_without_input() {
|
||||
let mut app = app();
|
||||
app.run(
|
||||
"charStyle.new",
|
||||
json!({"name": "Body", "attrs": {
|
||||
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
|
||||
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
|
||||
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
|
||||
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
|
||||
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
|
||||
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
|
||||
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
open(&mut app, "Body").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1920.0, 1080.0));
|
||||
let mut drawn = 0;
|
||||
for _ in 0..6 {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let texts = texts_of(&out);
|
||||
let top = |label: &str| texts.iter().find(|(text, _)| text == label).map(|r| r.1.top());
|
||||
// The first frame only measures the window and draws no text.
|
||||
let (Some(reset), Some(general)) = (top("Reset Panel"), top("General")) else { continue };
|
||||
drawn += 1;
|
||||
let content = reset - general;
|
||||
assert!(content >= 410.0, "the content is 420 points tall in drawn frame {drawn}: {content}");
|
||||
}
|
||||
assert!(drawn >= 4, "the dialog is drawn: {drawn} frames");
|
||||
// On the smallest window the buttons are on the screen in the first frame that draws them.
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 500.0));
|
||||
for _ in 0..3 {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let texts = texts_of(&out);
|
||||
let Some(ok) = texts.iter().find(|(text, _)| text == "OK").map(|r| r.1) else { continue };
|
||||
let cancel = texts.iter().find(|(text, _)| text == "Cancel").expect("Cancel is drawn with OK").1;
|
||||
assert!(ok.bottom() <= 501.0 && cancel.bottom() <= 501.0, "the buttons are on an 800 by 500 screen when first drawn: {ok:?} {cancel:?}");
|
||||
return;
|
||||
}
|
||||
panic!("the dialog is drawn on an 800 by 500 screen");
|
||||
}
|
||||
|
||||
/// Going back to the long General list from a shorter section keeps OK and Cancel on a small
|
||||
/// screen in the first frame after the switch, not only once the window has measured itself again.
|
||||
#[test]
|
||||
fn switching_to_a_long_section_keeps_the_buttons_on_a_small_screen() {
|
||||
let mut app = app();
|
||||
app.run(
|
||||
"charStyle.new",
|
||||
json!({"name": "Body", "attrs": {
|
||||
"font_family": "Source Sans 3", "font_style": "Regular", "size": 24.0, "leading": 30.0,
|
||||
"kerning": 0.0, "tracking": 5.0, "all_caps": false, "small_caps": null,
|
||||
"position": {"kind": "normal", "size": 100.0, "position": 0.0}, "underline": false,
|
||||
"strikethrough": false, "standardVerticalRoman": false, "charAlign": "romanBaseline",
|
||||
"h_scale": 100.0, "v_scale": 100.0, "baseline_shift": 0.0, "rotation": 0.0,
|
||||
"language": "English: USA", "overprint_fill": false, "overprint_stroke": false,
|
||||
"stroke_width": 0.0, "features": ["liga", "calt", "dlig", "swsh", "salt", "titl", "ordn", "frac"]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
open(&mut app, "Body").unwrap();
|
||||
for h in [560.0, 640.0, 720.0] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, h));
|
||||
for section in ["basic", "advanced", "color", "opentype"] {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!(section));
|
||||
let _ = frame_texts_in(&ctx, &mut app, 900.0, h);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("section".into(), json!("general"));
|
||||
let mut out =
|
||||
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let texts = texts_of(&out);
|
||||
for label in ["OK", "Cancel"] {
|
||||
let r = texts.iter().find(|(text, _)| text == label).unwrap_or_else(|| panic!("{label} is drawn")).1;
|
||||
assert!(r.bottom() <= h + 1.0, "{label} stays on a {h}-point screen going from {section} to General: {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog stays usable in the smallest window the app allows (800 by 500 points): in every
|
||||
/// section the section list, OK and Cancel are on the screen.
|
||||
#[test]
|
||||
|
||||
@@ -90,6 +90,9 @@ impl WheelOwner {
|
||||
pub struct MenuLook<'a> {
|
||||
/// Show each family's sample (Preferences › Type › Enable in-menu font previews).
|
||||
pub samples: bool,
|
||||
/// Preferences › Type › Show samples on white: each sample is drawn in dark type on a white
|
||||
/// area, whatever the interface's brightness (off by default).
|
||||
pub on_white: bool,
|
||||
/// Preferences › Type › Show Font Names in English (on by default).
|
||||
pub english_names: bool,
|
||||
/// Row height in points (Font Preview Size).
|
||||
@@ -106,9 +109,9 @@ pub struct MenuLook<'a> {
|
||||
}
|
||||
|
||||
impl Default for MenuLook<'static> {
|
||||
/// Samples on, English names, medium rows, no favourites, no wheel.
|
||||
/// Samples on in the theme's colors, English names, medium rows, no favorites, no wheel.
|
||||
fn default() -> Self {
|
||||
Self { samples: true, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false, wheel_owner: None }
|
||||
Self { samples: true, on_white: false, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false, wheel_owner: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +124,7 @@ impl<'a> MenuLook<'a> {
|
||||
};
|
||||
Self {
|
||||
samples: app.session.prefs.font_preview,
|
||||
on_white: app.session.prefs.font_samples_on_white,
|
||||
english_names: app.session.prefs.font_names_in_english,
|
||||
row,
|
||||
favorites: &app.ui.favorite_fonts,
|
||||
@@ -1235,6 +1239,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio
|
||||
let current = current.as_str();
|
||||
let row_h = look.row;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ink = sample_ink(look.on_white, t.text);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f));
|
||||
let widest = name_width(ui, look.english_names, &rows);
|
||||
@@ -1315,9 +1320,12 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio
|
||||
ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(NAME_SIZE), t.text);
|
||||
// The sample takes the row's whole height.
|
||||
let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 0.0), rect.max - vec2(STAR + 2.0, 0.0));
|
||||
if !look.samples {
|
||||
} else if let Some(tex) = sample_texture(ui.ctx(), family, row.style(), sample, sample_rect.height()) {
|
||||
paint_sample(ui, sample_rect, &tex, t.text);
|
||||
if look.samples {
|
||||
// The paper is drawn while the sample is still rendering, and for a family whose sample fails.
|
||||
let area = sample_area(ui, sample_rect, ink, PAPER_INSET);
|
||||
if let Some(tex) = sample_texture(ui.ctx(), family, row.style(), sample, area.height()) {
|
||||
paint_sample(ui, area, &tex, ink);
|
||||
}
|
||||
}
|
||||
if resp.clicked() && !on_triangle && !on_star {
|
||||
chosen = row.pick();
|
||||
@@ -1366,19 +1374,67 @@ pub(crate) const MENU_SAMPLE_SIZE: egui::Vec2 = vec2(90.0, 16.0);
|
||||
|
||||
/// A generic sample of `family` drawn in `slot` (the room a Type → Font item of the in-window
|
||||
/// menu bar keeps for it; the native macOS menu shows names only).
|
||||
pub(crate) fn menu_item_sample(ui: &Ui, slot: egui::Rect, family: &str) {
|
||||
if let Some(tex) = sample_texture(ui.ctx(), family, None, None, slot.height()) {
|
||||
paint_sample(ui, slot, &tex, Tokens::get(ui.ctx()).text_dim);
|
||||
/// With `on_white` (Show samples on white), the sample is drawn in dark type on a white slot.
|
||||
pub(crate) fn menu_item_sample(ui: &Ui, slot: egui::Rect, family: &str, on_white: bool) {
|
||||
let ink = sample_ink(on_white, Tokens::get(ui.ctx()).text_dim);
|
||||
let area = sample_area(ui, slot, ink, 0.0);
|
||||
if let Some(tex) = sample_texture(ui.ctx(), family, None, None, area.height()) {
|
||||
paint_sample(ui, area, &tex, ink);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw sample `tex` (tinted `tint`) at the left of `area`, centred vertically, cut at its right.
|
||||
fn paint_sample(ui: &Ui, area: egui::Rect, tex: &egui::TextureHandle, tint: Color32) {
|
||||
/// What a sample is drawn on with Show samples on white.
|
||||
pub(crate) const SAMPLE_PAPER: Color32 = Color32::WHITE;
|
||||
|
||||
/// How a sample is drawn: in `theme` (an interface text color) on the menu's own background, or,
|
||||
/// with Show samples on white, in the Light brightness's text color on [`SAMPLE_PAPER`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct SampleInk {
|
||||
pub ink: Color32,
|
||||
pub paper: Option<Color32>,
|
||||
}
|
||||
|
||||
pub(crate) fn sample_ink(on_white: bool, theme: Color32) -> SampleInk {
|
||||
if on_white {
|
||||
SampleInk { ink: Tokens::for_brightness(crate::theme::Brightness::Light).text, paper: Some(SAMPLE_PAPER) }
|
||||
} else {
|
||||
SampleInk { ink: theme, paper: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Space between the left edge of a sample's paper and its type.
|
||||
const PAPER_PAD: f32 = 3.0;
|
||||
|
||||
/// Space above and below a menu row's paper, where the row's highlight color shows.
|
||||
pub(crate) const PAPER_INSET: f32 = 2.0;
|
||||
|
||||
/// The part of `slot` a sample is drawn in: `slot` itself, or, when `ink` has paper, the paper
|
||||
/// painted in `slot` with `inset` points left above and below it.
|
||||
fn sample_area(ui: &Ui, slot: egui::Rect, ink: SampleInk, inset: f32) -> egui::Rect {
|
||||
let Some(paper) = ink.paper else { return slot };
|
||||
let area = slot.shrink2(vec2(0.0, inset));
|
||||
if area.is_positive() {
|
||||
ui.painter_at(slot).rect_filled(area, 2.0, paper);
|
||||
}
|
||||
area
|
||||
}
|
||||
|
||||
/// Draw sample `tex` at the left of `area`, centered vertically, cut at its right; on paper,
|
||||
/// [`PAPER_PAD`] in from its left edge.
|
||||
fn paint_sample(ui: &Ui, area: egui::Rect, tex: &egui::TextureHandle, ink: SampleInk) {
|
||||
let painter = ui.painter_at(area);
|
||||
let mut start = area.left_center();
|
||||
if ink.paper.is_some() {
|
||||
start.x += PAPER_PAD;
|
||||
}
|
||||
let size = tex.size_vec2() / ui.ctx().pixels_per_point();
|
||||
let at = egui::Rect::from_min_size(area.left_center() - vec2(0.0, size.y * 0.5), size);
|
||||
let at = egui::Rect::from_min_size(start - vec2(0.0, size.y * 0.5), size);
|
||||
let shown = at.intersect(area);
|
||||
if !shown.is_positive() {
|
||||
return;
|
||||
}
|
||||
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2((shown.width() / size.x).clamp(0.0, 1.0), 1.0));
|
||||
ui.painter_at(area).image(tex.id(), shown, uv, tint);
|
||||
painter.image(tex.id(), shown, uv, ink.ink);
|
||||
}
|
||||
|
||||
type Key = (String, String, String, u32);
|
||||
|
||||
@@ -882,3 +882,50 @@ fn the_variable_filter_lists_only_variable_families() {
|
||||
assert!(lasting.iter().all(|f| has(&again, f)), "{again:?}");
|
||||
assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}");
|
||||
}
|
||||
|
||||
/// A Type › Font menu item's sample follows Show samples on white: dark type on a white slot,
|
||||
/// or the interface's dimmed text color with nothing behind it. The slot is white while the
|
||||
/// sample is still rendering too.
|
||||
#[test]
|
||||
fn a_menu_items_sample_follows_show_samples_on_white() {
|
||||
use crate::theme::{Brightness, Tokens};
|
||||
let dark = Tokens::for_brightness(Brightness::Light).text;
|
||||
for b in Brightness::ALL {
|
||||
let t = Tokens::for_brightness(b);
|
||||
for on_white in [false, true] {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
|
||||
let slot = egui::Rect::from_min_size(egui::pos2(40.0, 20.0), super::MENU_SAMPLE_SIZE);
|
||||
let frame = || {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::menu_item_sample(ui, slot, "Inter", on_white));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
};
|
||||
frame();
|
||||
super::wait_for_samples(&ctx);
|
||||
let shapes = frame();
|
||||
let tints: Vec<_> = shapes
|
||||
.iter()
|
||||
.filter_map(|s| match &s.shape {
|
||||
egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => m.vertices.first().map(|v| v.color),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let papers =
|
||||
shapes.iter().filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == super::SAMPLE_PAPER && r.rect == slot)).count();
|
||||
assert_eq!(tints, [if on_white { dark } else { t.text_dim }], "{}, on white {on_white}", b.id());
|
||||
assert_eq!(papers, usize::from(on_white), "{}, on white {on_white}", b.id());
|
||||
}
|
||||
}
|
||||
// While a family's sample is still rendering, its slot is already white.
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let slot = egui::Rect::from_min_size(egui::pos2(40.0, 20.0), super::MENU_SAMPLE_SIZE);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::menu_item_sample(ui, slot, "JetBrains Mono", true));
|
||||
out.textures_delta.clear();
|
||||
let shapes = out.shapes;
|
||||
super::wait_for_samples(&ctx);
|
||||
assert!(!shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default())), "no sample");
|
||||
assert!(shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == super::SAMPLE_PAPER && r.rect == slot)), "the slot is white");
|
||||
}
|
||||
|
||||
@@ -679,13 +679,23 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String {
|
||||
}
|
||||
|
||||
/// Fill `{name}` placeholders in one pass: a value that holds `{name}` text stays as it is.
|
||||
/// Unknown placeholders are left as written.
|
||||
/// Unknown placeholders are left as written. `{{` and `}}` are a literal `{` and `}`.
|
||||
fn fill_once(template: &str, args: &[(&str, &str)]) -> String {
|
||||
let mut rest = template;
|
||||
let mut out = String::with_capacity(rest.len());
|
||||
while let Some(open) = rest.find('{') {
|
||||
while let Some(open) = rest.find(['{', '}']) {
|
||||
out.push_str(&rest[..open]);
|
||||
let after = &rest[open + 1..];
|
||||
if rest[open..].starts_with('}') {
|
||||
out.push('}');
|
||||
rest = after.strip_prefix('}').unwrap_or(after);
|
||||
continue;
|
||||
}
|
||||
if let Some(escaped) = after.strip_prefix('{') {
|
||||
out.push('{');
|
||||
rest = escaped;
|
||||
continue;
|
||||
}
|
||||
let filled = after.find('}').and_then(|close| args.iter().find(|(k, _)| *k == &after[..close]).map(|(_, v)| (*v, close)));
|
||||
match filled {
|
||||
Some((value, close)) => {
|
||||
|
||||
@@ -797,6 +797,8 @@ font-technology Variable Променливи
|
||||
Delete Width Point Изтриване на точка на ширината
|
||||
Delete Workspace Изтриване на работно пространство
|
||||
Delete {kind} Style Изтриване на стил ({kind})
|
||||
Delete the selected items and their artwork? Да се изтрият ли избраните елементи заедно с графиката им?
|
||||
Delete this layer and its artwork? Да се изтрие ли този слой заедно с графиката му?
|
||||
Delete “{name}”? Изтриване на „{name}“?
|
||||
Depth: Дълбочина:
|
||||
Description Описание
|
||||
@@ -1009,6 +1011,7 @@ font-technology Variable Променливи
|
||||
Enable Contextual Task Bar Разрешаване на контекстуалната лента със задачи
|
||||
Enable copying of text, images and other content Разрешаване на копирането на текст, изображения и друго съдържание
|
||||
Enable in-menu font previews Разрешаване на прегледи на шрифтове в менюто
|
||||
Show samples on white Показване на мострите на бял фон
|
||||
Enable Missing Glyph Protection Разрешаване на защита срещу липсващи глифи
|
||||
Enable Opacity Mask Разрешаване на маска за непрозрачност
|
||||
Enable plaintext metadata Разрешаване на метаданни в обикновен текст
|
||||
@@ -1971,6 +1974,10 @@ font-technology Variable Променливи
|
||||
Long Дълго
|
||||
Stylistic Стилистично
|
||||
Mojikumi Set Набор Mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Набор Kinsoku
|
||||
Kinsoku Shori Type Тип Kinsoku Shori
|
||||
Push In First Първо прибиране
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2006,6 +2006,10 @@ digits Malayalam Malajalam
|
||||
Long Dugo
|
||||
Stylistic Stilsko
|
||||
Mojikumi Set Mojikumi skup
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Kinsoku skup
|
||||
Kinsoku Shori Type Vrsta Kinsoku Shori
|
||||
Push In First Prvo uvlačenje
|
||||
@@ -2141,6 +2145,8 @@ digits Malayalam Malajalam
|
||||
Weight: Debljina:
|
||||
Art using a deleted global swatch keeps its colour. Crtež koji koristi izbrisani globalni uzorak boje zadržava svoju boju.
|
||||
Color Group: {name} Grupa boja: {name}
|
||||
Delete the selected items and their artwork? Izbrisati odabrane stavke zajedno s njihovim crtežom?
|
||||
Delete this layer and its artwork? Izbrisati ovaj sloj zajedno s njegovim crtežom?
|
||||
Delete “{name}”? Izbrisati „{name}”?
|
||||
Document Swatches Uzorci boja dokumenta
|
||||
Edit or Apply Color Group Uredi ili primijeni grupu boja
|
||||
@@ -3729,6 +3735,7 @@ theme Appearance Izgled
|
||||
Enable Background Export Omogući izvoz u pozadini
|
||||
Enable Background Save Omogući čuvanje u pozadini
|
||||
Enable in-menu font previews Omogući preglede fontova u meniju
|
||||
Show samples on white Prikaži uzorke na bijeloj podlozi
|
||||
Enable Missing Glyph Protection Omogući zaštitu od nedostajućih glifova
|
||||
Enable Rubber Band for Omogući gumenu traku za
|
||||
Enable Touch Gestures Omogući pokrete dodirom
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1163,6 +1163,8 @@ antiAlias Sharp Nítid
|
||||
Delete Width Point Suprimeix el punt d’amplada
|
||||
Delete Workspace Suprimeix l’espai de treball
|
||||
Delete {kind} Style Suprimeix l’estil de {kind}
|
||||
Delete the selected items and their artwork? Voleu suprimir els elements seleccionats i el seu art?
|
||||
Delete this layer and its artwork? Voleu suprimir aquesta capa i el seu art?
|
||||
Delete “{name}”? Voleu suprimir “{name}”?
|
||||
Depth Profunditat
|
||||
Depth: Profunditat:
|
||||
@@ -1472,6 +1474,7 @@ antiAlias Sharp Nítid
|
||||
Enable copying of text, images and other content Activa la còpia de text, imatges i altre contingut
|
||||
Enable Guides for 9-Slice Scaling Activa les guies per a l’escalat de 9 talls
|
||||
Enable in-menu font previews Activa les previsualitzacions de tipus de lletra als menús
|
||||
Show samples on white Mostra les mostres sobre fons blanc
|
||||
Enable Missing Glyph Protection Activa la protecció de glifs absents
|
||||
Enable Opacity Mask Activa la màscara d’opacitat
|
||||
Enable plaintext metadata Activa les metadades en text pla
|
||||
@@ -2592,6 +2595,10 @@ digits Malayalam Malaiàlam
|
||||
Modify Modifica
|
||||
Mojikumi Ajust mojikumi
|
||||
Mojikumi Set Conjunt mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Monitor RGB RGB del monitor
|
||||
Mono Monocrom
|
||||
Monochromatic Monocromàtic
|
||||
@@ -5821,6 +5828,7 @@ digits Malayalam Malaiàlam
|
||||
@msg not a VectorCraft shortcut set (missing `overrides`) no és un conjunt de dreceres de VectorCraft (falta `overrides`)
|
||||
@msg not an EMF or WMF file no és un fitxer EMF o WMF
|
||||
@msg not an HP-GL file no és un fitxer HP-GL
|
||||
@msg not a PICT file no és un fitxer PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` ha de ser una matriu d’1 a 255 colors #rrggbb
|
||||
@msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf
|
||||
@msg not found no s’ha trobat
|
||||
@@ -6034,6 +6042,7 @@ digits Malayalam Malaiàlam
|
||||
@msg t, dx and dy must be finite t, dx i dy han de ser finits
|
||||
@msg Targa files are RGB: colorModel rgb Els fitxers Targa són RGB: colorModel rgb
|
||||
@msg Text Wrap: select a wrap object Ajustament de text: selecciona un objecte d’ajustament
|
||||
@msg the active document changed while the question was open; nothing was deleted el document actiu ha canviat mentre la pregunta era oberta; no s’ha suprimit res
|
||||
@msg that document is no longer open aquest document ja no està obert
|
||||
@msg Extrude & Bevel Extrudeix i bisella
|
||||
@msg Revolve Revoluciona
|
||||
@@ -7201,7 +7210,9 @@ breakText Glyphs Glifs
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» obre fitxers {_1}, no `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` és un complement del tipus `{_2}`, no un importador
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` és un complement del tipus `{_2}`, no un exportador
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` és un importador: obriu un fitxer amb document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` és un exportador: exporteu amb document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` és un efecte dinàmic: apliqueu-lo amb effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects l’importador no ha retornat cap objecte
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` un importador retorna `objects` i `warnings`, no `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el fitxer supera els {MAX_INPUT_BYTES} bytes que llegeix un importador
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -47,23 +47,42 @@ const MAX_MESSAGE_LEN: usize = 2000;
|
||||
|
||||
impl Template {
|
||||
/// `None` for a template that can't be matched reliably: no literal text, or two placeholders
|
||||
/// in a row (where one ends and the next begins is unknowable).
|
||||
/// in a row (where one ends and the next begins is unknowable). `{{` and `}}` are a literal `{`
|
||||
/// and `}`, as in a Rust format string, and so is a `{` with no `}` after it.
|
||||
fn parse(source: &str, forms: Vec<String>, counted: bool) -> Option<Template> {
|
||||
let mut pieces = Vec::new();
|
||||
let mut text = String::new();
|
||||
let mut rest = source;
|
||||
while let Some(a) = rest.find('{') {
|
||||
while let Some(a) = rest.find(['{', '}']) {
|
||||
text.push_str(rest.get(..a)?);
|
||||
let after = rest.get(a + 1..)?;
|
||||
let b = after.find('}')?;
|
||||
if a > 0 {
|
||||
pieces.push(Piece::Text(rest.get(..a)?.to_string()));
|
||||
let brace = if rest.get(a..)?.starts_with('{') { '{' } else { '}' };
|
||||
if let Some(escaped) = after.strip_prefix(brace) {
|
||||
text.push(brace);
|
||||
rest = escaped;
|
||||
continue;
|
||||
}
|
||||
if brace == '}' {
|
||||
text.push('}');
|
||||
rest = after;
|
||||
continue;
|
||||
}
|
||||
let Some(b) = after.find('}') else {
|
||||
text.push('{');
|
||||
rest = after;
|
||||
continue;
|
||||
};
|
||||
if !text.is_empty() {
|
||||
pieces.push(Piece::Text(std::mem::take(&mut text)));
|
||||
} else if matches!(pieces.last(), Some(Piece::Hole(_))) {
|
||||
return None;
|
||||
}
|
||||
pieces.push(Piece::Hole(after.get(..b)?.to_string()));
|
||||
rest = after.get(b + 1..)?;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
pieces.push(Piece::Text(rest.to_string()));
|
||||
text.push_str(rest);
|
||||
if !text.is_empty() {
|
||||
pieces.push(Piece::Text(text));
|
||||
}
|
||||
let literal = pieces.iter().map(|p| if let Piece::Text(t) = p { t.len() } else { 0 }).sum();
|
||||
(literal > 0).then_some(Template { pieces, literal, forms, counted })
|
||||
@@ -175,7 +194,7 @@ impl Catalog {
|
||||
c.plurals.insert(src, tr.split('|').map(str::to_string).collect());
|
||||
}
|
||||
"@msg" => {
|
||||
if src.contains('{') {
|
||||
if src.contains('{') || src.contains("}}") {
|
||||
c.templates.extend(Template::parse(&src, vec![tr], false));
|
||||
} else {
|
||||
c.messages.insert(src, tr);
|
||||
@@ -237,13 +256,17 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// `{name}` placeholders of a template, in order of appearance.
|
||||
/// `{name}` placeholders of a template, in order of appearance; `{{` is a literal brace.
|
||||
#[cfg(test)]
|
||||
pub fn placeholders(s: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = s;
|
||||
while let Some(a) = rest.find('{') {
|
||||
let after = &rest[a + 1..];
|
||||
if let Some(escaped) = after.strip_prefix('{') {
|
||||
rest = escaped;
|
||||
continue;
|
||||
}
|
||||
match after.find('}') {
|
||||
Some(b) => {
|
||||
out.push(&after[..b]);
|
||||
|
||||
@@ -2037,6 +2037,10 @@ digits Malayalam Malajálamské
|
||||
Long Dlouhý
|
||||
Stylistic Stylistický
|
||||
Mojikumi Set Sada Mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Sada Kinsoku
|
||||
Kinsoku Shori Type Typ Kinsoku Shori
|
||||
Push In First Nejprve vtáhnout
|
||||
@@ -2172,6 +2176,8 @@ digits Malayalam Malajálamské
|
||||
Weight: Tloušťka:
|
||||
Art using a deleted global swatch keeps its colour. Kresba, která používá odstraněnou globální položku vzorníku, si barvu ponechá.
|
||||
Color Group: {name} Skupina barev: {name}
|
||||
Delete the selected items and their artwork? Odstranit vybrané položky i s kresbou?
|
||||
Delete this layer and its artwork? Odstranit tuto vrstvu i s kresbou?
|
||||
Delete “{name}”? Odstranit „{name}“?
|
||||
Document Swatches Vzorník dokumentu
|
||||
Edit or Apply Color Group Upravit nebo použít skupinu barev
|
||||
@@ -3783,6 +3789,7 @@ theme Appearance Motiv
|
||||
Enable Background Export Povolit export na pozadí
|
||||
Enable Background Save Povolit ukládání na pozadí
|
||||
Enable in-menu font previews Povolit náhledy písem v nabídce
|
||||
Show samples on white Zobrazit ukázky na bílém pozadí
|
||||
Enable Missing Glyph Protection Povolit ochranu chybějících glyfů
|
||||
Enable Rubber Band for Povolit gumovou čáru pro
|
||||
Enable Touch Gestures Povolit dotyková gesta
|
||||
@@ -5014,6 +5021,7 @@ imageTrace Outline Obrys
|
||||
@msg not a valid plug-in module: {0} neplatný modul zásuvného modulu: {0}
|
||||
@msg not an EMF or WMF file nejde o soubor EMF ani WMF
|
||||
@msg not an HP-GL file nejde o soubor HP-GL
|
||||
@msg not a PICT file nejde o soubor PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musí být pole 1 až 255 barev #rrggbb
|
||||
@msg not found nenalezeno
|
||||
@msg not in pattern editing mode není aktivní režim úprav vzorku
|
||||
@@ -5396,6 +5404,7 @@ imageTrace Outline Obrys
|
||||
@msg choose the font files to add vyberte soubory písem, které chcete přidat
|
||||
@msg not a file nejde o soubor
|
||||
@msg not a font file nejde o soubor písma
|
||||
@msg the active document changed while the question was open; nothing was deleted aktivní dokument se změnil, zatímco byla otázka otevřená; nic nebylo odstraněno
|
||||
@msg that document is no longer open tento dokument už není otevřen
|
||||
@msg larger than 256 MB větší než 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time soubory mají dohromady více než 1 GB: přidávejte jich méně najednou
|
||||
@@ -7220,7 +7229,9 @@ breakText Glyphs Glyfy
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ otevírá soubory {_1}, ne `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` je zásuvný modul druhu `{_2}`, ne importní zásuvný modul
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` je zásuvný modul druhu `{_2}`, ne exportní zásuvný modul
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` je importní zásuvný modul: otevřete soubor pomocí document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` je importní zásuvný modul: otevřete soubor pomocí document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` je exportní zásuvný modul: exportujte pomocí document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` je živý efekt: použijte ho pomocí effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects importní zásuvný modul nevrátil žádné objekty
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` importní zásuvný modul vrací `objects` a `warnings`, ne `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads soubor je větší než {MAX_INPUT_BYTES} bajtů, které importní zásuvný modul čte
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1839,6 +1839,10 @@ digits Malayalam Malayalam
|
||||
Long Lang
|
||||
Stylistic Stilistisk
|
||||
Mojikumi Set Mojikumi-sæt
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Kinsoku-sæt
|
||||
Kinsoku Shori Type Kinsoku Shori-type
|
||||
Push In First Skub ind først
|
||||
@@ -1974,6 +1978,8 @@ digits Malayalam Malayalam
|
||||
Weight: Vægt:
|
||||
Art using a deleted global swatch keeps its colour. Grafik, der bruger en slettet global farveprøve, bevarer sin farve.
|
||||
Color Group: {name} Farvegruppe: {name}
|
||||
Delete the selected items and their artwork? Slet de valgte elementer og deres grafik?
|
||||
Delete this layer and its artwork? Slet dette lag og dets grafik?
|
||||
Delete “{name}”? Slet »{name}«?
|
||||
Document Swatches Dokumentfarveprøver
|
||||
Edit or Apply Color Group Redigér eller anvend farvegruppe
|
||||
@@ -3516,6 +3522,7 @@ theme Appearance Udseende
|
||||
Enable Background Export Aktivér eksport i baggrunden
|
||||
Enable Background Save Aktivér lagring i baggrunden
|
||||
Enable in-menu font previews Aktivér skrifttypeforhåndsvisning i menuer
|
||||
Show samples on white Vis eksempler på hvidt
|
||||
Enable Missing Glyph Protection Aktivér beskyttelse mod manglende glyffer
|
||||
Enable Rubber Band for Aktivér gummibånd for
|
||||
Enable Touch Gestures Aktivér berøringsbevægelser
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -791,6 +791,8 @@ font-technology Variable Variabel
|
||||
Delete Width Point Breitenpunkt löschen
|
||||
Delete Workspace Arbeitsbereich löschen
|
||||
Delete {kind} Style {kind}format löschen
|
||||
Delete the selected items and their artwork? Die ausgewählten Elemente und ihr Bildmaterial löschen?
|
||||
Delete this layer and its artwork? Diese Ebene und ihr Bildmaterial löschen?
|
||||
Delete “{name}”? „{name}“ löschen?
|
||||
Depth: Tiefe:
|
||||
Description Beschreibung
|
||||
@@ -1011,6 +1013,7 @@ font-technology Variable Variabel
|
||||
Enable Contextual Task Bar Kontextbezogene Taskleiste aktivieren
|
||||
Enable copying of text, images and other content Kopieren von Text, Bildern und anderen Inhalten zulassen
|
||||
Enable in-menu font previews Schriftvorschau im Menü aktivieren
|
||||
Show samples on white Muster auf Weiß anzeigen
|
||||
Enable Missing Glyph Protection Schutz vor fehlenden Glyphen aktivieren
|
||||
Enable Opacity Mask Deckkraftmaske aktivieren
|
||||
Enable plaintext metadata Klartext-Metadaten zulassen
|
||||
@@ -2047,6 +2050,10 @@ file-filter Templates Vorlagen
|
||||
Long Lang
|
||||
Stylistic Stilistisch
|
||||
Mojikumi Set Mojikumi-Satz
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Kinsoku-Satz
|
||||
Kinsoku Shori Type Kinsoku-Shori-Typ
|
||||
Push In First Zuerst einziehen
|
||||
@@ -4631,6 +4638,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg not a valid plug-in module: {0} kein gültiges Plug-in-Modul: {0}
|
||||
@msg not an EMF or WMF file keine EMF- oder WMF-Datei
|
||||
@msg not an HP-GL file keine HP-GL-Datei
|
||||
@msg not a PICT file keine PICT-Datei
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` muss ein Array von 1 bis 255 Farben der Form #rrggbb sein
|
||||
@msg not found nicht gefunden
|
||||
@msg not in pattern editing mode nicht im Musterbearbeitungsmodus
|
||||
@@ -5013,6 +5021,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg choose the font files to add wählen Sie die Schriftdateien aus, die hinzugefügt werden sollen
|
||||
@msg not a file keine Datei
|
||||
@msg not a font file keine Schriftdatei
|
||||
@msg the active document changed while the question was open; nothing was deleted das aktive Dokument hat sich geändert, während die Frage offen war; es wurde nichts gelöscht
|
||||
@msg that document is no longer open dieses Dokument ist nicht mehr geöffnet
|
||||
@msg larger than 256 MB größer als 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time die Dateien sind zusammen größer als 1 GB: Fügen Sie weniger auf einmal hinzu
|
||||
@@ -6815,7 +6824,9 @@ breakText Glyphs Glyphen
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ öffnet {_1}-Dateien, nicht `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` ist ein Plug-in der Art `{_2}`, kein Import-Plug-in
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` ist ein Plug-in der Art `{_2}`, kein Export-Plug-in
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` ist ein Import-Plug-in: Öffnen Sie eine Datei mit document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` ist ein Import-Plug-in: Öffnen Sie eine Datei mit document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` ist ein Export-Plug-in: Exportieren Sie mit document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` ist ein Live-Effekt: Wenden Sie ihn mit effect.apply {{"effect": "{_2}"}} an
|
||||
@msg the importer returned no objects das Import-Plug-in hat keine Objekte zurückgegeben
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` ein Import-Plug-in gibt `objects` und `warnings` zurück, nicht `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads die Datei ist größer als die {MAX_INPUT_BYTES} Byte, die ein Import-Plug-in liest
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -771,6 +771,8 @@ font-technology Variable Μεταβλητές
|
||||
Delete Width Point Διαγραφή σημείου πλάτους
|
||||
Delete Workspace Διαγραφή χώρου εργασίας
|
||||
Delete {kind} Style Διαγραφή στυλ {kind}
|
||||
Delete the selected items and their artwork? Να διαγραφούν τα επιλεγμένα στοιχεία και τα γραφικά τους;
|
||||
Delete this layer and its artwork? Να διαγραφεί αυτό το επίπεδο και τα γραφικά του;
|
||||
Delete “{name}”? Να διαγραφεί το «{name}»;
|
||||
Depth: Βάθος:
|
||||
Description Περιγραφή
|
||||
@@ -979,6 +981,7 @@ font-technology Variable Μεταβλητές
|
||||
Enable Contextual Task Bar Ενεργοποίηση γραμμής εργασιών βάσει περιβάλλοντος
|
||||
Enable copying of text, images and other content Να επιτρέπεται η αντιγραφή κειμένου, εικόνων και άλλου περιεχομένου
|
||||
Enable in-menu font previews Ενεργοποίηση προεπισκόπησης γραμματοσειρών στα μενού
|
||||
Show samples on white Εμφάνιση δειγμάτων σε λευκό
|
||||
Enable Missing Glyph Protection Ενεργοποίηση προστασίας από ελλείποντες γλύφους
|
||||
Enable Opacity Mask Ενεργοποίηση μάσκας αδιαφάνειας
|
||||
Enable plaintext metadata Ενεργοποίηση μεταδεδομένων απλού κειμένου
|
||||
@@ -2969,6 +2972,10 @@ theme Appearance Εμφάνιση
|
||||
Long Μακρύ
|
||||
Stylistic Στιλιστικό
|
||||
Mojikumi Set Σύνολο Mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Σύνολο Kinsoku
|
||||
Kinsoku Shori Type Τύπος Kinsoku Shori
|
||||
Push In First Πρώτα προς τα μέσα
|
||||
@@ -4659,6 +4666,7 @@ digits Malayalam Μαλαγιάλαμ
|
||||
@msg not a valid plug-in module: {0} δεν είναι έγκυρη μονάδα προσθέτου: {0}
|
||||
@msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF
|
||||
@msg not an HP-GL file δεν είναι αρχείο HP-GL
|
||||
@msg not a PICT file δεν είναι αρχείο PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors το `pens` πρέπει να είναι πίνακας 1 έως 255 χρωμάτων #rrggbb
|
||||
@msg not found δεν βρέθηκε
|
||||
@msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου
|
||||
@@ -5035,6 +5043,7 @@ digits Malayalam Μαλαγιάλαμ
|
||||
@msg choose the font files to add επιλέξτε τα αρχεία γραμματοσειρών που θέλετε να προσθέσετε
|
||||
@msg not a file δεν είναι αρχείο
|
||||
@msg not a font file δεν είναι αρχείο γραμματοσειράς
|
||||
@msg the active document changed while the question was open; nothing was deleted το ενεργό έγγραφο άλλαξε ενώ η ερώτηση ήταν ανοιχτή· δεν διαγράφηκε τίποτα
|
||||
@msg that document is no longer open αυτό το έγγραφο δεν είναι πλέον ανοιχτό
|
||||
@msg larger than 256 MB μεγαλύτερο από 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time τα αρχεία ξεπερνούν το 1 GB: προσθέστε λιγότερα κάθε φορά
|
||||
@@ -7172,7 +7181,9 @@ breakText Glyphs Γλύφοι
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` το «{shown}» ανοίγει αρχεία {_1}, όχι `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer το `{_1}` είναι πρόσθετο τύπου `{_2}`, όχι πρόσθετο εισαγωγής
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter το `{_1}` είναι πρόσθετο τύπου `{_2}`, όχι πρόσθετο εξαγωγής
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} το `{_1}` είναι πρόσθετο εισαγωγής: ανοίξτε ένα αρχείο με document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} το `{_1}` είναι πρόσθετο εισαγωγής: ανοίξτε ένα αρχείο με document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} το `{_1}` είναι πρόσθετο εξαγωγής: κάντε εξαγωγή με document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} το `{_1}` είναι δυναμικό εφέ: εφαρμόστε το με effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects το πρόσθετο εισαγωγής δεν επέστρεψε κανένα αντικείμενο
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` ένα πρόσθετο εισαγωγής επιστρέφει `objects` και `warnings`, όχι `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads το αρχείο είναι μεγαλύτερο από τα {MAX_INPUT_BYTES} byte που διαβάζει ένα πρόσθετο εισαγωγής
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -812,6 +812,8 @@ font-technology Variable Variables
|
||||
Delete Width Point Eliminar punto de anchura
|
||||
Delete Workspace Eliminar espacio de trabajo
|
||||
Delete {kind} Style Eliminar estilo de {kind}
|
||||
Delete the selected items and their artwork? ¿Eliminar los elementos seleccionados y su ilustración?
|
||||
Delete this layer and its artwork? ¿Eliminar esta capa y su ilustración?
|
||||
Delete “{name}”? ¿Eliminar “{name}”?
|
||||
Depth: Profundidad:
|
||||
Description Descripción
|
||||
@@ -1032,6 +1034,7 @@ font-technology Variable Variables
|
||||
Enable Contextual Task Bar Activar barra de tareas contextual
|
||||
Enable copying of text, images and other content Permitir copiar texto, imágenes y otro contenido
|
||||
Enable in-menu font previews Activar vista previa de fuentes en el menú
|
||||
Show samples on white Mostrar muestras sobre blanco
|
||||
Enable Missing Glyph Protection Activar protección de glifos ausentes
|
||||
Enable Opacity Mask Activar máscara de opacidad
|
||||
Enable plaintext metadata Activar metadatos en texto sin formato
|
||||
@@ -2041,6 +2044,10 @@ font-technology Variable Variables
|
||||
Long Larga
|
||||
Stylistic Estilística
|
||||
Mojikumi Set Conjunto de mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Conjunto de kinsoku
|
||||
Kinsoku Shori Type Tipo de kinsoku shori
|
||||
Push In First Primero hacia dentro
|
||||
@@ -4706,6 +4713,7 @@ axis Both Ambos
|
||||
@msg not a valid plug-in module: {0} no es un módulo de plugin válido: {0}
|
||||
@msg not an EMF or WMF file no es un archivo EMF ni WMF
|
||||
@msg not an HP-GL file no es un archivo HP-GL
|
||||
@msg not a PICT file no es un archivo PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` debe ser una lista de 1 a 255 colores #rrggbb
|
||||
@msg not found no se encontró
|
||||
@msg not in pattern editing mode no está en el modo de edición de motivos
|
||||
@@ -5088,6 +5096,7 @@ axis Both Ambos
|
||||
@msg choose the font files to add elige los archivos de fuentes que quieres agregar
|
||||
@msg not a file no es un archivo
|
||||
@msg not a font file no es un archivo de fuente
|
||||
@msg the active document changed while the question was open; nothing was deleted el documento activo cambió mientras la pregunta estaba abierta; no se eliminó nada
|
||||
@msg that document is no longer open ese documento ya no está abierto
|
||||
@msg larger than 256 MB mayor de 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time los archivos suman más de 1 GB: agrega menos a la vez
|
||||
@@ -7163,7 +7172,9 @@ breakText Glyphs Glifos
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» abre archivos {_1}, no `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` es un plugin de tipo `{_2}`, no un plugin de importación
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` es un plugin de tipo `{_2}`, no un plugin de exportación
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` es un plugin de importación: abra un archivo con document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` es un plugin de importación: abra un archivo con document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` es un plugin de exportación: exporte con document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` es un efecto interactivo: aplíquelo con effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects el plugin de importación no devolvió ningún objeto
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` un plugin de importación devuelve `objects` y `warnings`, no `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads el archivo supera los {MAX_INPUT_BYTES} bytes que lee un plugin de importación
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1998,6 +1998,10 @@ digits Malayalam Malajalam
|
||||
Long Pitkä
|
||||
Stylistic Tyylitelty
|
||||
Mojikumi Set Mojikumi-joukko
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Kinsoku-joukko
|
||||
Kinsoku Shori Type Kinsoku Shori -tyyppi
|
||||
Push In First Ensin sisään
|
||||
@@ -2133,6 +2137,8 @@ digits Malayalam Malajalam
|
||||
Weight: Paksuus:
|
||||
Art using a deleted global swatch keeps its colour. Poistettua globaalia värimallia käyttävä kuvitus säilyttää värinsä.
|
||||
Color Group: {name} Väriryhmä: {name}
|
||||
Delete the selected items and their artwork? Poistetaanko valitut kohteet ja niiden kuvitus?
|
||||
Delete this layer and its artwork? Poistetaanko tämä taso ja sen kuvitus?
|
||||
Delete “{name}”? Poistetaanko ”{name}”?
|
||||
Document Swatches Asiakirjan värimallit
|
||||
Edit or Apply Color Group Muokkaa tai käytä väriryhmää
|
||||
@@ -3721,6 +3727,7 @@ theme Appearance Ulkoasu
|
||||
Enable Background Export Ota taustavienti käyttöön
|
||||
Enable Background Save Ota taustatallennus käyttöön
|
||||
Enable in-menu font previews Ota fonttien esikatselu valikoissa käyttöön
|
||||
Show samples on white Näytä mallit valkoisella pohjalla
|
||||
Enable Missing Glyph Protection Ota puuttuvien merkkien suojaus käyttöön
|
||||
Enable Rubber Band for Ota kumilankavalinta käyttöön kohteelle
|
||||
Enable Touch Gestures Ota kosketuseleet käyttöön
|
||||
@@ -4920,6 +4927,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg not a valid plug-in module: {0} ei kelvollinen laajennusmoduuli: {0}
|
||||
@msg not an EMF or WMF file ei EMF- tai WMF-tiedosto
|
||||
@msg not an HP-GL file ei HP-GL-tiedosto
|
||||
@msg not a PICT file ei PICT-tiedosto
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` on oltava 1–255 #rrggbb-värin taulukko
|
||||
@msg not found ei löytynyt
|
||||
@msg not in pattern editing mode ei kuvion muokkaustilassa
|
||||
@@ -5296,6 +5304,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg choose the font files to add valitse lisättävät fonttitiedostot
|
||||
@msg not a file ei tiedosto
|
||||
@msg not a font file ei fonttitiedosto
|
||||
@msg the active document changed while the question was open; nothing was deleted aktiivinen asiakirja vaihtui kysymyksen ollessa auki; mitään ei poistettu
|
||||
@msg that document is no longer open kyseinen asiakirja ei ole enää auki
|
||||
@msg larger than 256 MB suurempi kuin 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time tiedostojen koko on yli 1 GB: lisää vähemmän kerrallaan
|
||||
@@ -7255,7 +7264,9 @@ breakText Glyphs Merkit
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` ”{shown}” avaa {_1}-tiedostoja, ei `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` on lajin `{_2}` laajennus, ei tuontilaajennus
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` on lajin `{_2}` laajennus, ei vientilaajennus
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` on tuontilaajennus: avaa tiedosto komennolla document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` on tuontilaajennus: avaa tiedosto komennolla document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` on vientilaajennus: vie komennolla document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` on live-tehoste: käytä sitä komennolla effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects tuontilaajennus ei palauttanut objekteja
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` tuontilaajennus palauttaa `objects` ja `warnings`, ei `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads tiedosto on suurempi kuin tuontilaajennuksen lukemat {MAX_INPUT_BYTES} tavua
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -815,6 +815,8 @@ font-technology Variable Variables
|
||||
Delete Width Point Supprimer le point de largeur
|
||||
Delete Workspace Supprimer l’espace de travail
|
||||
Delete {kind} Style Supprimer le style de {kind}
|
||||
Delete the selected items and their artwork? Supprimer les éléments sélectionnés et leur illustration ?
|
||||
Delete this layer and its artwork? Supprimer ce calque et son illustration ?
|
||||
Delete “{name}”? Supprimer « {name} » ?
|
||||
Depth: Profondeur :
|
||||
Description Description
|
||||
@@ -1027,6 +1029,7 @@ font-technology Variable Variables
|
||||
Enable Contextual Task Bar Activer la barre des tâches contextuelle
|
||||
Enable copying of text, images and other content Autoriser la copie de texte, d’images et d’autres contenus
|
||||
Enable in-menu font previews Activer l’aperçu des polices dans les menus
|
||||
Show samples on white Afficher les échantillons sur fond blanc
|
||||
Enable Missing Glyph Protection Activer la protection des glyphes manquants
|
||||
Enable Opacity Mask Activer le masque d’opacité
|
||||
Enable plaintext metadata Activer les métadonnées en texte brut
|
||||
@@ -2035,6 +2038,10 @@ font-technology Variable Variables
|
||||
Long Longue
|
||||
Stylistic Stylistique
|
||||
Mojikumi Set Jeu de mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Jeu de kinsoku
|
||||
Kinsoku Shori Type Type de kinsoku shori
|
||||
Push In First D’abord vers l’intérieur
|
||||
@@ -4695,6 +4702,7 @@ axis Both Les deux
|
||||
@msg not a valid plug-in module: {0} ce n’est pas un module de plug-in valide : {0}
|
||||
@msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF
|
||||
@msg not an HP-GL file ce n’est pas un fichier HP-GL
|
||||
@msg not a PICT file ce n’est pas un fichier PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` doit être une liste de 1 à 255 couleurs #rrggbb
|
||||
@msg not found introuvable
|
||||
@msg not in pattern editing mode pas en mode d’édition de motif
|
||||
@@ -5075,6 +5083,7 @@ axis Both Les deux
|
||||
@msg choose the font files to add choisissez les fichiers de polices à ajouter
|
||||
@msg not a file n’est pas un fichier
|
||||
@msg not a font file n’est pas un fichier de police
|
||||
@msg the active document changed while the question was open; nothing was deleted le document actif a changé pendant que la question était ouverte ; rien n’a été supprimé
|
||||
@msg that document is no longer open ce document n’est plus ouvert
|
||||
@msg larger than 256 MB plus de 256 Mo
|
||||
@msg the files come to more than 1 GB: add fewer at a time les fichiers dépassent 1 Go : ajoutez-en moins à la fois
|
||||
@@ -7215,7 +7224,9 @@ breakText Glyphs Glyphes
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` « {shown} » ouvre les fichiers {_1}, pas `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` est un module externe de type `{_2}`, pas un module externe d’importation
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` est un module externe de type `{_2}`, pas un module externe d’exportation
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` est un module externe d’importation : ouvrez un fichier avec document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` est un module externe d’importation : ouvrez un fichier avec document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` est un module externe d’exportation : exportez avec document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` est un effet dynamique : appliquez-le avec effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects le module externe d’importation n’a renvoyé aucun objet
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` un module externe d’importation renvoie `objects` et `warnings`, pas `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads le fichier dépasse les {MAX_INPUT_BYTES} octets qu’un module externe d’importation lit
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1222,6 +1222,8 @@ antiAlias Sharp शार्प
|
||||
Define Perspective Grid… पर्सपेक्टिव ग्रिड डिफ़ाइन करें…
|
||||
Definition परिभाषा
|
||||
Delete हटाएँ
|
||||
Delete the selected items and their artwork? चुने गए आइटम और उनका आर्टवर्क हटाएँ?
|
||||
Delete this layer and its artwork? यह लेयर और इसका आर्टवर्क हटाएँ?
|
||||
Delete “{name}”? "{name}" को हटाएं?
|
||||
Delete {kind} Style Delete {kind} Style
|
||||
Delete Action Sets एक्शन सेट हटाएं
|
||||
@@ -1623,6 +1625,7 @@ antiAlias Sharp शार्प
|
||||
Enable copying of text, images and other content टेक्स्ट, इमेज और अन्य सामग्री की प्रतिलिपि बनाने में सक्षम करें
|
||||
Enable Guides for 9-Slice Scaling 9 स्लाइस स्केलिंग के लिए गाइड सक्षम करें
|
||||
Enable in-menu font previews इन-मेनू फ़ॉन्ट प्रीव्यू सक्षम करें
|
||||
Show samples on white नमूने सफ़ेद पृष्ठभूमि पर दिखाएँ
|
||||
Enable Missing Glyph Protection मिसिंग ग्लाइफ संरक्षण सक्षम करें
|
||||
Enable Opacity Mask ओपेसिटी मास्क सक्षम करें
|
||||
Enable plaintext metadata सादाटेक्स्ट मेटाडाटा सक्षम करें
|
||||
@@ -2830,6 +2833,10 @@ antiAlias Sharp शार्प
|
||||
Modify संशोधित करें
|
||||
Mojikumi मोजियाकुमी
|
||||
Mojikumi Set मोजिकुमी सेट
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Monitor RGB मॉनिटर RGB
|
||||
Mono मोनो
|
||||
Monochromatic मोनोक्रोमैटिक
|
||||
@@ -5761,8 +5768,8 @@ antiAlias Sharp शार्प
|
||||
@msg `{_1}` has no brushes to apply `{_1}` को लागू करने के लिए कोई ब्रश नहीं है
|
||||
@msg `{_1}` has no symbols to place `{_1}` के पास कोई सिंबल नहीं है
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` `{_2}` प्रकार का प्लग-इन है, निर्यातक नहीं
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect"}"}} `{_1}` एक लाइव इफ़ेक्ट है: इसे effect.apply {{"effect"}"}} के साथ लागू करें
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format"}"}} `{_1}` एक एक्सपोर्टर है: document.export {{"format"}"}} से एक्सपोर्ट करें
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` एक लाइव इफ़ेक्ट है: इसे effect.apply {{"effect": "{_2}"}} के साथ लागू करें
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` एक एक्सपोर्टर है: document.export {{"format": "{_2}"}} से एक्सपोर्ट करें
|
||||
@msg `{_1}` is built in and always listed `{_1}` बनाया गया है और हमेशा सूचीबद्ध है
|
||||
@msg `{_1}` is no save command `{_1}` कोई बचत आदेश नहीं है
|
||||
@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` पिछली text.findFontFiles सर्च में मिली फ़ॉन्ट फ़ाइल नहीं है
|
||||
@@ -6024,7 +6031,7 @@ antiAlias Sharp शार्प
|
||||
@msg `mode` must be "straight", "bezier" or "arc" `mode` को "straight", "bezier" या "arc" होना चाहिए
|
||||
@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe `mode` को grayscale, rgb, hsb, cmyk या websafe होना चाहिए
|
||||
@msg `mode` must be one of {_1} `mode` को {_1} में से एक होना चाहिए
|
||||
@msg `mojikumi` must be "none" or "lineEndHalf" `mojikumi` होना चाहिए "none" or "lineEndHalf"
|
||||
@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" या "lineEndHalf" होना चाहिए।
|
||||
@msg `name` is empty `name` खाली है
|
||||
@msg `name` must be 1-64 printable characters `name` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए
|
||||
@msg `names` is a list of preset names `names` प्रीसेट नामों की सूची है
|
||||
@@ -7378,12 +7385,12 @@ antiAlias Sharp शार्प
|
||||
@msg not an action set file एक एक्शन सेट फ़ाइल नहीं
|
||||
@msg not an EMF or WMF file नहीं एक EMF या WMF फ़ाइल
|
||||
@msg not an HP-GL file HP-GL फ़ाइल नहीं है
|
||||
@msg not a PICT file PICT फ़ाइल नहीं है
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` को 1 से 255 #rrggbb रंगों की ऐरे होना चाहिए
|
||||
@msg not an export presets file (format `{_1}`) नहीं निर्यात presets फ़ाइल (format `{_1}`)
|
||||
@msg not an export presets file: {e} एक निर्यात प्रीसेट फ़ाइल नहीं: {e}
|
||||
@msg not an image VectorCraft reads (see document.formats) यह ऐसी इमेज नहीं है जिसे VectorCraft पढ़ सके (document.formats देखें)
|
||||
@msg not an RTF file: it doesn't start with {\\rtf RTF फ़ाइल नहीं: यह {\rtf के साथ शुरू नहीं होता है
|
||||
@msg not an RTF file: it doesn't start with not an RTF file: it doesn't start with {\rtf RTF फ़ाइल: यह एक RTF फ़ाइल के साथ शुरू नहीं होता है: यह {\rtf के साथ शुरू नहीं होता है
|
||||
@msg not area type (text in a frame) outside perspective नहीं क्षेत्र के प्रकार (एक फ्रेम में टेक्स्ट) बाहर पर्सपेक्टिव
|
||||
@msg not editing an opacity mask एक opacity मास्क संपादित न करें
|
||||
@msg not found नहीं मिला
|
||||
@@ -7828,6 +7835,7 @@ antiAlias Sharp शार्प
|
||||
@msg textureImage: {e} textureImage: {e}
|
||||
@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: किसी इमेज फ़ाइल का {path} या {name, dataBase64} दें, या effect.list द्वारा दिया गया टेक्सचर दें
|
||||
@msg that anchor can't slide: an end of an open path has nowhere to go कि एंकर स्लाइड नहीं कर सकते: एक खुले रास्ते का अंत कहीं नहीं जाना है
|
||||
@msg the active document changed while the question was open; nothing was deleted प्रश्न खुला रहते हुए सक्रिय डॉक्यूमेंट बदल गया; कुछ भी नहीं हटाया गया
|
||||
@msg that document is no longer open यह डॉक्यूमेंट अब खुला नहीं है
|
||||
@msg that makes more than {MAX_PIECES} slices जो {MAX_PIECES} स्लाइस से अधिक बनाता है
|
||||
@msg that makes too many pieces (at most 100000): make the dashes longer जो बहुत सारे टुकड़े बनाता है (अधिकांश 100000 में): डैश को लंबे समय तक बनाते हैं
|
||||
@@ -8646,7 +8654,7 @@ theme Appearance अपीयरेंस
|
||||
@msg `importer` must be a plug-in format id (see document.formats) `importer` किसी प्लग-इन का फ़ॉर्मेट id होना चाहिए (document.formats देखें)
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” {_1} फ़ाइलें खोलता है, `{_2}` नहीं
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` `{_2}` प्रकार का प्लग-इन है, इंपोर्टर नहीं
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` एक इंपोर्टर है: document.open {{"importer"}"}} से फ़ाइल खोलें
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` एक इंपोर्टर है: document.open {{"importer": "{_2}"}} से फ़ाइल खोलें
|
||||
@msg the importer returned no objects इंपोर्टर ने कोई ऑब्जेक्ट नहीं लौटाया
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` इंपोर्टर `objects` और `warnings` लौटाता है, `{key}` नहीं
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads फ़ाइल उन {MAX_INPUT_BYTES} बाइट्स से बड़ी है जो इंपोर्टर पढ़ता है
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1042,6 +1042,8 @@ font-technology Variable Változó
|
||||
Delete Width Point Szélességi pont törlése
|
||||
Delete Workspace Munkaterület törlése
|
||||
Delete {kind} Style Stílus törlése: {kind}
|
||||
Delete the selected items and their artwork? Törlöd a kijelölt elemeket a grafikájukkal együtt?
|
||||
Delete this layer and its artwork? Törlöd ezt a réteget a grafikájával együtt?
|
||||
Delete “{name}”? Törlöd ezt: „{name}”?
|
||||
Depth: Színmélység:
|
||||
Descender Lenyúló szár
|
||||
@@ -1308,6 +1310,7 @@ font-technology Variable Változó
|
||||
Enable Contextual Task Bar Helyzetérzékeny műveletsáv bekapcsolása
|
||||
Enable copying of text, images and other content Szöveg, képek és más tartalom másolásának engedélyezése
|
||||
Enable in-menu font previews Betűtípus-előnézet a menüben
|
||||
Show samples on white Minták fehér alapon
|
||||
Enable Missing Glyph Protection Hiányzó glifák elleni védelem
|
||||
Enable Opacity Mask Átlátszósági maszk bekapcsolása
|
||||
Enable plaintext metadata Titkosítatlan metaadatok engedélyezése
|
||||
@@ -2567,6 +2570,10 @@ file-filter Templates Sablonok
|
||||
Long Hosszú
|
||||
Stylistic Stilisztikai
|
||||
Mojikumi Set Mojikumi-készlet
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Monitor RGB Monitor-RGB
|
||||
Mono Egyszínű
|
||||
Monochromatic Egyszínű
|
||||
@@ -5391,6 +5398,7 @@ imageTrace Outline Körvonal
|
||||
@msg not a blend nem összemosás
|
||||
@msg not an EMF or WMF file nem EMF- vagy WMF-fájl
|
||||
@msg not an HP-GL file nem HP-GL-fájl
|
||||
@msg not a PICT file nem PICT-fájl
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`: 1–255 #rrggbb szín tömbje kell
|
||||
@msg not found nem található
|
||||
@msg not in pattern editing mode nem mintázatszerkesztő módban vagy
|
||||
@@ -5598,6 +5606,7 @@ imageTrace Outline Körvonal
|
||||
@msg SVG parse error: {0} SVG-elemzési hiba: {0}
|
||||
@msg Targa files are RGB: colorModel rgb a Targa-fájlok RGB-k: colorModel rgb
|
||||
@msg Text Wrap: select a wrap object Szöveg körbefuttatása: jelölj ki egy körbefuttató objektumot
|
||||
@msg the active document changed while the question was open; nothing was deleted az aktív dokumentum megváltozott, amíg a kérdés nyitva volt; semmi sem lett törölve
|
||||
@msg that document is no longer open az a dokumentum már nincs megnyitva
|
||||
@msg the art could not be sampled a grafikából nem sikerült mintát venni
|
||||
@msg the art has no bounds a grafikának nincsenek határai
|
||||
@@ -7204,7 +7213,9 @@ breakText Glyphs Glifák
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` „{shown}” {_1} fájlokat nyit meg, nem `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}`: `{_2}` fajtájú bővítmény, nem importáló bővítmény
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}`: `{_2}` fajtájú bővítmény, nem exportáló bővítmény
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` importáló bővítmény: nyisson meg egy fájlt a document.open {{"importer"}"}} paranccsal
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` importáló bővítmény: nyisson meg egy fájlt a document.open {{"importer": "{_2}"}} paranccsal
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` exportáló bővítmény: exportáljon a document.export {{"format": "{_2}"}} paranccsal
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` élő effektus: alkalmazza az effect.apply {{"effect": "{_2}"}} paranccsal
|
||||
@msg the importer returned no objects az importáló bővítmény nem adott vissza objektumot
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` az importáló bővítmény `objects` és `warnings` értéket ad vissza, nem `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads a fájl nagyobb annál a {MAX_INPUT_BYTES} bájtnál, amit egy importáló bővítmény beolvas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1839,6 +1839,10 @@ digits Malayalam Malayalam
|
||||
Long Panjang
|
||||
Stylistic Stilistik
|
||||
Mojikumi Set Set Mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Set Kinsoku
|
||||
Kinsoku Shori Type Jenis Kinsoku Shori
|
||||
Push In First Dorong masuk dahulu
|
||||
@@ -1974,6 +1978,8 @@ digits Malayalam Malayalam
|
||||
Weight: Ketebalan:
|
||||
Art using a deleted global swatch keeps its colour. Karya seni yang memakai swatch global terhapus tetap mempertahankan warnanya.
|
||||
Color Group: {name} Grup warna: {name}
|
||||
Delete the selected items and their artwork? Hapus item yang dipilih beserta karya seninya?
|
||||
Delete this layer and its artwork? Hapus lapisan ini beserta karya seninya?
|
||||
Delete “{name}”? Hapus “{name}”?
|
||||
Document Swatches Swatch dokumen
|
||||
Edit or Apply Color Group Edit atau terapkan grup warna
|
||||
@@ -3516,6 +3522,7 @@ theme Appearance Tampilan
|
||||
Enable Background Export Aktifkan ekspor di latar belakang
|
||||
Enable Background Save Aktifkan penyimpanan di latar belakang
|
||||
Enable in-menu font previews Aktifkan pratinjau font dalam menu
|
||||
Show samples on white Tampilkan sampel di atas putih
|
||||
Enable Missing Glyph Protection Aktifkan perlindungan glyph yang hilang
|
||||
Enable Rubber Band for Aktifkan rubber band untuk
|
||||
Enable Touch Gestures Aktifkan gestur sentuh
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2418 and column 5.
|
@@ -812,6 +812,8 @@ font-technology Variable Variabili
|
||||
Delete Width Point Elimina punto di larghezza
|
||||
Delete Workspace Elimina area di lavoro
|
||||
Delete {kind} Style Elimina stile {kind}
|
||||
Delete the selected items and their artwork? Eliminare gli elementi selezionati e la loro grafica?
|
||||
Delete this layer and its artwork? Eliminare questo livello e la sua grafica?
|
||||
Delete “{name}”? Eliminare “{name}”?
|
||||
Depth: Profondità:
|
||||
Description Descrizione
|
||||
@@ -1041,6 +1043,7 @@ font-technology Variable Variabili
|
||||
Enable Contextual Task Bar Attiva barra attività contestuale
|
||||
Enable copying of text, images and other content Consenti la copia di testo, immagini e altri contenuti
|
||||
Enable in-menu font previews Attiva anteprima dei font nei menu
|
||||
Show samples on white Mostra campioni su sfondo bianco
|
||||
Enable Missing Glyph Protection Attiva protezione glifi mancanti
|
||||
Enable Opacity Mask Attiva maschera di opacità
|
||||
Enable plaintext metadata Attiva metadati in testo semplice
|
||||
@@ -2079,6 +2082,10 @@ file-filter Templates Modelli
|
||||
Long Lunga
|
||||
Stylistic Stilistica
|
||||
Mojikumi Set Set mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Set kinsoku
|
||||
Kinsoku Shori Type Tipo di kinsoku shori
|
||||
Push In First Prima all'interno
|
||||
@@ -4757,6 +4764,7 @@ axis Both Entrambi
|
||||
@msg not a valid plug-in module: {0} non è un modulo plug-in valido: {0}
|
||||
@msg not an EMF or WMF file non è un file EMF o WMF
|
||||
@msg not an HP-GL file non è un file HP-GL
|
||||
@msg not a PICT file non è un file PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve essere un elenco da 1 a 255 colori #rrggbb
|
||||
@msg not found non trovato
|
||||
@msg not in pattern editing mode non sei in modalità modifica motivo
|
||||
@@ -5139,6 +5147,7 @@ axis Both Entrambi
|
||||
@msg choose the font files to add scegli i file dei font da aggiungere
|
||||
@msg not a file non è un file
|
||||
@msg not a font file non è un file di font
|
||||
@msg the active document changed while the question was open; nothing was deleted il documento attivo è cambiato mentre la domanda era aperta; non è stato eliminato nulla
|
||||
@msg that document is no longer open quel documento non è più aperto
|
||||
@msg larger than 256 MB più grande di 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time i file superano 1 GB: aggiungine meno alla volta
|
||||
@@ -6849,7 +6858,9 @@ breakText Glyphs Glifi
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» apre file {_1}, non `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` è un plug-in di tipo `{_2}`, non un plug-in di importazione
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` è un plug-in di tipo `{_2}`, non un plug-in di esportazione
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` è un plug-in di importazione: apri un file con document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` è un plug-in di importazione: apri un file con document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` è un plug-in di esportazione: esporta con document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` è un effetto dinamico: applicalo con effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects il plug-in di importazione non ha restituito alcun oggetto
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` un plug-in di importazione restituisce `objects` e `warnings`, non `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads il file supera i {MAX_INPUT_BYTES} byte che un plug-in di importazione legge
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -71,12 +71,16 @@ digits Malayalam マラヤーラム数字
|
||||
Flip 反転
|
||||
Japanese 日本語
|
||||
Join Our Discord Discord に参加
|
||||
Line-end Punctuation Half Width 行末約物半角
|
||||
Line-end Punctuation Half Width 行末・連続約物半角
|
||||
Insert Kashidas カシーダを挿入
|
||||
Kashidas カシーダ
|
||||
Long 長い
|
||||
Stylistic スタイリスティック
|
||||
Mojikumi Set 文字組み
|
||||
YakumonoHankaku 約物半角
|
||||
GyomatsuYakumonoHankaku 行末約物半角
|
||||
GyomatsuYakumonoZenkaku 行末約物全角
|
||||
YakumonoZenkaku 約物全角
|
||||
Kinsoku Set 禁則処理
|
||||
Kinsoku Shori Type 禁則調整方式
|
||||
Push In First 追い込み優先
|
||||
@@ -1471,6 +1475,8 @@ antiAlias Sharp シャープ
|
||||
Delete theme テーマを削除
|
||||
Delete this saved preset この保存済みプリセットを削除
|
||||
Delete {kind} Style {kind}スタイルを削除
|
||||
Delete the selected items and their artwork? 選択した項目とそのアートワークを削除しますか?
|
||||
Delete this layer and its artwork? このレイヤーとそのアートワークを削除しますか?
|
||||
Delete “{name}”? 「{name}」を削除しますか?
|
||||
Depth: 深さ:
|
||||
Description 説明
|
||||
@@ -1638,6 +1644,7 @@ antiAlias Sharp シャープ
|
||||
Enable Touch Workspace タッチワークスペースを有効にする
|
||||
Enable copying of text, images and other content テキスト、イメージ、その他のコンテンツのコピーを許可
|
||||
Enable in-menu font previews メニュー内でフォントプレビューを有効にする
|
||||
Show samples on white サンプルを白地に表示
|
||||
Enable plaintext metadata プレーンテキストのメタデータを有効にする
|
||||
Enable text access for screen readers スクリーンリーダー用にテキストアクセスを有効にする
|
||||
Encoding エンコーディング
|
||||
@@ -4746,6 +4753,7 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg {bpp}-bit bitmaps can't be read {bpp} ビットのビットマップは読み込めません
|
||||
@msg not an EMF or WMF file EMF ファイルでも WMF ファイルでもありません
|
||||
@msg not an HP-GL file HP-GL ファイルではありません
|
||||
@msg not a PICT file PICT ファイルではありません
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` は 1〜255 個の #rrggbb 形式のカラーの配列にしてください
|
||||
@msg its layers use {_1}, which aren't read yet レイヤーで使用されている {_1} はまだ読み込めません
|
||||
@msg the EMF header is damaged EMF ヘッダーが破損しています
|
||||
@@ -5206,6 +5214,7 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg choose the font files to add 追加するフォントファイルを選択してください
|
||||
@msg not a file ファイルではありません
|
||||
@msg not a font file フォントファイルではありません
|
||||
@msg the active document changed while the question was open; nothing was deleted 質問が開いている間にアクティブなドキュメントが変わったため、何も削除されませんでした
|
||||
@msg that document is no longer open そのドキュメントはもう開かれていません
|
||||
@msg larger than 256 MB 256 MB を超えています
|
||||
@msg the files come to more than 1 GB: add fewer at a time ファイルの合計が 1 GB を超えています: 一度に追加するファイルを減らしてください
|
||||
@@ -6914,7 +6923,9 @@ breakText Glyphs 字形
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` 「{shown}」が開くのは {_1} ファイルで、`{_2}` ではありません
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` は種類 `{_2}` のプラグインで、読み込みプラグインではありません
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` は種類 `{_2}` のプラグインで、書き出しプラグインではありません
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` は読み込みプラグインです: document.open {{"importer"}"}} でファイルを開いてください
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` は読み込みプラグインです: document.open {{"importer": "{_2}"}} でファイルを開いてください
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` は書き出しプラグインです: document.export {{"format": "{_2}"}} で書き出してください
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` はライブ効果です: effect.apply {{"effect": "{_2}"}} で適用してください
|
||||
@msg the importer returned no objects 読み込みプラグインがオブジェクトを返しませんでした
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` 読み込みプラグインが返すのは `objects` と `warnings` で、`{key}` ではありません
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads ファイルが読み込みプラグインの読み取る {MAX_INPUT_BYTES} バイトを超えています
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1768,6 +1768,10 @@ digits Malayalam მალაიალამური
|
||||
Long გრძელი
|
||||
Stylistic სტილისტური
|
||||
Mojikumi Set Mojikumi-ის ნაკრები
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Kinsoku-ის ნაკრები
|
||||
Kinsoku Shori Type Kinsoku Shori-ის ტიპი
|
||||
Push In First ჯერ შეტანა
|
||||
@@ -1903,6 +1907,8 @@ digits Malayalam მალაიალამური
|
||||
Weight: სისქე:
|
||||
Art using a deleted global swatch keeps its colour. წაშლილი გლობალური ფერის ნიმუშის მქონე ნახატი თავის ფერს ინარჩუნებს.
|
||||
Color Group: {name} ფერთა ჯგუფი: {name}
|
||||
Delete the selected items and their artwork? წაიშალოს მონიშნული ელემენტები და მათი ნახატი?
|
||||
Delete this layer and its artwork? წაიშალოს ეს შრე და მისი ნახატი?
|
||||
Delete “{name}”? წაიშალოს „{name}“?
|
||||
Document Swatches დოკუმენტის ფერის ნიმუშები
|
||||
Edit or Apply Color Group ფერთა ჯგუფის რედაქტირება ან გამოყენება
|
||||
@@ -3360,6 +3366,7 @@ theme Appearance იერსახე
|
||||
Enable Background Export ფონური ექსპორტის ჩართვა
|
||||
Enable Background Save ფონური შენახვის ჩართვა
|
||||
Enable in-menu font previews შრიფტების წინასწარი ნახვა მენიუში
|
||||
Show samples on white ნიმუშების ჩვენება თეთრ ფონზე
|
||||
Enable Missing Glyph Protection გამოტოვებული გლიფებისგან დაცვის ჩართვა
|
||||
Enable Rubber Band for რეზინის ზოლის ჩართვა:
|
||||
Enable Touch Gestures შეხების ჟესტების ჩართვა
|
||||
@@ -4384,6 +4391,7 @@ font-technology Variable ცვლადი
|
||||
@msg not a valid plug-in module: {0} ეს არ არის პლაგინის სწორი მოდული: {0}
|
||||
@msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი
|
||||
@msg not an HP-GL file ეს არ არის HP-GL ფაილი
|
||||
@msg not a PICT file ეს არ არის PICT ფაილი
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` უნდა იყოს 1-დან 255-მდე #rrggbb ფერის მასივი
|
||||
@msg not found ვერ მოიძებნა
|
||||
@msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული
|
||||
@@ -4733,6 +4741,7 @@ font-technology Variable ცვლადი
|
||||
@msg choose the font files to add აირჩიეთ დასამატებელი შრიფტის ფაილები
|
||||
@msg not a file ფაილი არ არის
|
||||
@msg not a font file შრიფტის ფაილი არ არის
|
||||
@msg the active document changed while the question was open; nothing was deleted აქტიური დოკუმენტი შეიცვალა, სანამ კითხვა ღია იყო; არაფერი წაშლილა
|
||||
@msg that document is no longer open ის დოკუმენტი აღარ არის გახსნილი
|
||||
@msg larger than 256 MB 256 MB-ზე დიდია
|
||||
@msg the files come to more than 1 GB: add fewer at a time ფაილები 1 GB-ს აჭარბებს: დაამატეთ ნაკლები ერთდროულად
|
||||
@@ -7329,7 +7338,9 @@ font-script Japanese იაპონური
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` „{shown}“ ხსნის {_1} ფაილებს და არა `{_2}`-ს
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` არის `{_2}` სახის პლაგინი და არა იმპორტის პლაგინი
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` არის `{_2}` სახის პლაგინი და არა ექსპორტის პლაგინი
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` იმპორტის პლაგინია: გახსენით ფაილი document.open {{"importer"}"}}-ით
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` იმპორტის პლაგინია: გახსენით ფაილი document.open {{"importer": "{_2}"}}-ით
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` ექსპორტის პლაგინია: მოახდინეთ ექსპორტი document.export {{"format": "{_2}"}}-ით
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` ცოცხალი ეფექტია: გამოიყენეთ ის effect.apply {{"effect": "{_2}"}}-ით
|
||||
@msg the importer returned no objects იმპორტის პლაგინმა ობიექტები არ დააბრუნა
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` იმპორტის პლაგინი აბრუნებს `objects`-ს და `warnings`-ს და არა `{key}`-ს
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads ფაილი აღემატება {MAX_INPUT_BYTES} ბაიტს, რომელსაც იმპორტის პლაგინი კითხულობს
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -70,6 +70,10 @@ digits Malayalam 말라얄람어
|
||||
Long 길게
|
||||
Stylistic 스타일
|
||||
Mojikumi Set 모지쿠미 세트
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set 긴소쿠 세트
|
||||
Kinsoku Shori Type 긴소쿠 처리 유형
|
||||
Push In First 밀어 넣기 우선
|
||||
@@ -1461,6 +1465,8 @@ antiAlias Sharp 날카롭게
|
||||
Delete theme 테마 삭제
|
||||
Delete this saved preset 저장된 이 프리셋 삭제
|
||||
Delete {kind} Style {kind} 스타일 삭제
|
||||
Delete the selected items and their artwork? 선택한 항목과 해당 아트워크를 삭제하시겠습니까?
|
||||
Delete this layer and its artwork? 이 레이어와 해당 아트워크를 삭제하시겠습니까?
|
||||
Delete “{name}”? “{name}”을(를) 삭제하시겠습니까?
|
||||
Depth: 깊이:
|
||||
Description 설명
|
||||
@@ -1628,6 +1634,7 @@ antiAlias Sharp 날카롭게
|
||||
Enable Touch Workspace 터치 작업 영역 사용
|
||||
Enable copying of text, images and other content 텍스트, 이미지 및 기타 콘텐츠 복사 허용
|
||||
Enable in-menu font previews 메뉴에서 글꼴 미리 보기 사용
|
||||
Show samples on white 흰색 바탕에 샘플 표시
|
||||
Enable plaintext metadata 일반 텍스트 메타데이터 사용
|
||||
Enable text access for screen readers 화면 읽기 프로그램의 텍스트 접근 허용
|
||||
Encoding 인코딩
|
||||
@@ -4673,6 +4680,7 @@ theme Appearance 외관
|
||||
@msg {bpp}-bit bitmaps can't be read {bpp}비트 비트맵은 읽을 수 없습니다
|
||||
@msg not an EMF or WMF file EMF 또는 WMF 파일이 아닙니다
|
||||
@msg not an HP-GL file HP-GL 파일이 아닙니다
|
||||
@msg not a PICT file PICT 파일이 아닙니다
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens`는 #rrggbb 색상 1~255개의 배열이어야 합니다
|
||||
@msg its layers use {_1}, which aren't read yet 레이어에 {_1}이(가) 있으며, 아직 읽을 수 없습니다
|
||||
@msg the EMF header is damaged EMF 헤더가 손상되었습니다
|
||||
@@ -5133,6 +5141,7 @@ theme Appearance 외관
|
||||
@msg choose the font files to add 추가할 글꼴 파일을 선택하세요
|
||||
@msg not a file 파일이 아닙니다
|
||||
@msg not a font file 글꼴 파일이 아닙니다
|
||||
@msg the active document changed while the question was open; nothing was deleted 질문이 열려 있는 동안 활성 문서가 바뀌어 아무것도 삭제되지 않았습니다
|
||||
@msg that document is no longer open 해당 문서는 더 이상 열려 있지 않습니다
|
||||
@msg larger than 256 MB 256MB보다 큽니다
|
||||
@msg the files come to more than 1 GB: add fewer at a time 파일의 합계가 1GB를 넘습니다: 한 번에 적게 추가하세요
|
||||
@@ -6810,7 +6819,9 @@ breakText Glyphs 글리프
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` “{shown}”은(는) {_1} 파일을 엽니다. `{_2}`은(는) 열지 않습니다
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}`은(는) `{_2}` 종류의 플러그인이며 가져오기 플러그인이 아닙니다
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}`은(는) `{_2}` 종류의 플러그인이며 내보내기 플러그인이 아닙니다
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}`은(는) 가져오기 플러그인입니다: document.open {{"importer"}"}}로 파일을 여세요
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}`은(는) 가져오기 플러그인입니다: document.open {{"importer": "{_2}"}}로 파일을 여세요
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}`은(는) 내보내기 플러그인입니다: document.export {{"format": "{_2}"}}로 내보내세요
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}`은(는) 라이브 효과입니다: effect.apply {{"effect": "{_2}"}}로 적용하세요
|
||||
@msg the importer returned no objects 가져오기 플러그인이 개체를 반환하지 않았습니다
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` 가져오기 플러그인은 `{key}`가 아닌 `objects`와 `warnings`를 반환합니다
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 파일이 가져오기 플러그인이 읽는 {MAX_INPUT_BYTES}바이트보다 큽니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1853,6 +1853,10 @@ digits Malayalam Malayalam
|
||||
Long Lang
|
||||
Stylistic Stilistisch
|
||||
Mojikumi Set Mojikumi-set
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Kinsoku-set
|
||||
Kinsoku Shori Type Kinsoku Shori-type
|
||||
Push In First Eerst inschuiven
|
||||
@@ -1988,6 +1992,8 @@ digits Malayalam Malayalam
|
||||
Weight: Dikte:
|
||||
Art using a deleted global swatch keeps its colour. Artwork met een verwijderd globaal staal behoudt zijn kleur.
|
||||
Color Group: {name} Kleurgroep: {name}
|
||||
Delete the selected items and their artwork? De geselecteerde items en hun artwork verwijderen?
|
||||
Delete this layer and its artwork? Deze laag en het artwork erin verwijderen?
|
||||
Delete “{name}”? „{name}” verwijderen?
|
||||
Document Swatches Documentstalen
|
||||
Edit or Apply Color Group Kleurgroep bewerken of toepassen
|
||||
@@ -3530,6 +3536,7 @@ theme Appearance Vormgeving
|
||||
Enable Background Export Achtergrondexport inschakelen
|
||||
Enable Background Save Achtergrondopslag inschakelen
|
||||
Enable in-menu font previews Lettertypevoorbeelden in het menu inschakelen
|
||||
Show samples on white Voorbeelden op wit tonen
|
||||
Enable Missing Glyph Protection Beveiliging tegen ontbrekende glyphs inschakelen
|
||||
Enable Rubber Band for Rubberband inschakelen voor
|
||||
Enable Touch Gestures Touchgebaren inschakelen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1955,6 +1955,10 @@ digits Malayalam Malajalam
|
||||
Long Długi
|
||||
Stylistic Stylistyczny
|
||||
Mojikumi Set Zestaw mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Zestaw kinsoku
|
||||
Kinsoku Shori Type Typ kinsoku shori
|
||||
Push In First Najpierw wciągaj
|
||||
@@ -2090,6 +2094,8 @@ digits Malayalam Malajalam
|
||||
Weight: Grubość:
|
||||
Art using a deleted global swatch keeps its colour. Kompozycja używająca usuniętej próbki globalnej zachowa swój kolor.
|
||||
Color Group: {name} Grupa kolorów: {name}
|
||||
Delete the selected items and their artwork? Usunąć zaznaczone elementy razem z grafiką?
|
||||
Delete this layer and its artwork? Usunąć tę warstwę razem z grafiką?
|
||||
Delete “{name}”? Usunąć „{name}”?
|
||||
Document Swatches Próbki dokumentu
|
||||
Edit or Apply Color Group Edytuj lub zastosuj grupę kolorów
|
||||
@@ -3660,6 +3666,7 @@ theme Appearance Wygląd
|
||||
Enable Background Export Włącz eksport w tle
|
||||
Enable Background Save Włącz zapis w tle
|
||||
Enable in-menu font previews Włącz podgląd czcionek w menu
|
||||
Show samples on white Pokaż próbki na białym tle
|
||||
Enable Missing Glyph Protection Włącz ochronę przed brakującymi glifami
|
||||
Enable Rubber Band for Włącz gumkę podglądu dla
|
||||
Enable Touch Gestures Włącz gesty dotykowe
|
||||
@@ -4839,6 +4846,7 @@ imageTrace Outline Kontur
|
||||
@msg not a valid plug-in module: {0} nieprawidłowy moduł wtyczki: {0}
|
||||
@msg not an EMF or WMF file to nie jest plik EMF ani WMF
|
||||
@msg not an HP-GL file to nie jest plik HP-GL
|
||||
@msg not a PICT file to nie jest plik PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` musi być tablicą od 1 do 255 kolorów #rrggbb
|
||||
@msg not found nie znaleziono
|
||||
@msg not in pattern editing mode nie jest w trybie edycji wzorka
|
||||
@@ -5222,6 +5230,7 @@ imageTrace Outline Kontur
|
||||
@msg choose the font files to add wybierz pliki czcionek do dodania
|
||||
@msg not a file to nie jest plik
|
||||
@msg not a font file to nie jest plik czcionki
|
||||
@msg the active document changed while the question was open; nothing was deleted aktywny dokument zmienił się, gdy pytanie było otwarte; nic nie zostało usunięte
|
||||
@msg that document is no longer open ten dokument nie jest już otwarty
|
||||
@msg larger than 256 MB większy niż 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time pliki przekraczają łącznie 1 GB: dodawaj mniej naraz
|
||||
@@ -7076,7 +7085,9 @@ breakText Glyphs Glify
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` „{shown}” otwiera pliki {_1}, a nie `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` to wtyczka rodzaju `{_2}`, a nie wtyczka importu
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` to wtyczka rodzaju `{_2}`, a nie wtyczka eksportu
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` to wtyczka importu: otwórz plik za pomocą document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` to wtyczka importu: otwórz plik za pomocą document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` to wtyczka eksportu: eksportuj za pomocą document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` to efekt na żywo: zastosuj go za pomocą effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects wtyczka importu nie zwróciła żadnych obiektów
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` wtyczka importu zwraca `objects` i `warnings`, a nie `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads plik jest większy niż {MAX_INPUT_BYTES} bajtów, które odczytuje wtyczka importu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -725,6 +725,8 @@ antiAlias Sharp Nítido
|
||||
Delete theme Excluir tema
|
||||
Delete this saved preset Excluir este predefinido salvo
|
||||
Delete {kind} Style Excluir Estilo de {kind}
|
||||
Delete the selected items and their artwork? Excluir os itens selecionados e suas ilustrações?
|
||||
Delete this layer and its artwork? Excluir esta camada e sua ilustração?
|
||||
Delete “{name}”? Excluir “{name}”?
|
||||
Depth: Profundidade:
|
||||
Description Descrição
|
||||
@@ -932,6 +934,7 @@ antiAlias Sharp Nítido
|
||||
Enable Touch Workspace Habilitar Espaço de Trabalho de Toque
|
||||
Enable copying of text, images and other content Habilitar cópia de texto, imagens e outro conteúdo
|
||||
Enable in-menu font previews Habilitar visualizações de fonte no menu
|
||||
Show samples on white Mostrar amostras sobre branco
|
||||
Enable plaintext metadata Habilitar metadados de texto simples
|
||||
Enable text access for screen readers Habilitar acesso a texto para leitores de tela
|
||||
Encoding Codificação
|
||||
@@ -3562,6 +3565,10 @@ font-technology Variable Variáveis
|
||||
Long Longo
|
||||
Stylistic Estilístico
|
||||
Mojikumi Set Conjunto Mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Conjunto Kinsoku
|
||||
Kinsoku Shori Type Tipo de Kinsoku Shori
|
||||
Push In First Primeiro para dentro
|
||||
@@ -4163,6 +4170,7 @@ axis Both Ambos
|
||||
@msg not a valid plug-in module: {0} não é um módulo de plug-in válido: {0}
|
||||
@msg not an EMF or WMF file não é um arquivo EMF ou WMF
|
||||
@msg not an HP-GL file não é um arquivo HP-GL
|
||||
@msg not a PICT file não é um arquivo PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` deve ser uma matriz de 1 a 255 cores #rrggbb
|
||||
@msg not found não encontrado
|
||||
@msg not in pattern editing mode não está no modo de edição de padrão
|
||||
@@ -4537,6 +4545,7 @@ axis Both Ambos
|
||||
@msg choose the font files to add escolha os arquivos de fonte a adicionar
|
||||
@msg not a file não é um arquivo
|
||||
@msg not a font file não é um arquivo de fonte
|
||||
@msg the active document changed while the question was open; nothing was deleted o documento ativo mudou enquanto a pergunta estava aberta; nada foi excluído
|
||||
@msg that document is no longer open esse documento não está mais aberto
|
||||
@msg larger than 256 MB maior que 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time os arquivos somam mais de 1 GB: adicione menos por vez
|
||||
@@ -7293,7 +7302,9 @@ swatch Yellow Green Verde-amarelado
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” abre arquivos {_1}, não `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` é um plug-in do tipo `{_2}`, não um plug-in de importação
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` é um plug-in do tipo `{_2}`, não um plug-in de exportação
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` é um plug-in de importação: abra um arquivo com document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` é um plug-in de importação: abra um arquivo com document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` é um plug-in de exportação: exporte com document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` é um efeito dinâmico: aplique-o com effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects o plug-in de importação não retornou nenhum objeto
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` um plug-in de importação retorna `objects` e `warnings`, não `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads o arquivo é maior que os {MAX_INPUT_BYTES} bytes que um plug-in de importação lê
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -810,6 +810,8 @@ font-technology Variable Вариативные
|
||||
Delete Width Point Удалить точку ширины
|
||||
Delete Workspace Удалить рабочую среду
|
||||
Delete {kind} Style Удалить стиль {kind}
|
||||
Delete the selected items and their artwork? Удалить выбранные элементы вместе с графикой?
|
||||
Delete this layer and its artwork? Удалить этот слой вместе с графикой?
|
||||
Delete “{name}”? Удалить «{name}»?
|
||||
Depth: Глубина:
|
||||
Description Описание
|
||||
@@ -1030,6 +1032,7 @@ font-technology Variable Вариативные
|
||||
Enable Contextual Task Bar Включить контекстную панель задач
|
||||
Enable copying of text, images and other content Разрешить копирование текста, изображений и другого содержимого
|
||||
Enable in-menu font previews Включить предварительный просмотр шрифтов в меню
|
||||
Show samples on white Показывать образцы на белом
|
||||
Enable Missing Glyph Protection Включить защиту отсутствующих глифов
|
||||
Enable Opacity Mask Включить непрозрачную маску
|
||||
Enable plaintext metadata Включить метаданные в виде простого текста
|
||||
@@ -2068,6 +2071,10 @@ file-filter Templates Шаблоны
|
||||
Long Длинные
|
||||
Stylistic Стилистические
|
||||
Mojikumi Set Набор mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Набор kinsoku
|
||||
Kinsoku Shori Type Тип kinsoku shori
|
||||
Push In First Сначала втягивать
|
||||
@@ -4743,6 +4750,7 @@ axis Both Оба
|
||||
@msg not a valid plug-in module: {0} не является допустимым модулем: {0}
|
||||
@msg not an EMF or WMF file не является файлом EMF или WMF
|
||||
@msg not an HP-GL file не является файлом HP-GL
|
||||
@msg not a PICT file не является файлом PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` должен быть массивом из 1–255 цветов #rrggbb
|
||||
@msg not found не найдено
|
||||
@msg not in pattern editing mode не в режиме редактирования узора
|
||||
@@ -5125,6 +5133,7 @@ axis Both Оба
|
||||
@msg choose the font files to add выберите файлы шрифтов для добавления
|
||||
@msg not a file не файл
|
||||
@msg not a font file не файл шрифта
|
||||
@msg the active document changed while the question was open; nothing was deleted активный документ сменился, пока был открыт вопрос; ничего не удалено
|
||||
@msg that document is no longer open этот документ больше не открыт
|
||||
@msg larger than 256 MB больше 256 МБ
|
||||
@msg the files come to more than 1 GB: add fewer at a time файлы занимают больше 1 ГБ: добавляйте меньше за раз
|
||||
@@ -7309,7 +7318,9 @@ shape Open Открытый
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» открывает файлы {_1}, а не `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` является модулем вида `{_2}`, а не модулем импорта
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` является модулем вида `{_2}`, а не модулем экспорта
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` — модуль импорта: откройте файл с помощью document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` — модуль импорта: откройте файл с помощью document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` — модуль экспорта: экспортируйте с помощью document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` — динамический эффект: примените его с помощью effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects модуль импорта не вернул ни одного объекта
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` модуль импорта возвращает `objects` и `warnings`, а не `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads файл больше {MAX_INPUT_BYTES} байт, которые читает модуль импорта
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -799,6 +799,8 @@ font-technology Variable Variabla
|
||||
Delete Width Point Radera breddpunkt
|
||||
Delete Workspace Radera arbetslayout
|
||||
Delete {kind} Style Radera stil ({kind})
|
||||
Delete the selected items and their artwork? Radera de markerade objekten och deras bildmaterial?
|
||||
Delete this layer and its artwork? Radera det här lagret och dess bildmaterial?
|
||||
Delete “{name}”? Radera ”{name}”?
|
||||
Depth: Djup:
|
||||
Description Beskrivning
|
||||
@@ -1011,6 +1013,7 @@ font-technology Variable Variabla
|
||||
Enable Contextual Task Bar Aktivera sammanhangsberoende aktivitetsfält
|
||||
Enable copying of text, images and other content Tillåt kopiering av text, bilder och annat innehåll
|
||||
Enable in-menu font previews Aktivera förhandsgranskning av typsnitt i menyer
|
||||
Show samples on white Visa exempel på vitt
|
||||
Enable Missing Glyph Protection Aktivera skydd mot saknade glyfer
|
||||
Enable Opacity Mask Aktivera opacitetsmask
|
||||
Enable plaintext metadata Aktivera metadata i klartext
|
||||
@@ -1982,6 +1985,10 @@ font-technology Variable Variabla
|
||||
Long Lång
|
||||
Stylistic Stilistisk
|
||||
Mojikumi Set Mojikumi-uppsättning
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Kinsoku-uppsättning
|
||||
Kinsoku Shori Type Kinsoku Shori-typ
|
||||
Push In First Skjut in först
|
||||
@@ -4607,6 +4614,7 @@ axis Both Båda
|
||||
@msg not a valid plug-in module: {0} inte ett giltigt plugin: {0}
|
||||
@msg not an EMF or WMF file inte en EMF- eller WMF-fil
|
||||
@msg not an HP-GL file inte en HP-GL-fil
|
||||
@msg not a PICT file inte en PICT-fil
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` måste vara en array med 1 till 255 färger i formatet #rrggbb
|
||||
@msg not found hittades inte
|
||||
@msg not in pattern editing mode inte i läget för mönsterredigering
|
||||
@@ -4983,6 +4991,7 @@ axis Both Båda
|
||||
@msg choose the font files to add välj de typsnittsfiler som ska läggas till
|
||||
@msg not a file inte en fil
|
||||
@msg not a font file inte en typsnittsfil
|
||||
@msg the active document changed while the question was open; nothing was deleted det aktiva dokumentet ändrades medan frågan var öppen; inget raderades
|
||||
@msg that document is no longer open det dokumentet är inte längre öppet
|
||||
@msg larger than 256 MB större än 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time filerna blir större än 1 GB: lägg till färre åt gången
|
||||
@@ -7329,8 +7338,8 @@ digits Hindi Hindi
|
||||
@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} skulle skrivas ut på fler än {MAX_TILES} rutor: skala ned det
|
||||
@msg `{_1}` can't be edited or removed `{_1}` kan inte redigeras eller tas bort
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` är ett plugin av typen `{_2}`, inte en exportfunktion
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect"}"}} `{_1}` är en live-effekt: använd den med effect.apply {{"effect"}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format"}"}} `{_1}` är en exportfunktion: exportera med document.export {{"format"}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` är en live-effekt: använd den med effect.apply {{"effect": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` är en exportfunktion: exportera med document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is built in and always listed `{_1}` är inbyggd och listas alltid
|
||||
@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` är inte en typsnittsfil som den senaste sökningen med text.findFontFiles hittade
|
||||
@msg `{_1}` would be overwritten by the package: choose a different folder or name `{_1}` skulle skrivas över av paketet: välj en annan mapp eller ett annat namn
|
||||
@@ -7485,7 +7494,7 @@ digits Hindi Hindi
|
||||
@msg `mode` must be "handles", "straight" or "smooth" `mode` måste vara "handles", "straight" eller "smooth"
|
||||
@msg `mode` must be "straight", "bezier" or "arc" `mode` måste vara "straight", "bezier" eller "arc"
|
||||
@msg `mode` must be one of {_1} `mode` måste vara något av {_1}
|
||||
@msg `mojikumi` must be "none" or "lineEndHalf" `mojikumi` måste vara "none" eller "lineEndHalf"
|
||||
@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` måste vara "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" eller "lineEndHalf"
|
||||
@msg `name` must be 1-64 printable characters `name` måste vara 1-64 utskrivbara tecken
|
||||
@msg `names` is a list of preset names `names` är en lista med namn på förinställningar
|
||||
@msg `names` lists preset names `names` listar namn på förinställningar
|
||||
@@ -8168,7 +8177,6 @@ digits Hindi Hindi
|
||||
@msg not a Word document: its package has no main document part inte ett Word-dokument: dess paket saknar huvuddokumentdel
|
||||
@msg not a Word document: its WordDocument stream doesn't start with a file information block inte ett Word-dokument: dess WordDocument-ström börjar inte med ett filinformationsblock
|
||||
@msg not an image VectorCraft reads (see document.formats) inte en bild som VectorCraft kan läsa (se document.formats)
|
||||
@msg not an RTF file: it doesn't start with not an RTF file: it doesn't start with {\rtf inte en RTF-fil: den börjar inte med inte en RTF-fil: den börjar inte med {\rtf
|
||||
@msg not area type (text in a frame) outside perspective inte områdestext (text i en ram) utanför perspektivet
|
||||
@msg not editing an opacity mask redigerar inte en opacitetsmask
|
||||
@msg not JSON: {e} inte JSON: {e}
|
||||
@@ -8663,7 +8671,7 @@ digits Hindi Hindi
|
||||
@msg `importer` must be a plug-in format id (see document.formats) `importer` måste vara ett formatid för ett plugin (se document.formats)
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` ”{shown}” öppnar {_1}-filer, inte `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` är ett plugin av typen `{_2}`, inte en importfunktion
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` är en importfunktion: öppna en fil med document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` är en importfunktion: öppna en fil med document.open {{"importer": "{_2}"}}
|
||||
@msg the importer returned no objects importfunktionen returnerade inga objekt
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` en importfunktion returnerar `objects` och `warnings`, inte `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads filen är större än de {MAX_INPUT_BYTES} byte som en importfunktion läser
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1786,6 +1786,81 @@ fn templates_that_start_with_text_match_first() {
|
||||
assert_eq!(c.message("a.wasm is 9 bytes (limit 8)").map(|(f, _)| f), Some("sized {_1}"));
|
||||
}
|
||||
|
||||
/// `{{` and `}}` in a message row are literal braces, in the English source and in the
|
||||
/// translation, as in a Rust format string; only the other `{name}` are placeholders.
|
||||
#[test]
|
||||
fn message_rows_read_doubled_braces_as_literal_braces() {
|
||||
let c = Catalog::parse(concat!(
|
||||
"@msg\t`{_1}` runs with plugin.run {{\"id\": \"{_2}\"}}\t`{_1}`: plugin.run {{\"id\": \"{_2}\"}} (fr)\n",
|
||||
"@msg\tbraces {{}} and {_1}\tbraces {{}} with {_1}\n",
|
||||
"@msg\tonly {{literal}} braces\tliteral {{braces}}\n",
|
||||
"@msg\tnot an RTF file: it doesn't start with {\\\\rtf\tno RTF: {\\\\rtf\n",
|
||||
));
|
||||
let (form, caps) = c.message(r#"`a.b` runs with plugin.run {"id": "plugin:a.b"}"#).expect("matches");
|
||||
assert_eq!(caps, [("_1", "a.b"), ("_2", "plugin:a.b")]);
|
||||
assert_eq!(fill_once(form, &caps), r#"`a.b`: plugin.run {"id": "plugin:a.b"} (fr)"#);
|
||||
assert!(c.message(r#"`a.b` runs with plugin.run {{"id": "plugin:a.b"}}"#).is_none());
|
||||
let (form, caps) = c.message("braces {} and x").expect("matches");
|
||||
assert_eq!(fill_once(form, &caps), "braces {} with x");
|
||||
let (form, caps) = c.message("only {literal} braces").expect("matches");
|
||||
assert_eq!(fill_once(form, &caps), "literal {braces}");
|
||||
// A `{` with no `}` after it is literal text too.
|
||||
assert_eq!(c.message(r"not an RTF file: it doesn't start with {\rtf").map(|(f, _)| f), Some(r"no RTF: {\rtf"));
|
||||
assert_eq!(placeholders(r#"{{"id": "{_2}"}} {{x}} {n}"#), ["_2", "n"]);
|
||||
}
|
||||
|
||||
/// A format string's `{{` and `}}` stay literal braces in its catalog key, and only its real
|
||||
/// placeholders are numbered.
|
||||
#[test]
|
||||
fn message_keys_keep_doubled_braces() {
|
||||
assert_eq!(
|
||||
as_template(r#"`{}` is an importer: open a file with document.open {{"importer": "{}"}}"#),
|
||||
r#"`{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}}"#
|
||||
);
|
||||
assert_eq!(as_template("{k} must be {{size?, position?}}"), "{k} must be {{size?, position?}}");
|
||||
assert_eq!(as_template(r"it doesn't start with {\rtf"), r"it doesn't start with {\rtf");
|
||||
}
|
||||
|
||||
/// `plugin.run` of an importer, an exporter or a live effect names the command that runs it with
|
||||
/// the JSON that picks it, and every complete-message catalog translates that error with the
|
||||
/// plug-in's id and the JSON kept.
|
||||
#[test]
|
||||
fn plugin_run_of_another_kind_is_translated_with_its_json() {
|
||||
use serde_json::json;
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
let plugins = [
|
||||
("test.i18n.reader", tpl::importer("test.i18n.reader", r#"["ti18n"]"#, "{}"), "is an importer"),
|
||||
("test.i18n.writer", tpl::exporter("test.i18n.writer", r#"["ti18n"]"#, "{}"), "is an exporter"),
|
||||
("test.i18n.effect", tpl::manifest("test.i18n.effect", "effect", "{}"), "is a live effect"),
|
||||
];
|
||||
for (_, manifest, _) in &plugins {
|
||||
let wasm = wat::parse_str(tpl::module(manifest, tpl::ECHO, "", 1)).unwrap();
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap();
|
||||
}
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let errors: Vec<(String, &str, &str)> =
|
||||
plugins.iter().map(|(id, _, english)| (s.execute("plugin.run", &json!({"id": id})).unwrap_err().to_string(), *id, *english)).collect();
|
||||
for (id, _, _) in &plugins {
|
||||
s.execute("plugin.remove", &json!({"id": id})).unwrap();
|
||||
}
|
||||
for code in COMPLETE_MESSAGES {
|
||||
let lang = Lang::from_code(code).expect("registered");
|
||||
for (e, id, english) in &errors {
|
||||
assert!(e.contains(english), "{e}");
|
||||
let json = &e[e.rfind('{').unwrap()..];
|
||||
assert!(json.ends_with('}') && json.contains(id), "{e}");
|
||||
let shown = message(lang, e);
|
||||
assert!(!shown.contains(english) && shown.contains(json) && shown.contains(&format!("`{id}`")), "{code}: {e} -> {shown}");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
message(de(), &errors[0].0).ends_with(r#"Öffnen Sie eine Datei mit document.open {"importer": "plugin.test.i18n.reader"}"#),
|
||||
"{}",
|
||||
message(de(), &errors[0].0)
|
||||
);
|
||||
}
|
||||
|
||||
/// A `@msgplural` row translates both English forms of a message with a count, and the count
|
||||
/// picks the language's form.
|
||||
#[test]
|
||||
@@ -3666,7 +3741,8 @@ fn string_literal(s: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A format string as a catalog template (see [`message_literals`]).
|
||||
/// A format string as a catalog template (see [`message_literals`]). `{{` and `}}` stay as they
|
||||
/// are, the catalogs' literal braces.
|
||||
fn as_template(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut unnamed = 0;
|
||||
@@ -3674,7 +3750,15 @@ fn as_template(s: &str) -> String {
|
||||
while let Some(a) = rest.find('{') {
|
||||
out.push_str(&rest[..a]);
|
||||
let after = &rest[a + 1..];
|
||||
let Some(b) = after.find('}') else { break };
|
||||
if let Some(escaped) = after.strip_prefix('{') {
|
||||
out.push_str("{{");
|
||||
rest = escaped;
|
||||
continue;
|
||||
}
|
||||
let Some(b) = after.find('}') else {
|
||||
out.push_str(&rest[a..]);
|
||||
return out;
|
||||
};
|
||||
let name = after[..b].split(':').next().unwrap_or("");
|
||||
if name.is_empty() {
|
||||
unnamed += 1;
|
||||
|
||||
@@ -791,6 +791,8 @@ font-technology Variable Değişken
|
||||
Delete Width Point Genişlik Noktasını Sil
|
||||
Delete Workspace Çalışma Alanını Sil
|
||||
Delete {kind} Style {kind} Stilini Sil
|
||||
Delete the selected items and their artwork? Seçili öğeler ve içlerindeki çizim silinsin mi?
|
||||
Delete this layer and its artwork? Bu katman ve içindeki çizim silinsin mi?
|
||||
Delete “{name}”? “{name}” silinsin mi?
|
||||
Depth: Derinlik:
|
||||
Description Açıklama
|
||||
@@ -1003,6 +1005,7 @@ font-technology Variable Değişken
|
||||
Enable Contextual Task Bar Bağlamsal Görev Çubuğunu Etkinleştir
|
||||
Enable copying of text, images and other content Metin, resim ve diğer içeriklerin kopyalanmasını etkinleştirin
|
||||
Enable in-menu font previews Menü içi yazı tipi önizlemelerini etkinleştir
|
||||
Show samples on white Örnekleri beyaz üzerinde göster
|
||||
Enable Missing Glyph Protection Eksik Glif Korumasını Etkinleştir
|
||||
Enable Opacity Mask Opaklık Maskesini Etkinleştir
|
||||
Enable plaintext metadata Düz metin meta verilerini etkinleştir
|
||||
@@ -1871,6 +1874,10 @@ font-technology Variable Değişken
|
||||
Modify Değiştir
|
||||
Mojikumi Mojikumi
|
||||
Mojikumi Set Mojikumi Seti
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Kinsoku Seti
|
||||
Kinsoku Shori Type Kinsoku Shori Türü
|
||||
Push In First Önce İçeri
|
||||
@@ -4462,6 +4469,7 @@ axis Both Her İkisi
|
||||
@msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0}
|
||||
@msg not an EMF or WMF file EMF veya WMF dosyası değil
|
||||
@msg not an HP-GL file HP-GL dosyası değil
|
||||
@msg not a PICT file PICT dosyası değil
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 1 ile 255 arasında #rrggbb renginden oluşan bir dizi olmalıdır
|
||||
@msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor
|
||||
@msg not found bulunamadı
|
||||
@@ -4821,6 +4829,7 @@ axis Both Her İkisi
|
||||
@msg choose the font files to add eklenecek yazı tipi dosyalarını seçin
|
||||
@msg not a file dosya değil
|
||||
@msg not a font file yazı tipi dosyası değil
|
||||
@msg the active document changed while the question was open; nothing was deleted soru açıkken etkin belge değişti; hiçbir şey silinmedi
|
||||
@msg that document is no longer open bu belge artık açık değil
|
||||
@msg larger than 256 MB 256 MB'tan büyük
|
||||
@msg the files come to more than 1 GB: add fewer at a time dosyalar 1 GB'tan fazla geliyor: bir kerede daha azını ekleyin
|
||||
@@ -7176,7 +7185,9 @@ breakText Glyphs Glifler
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” {_1} dosyalarını açar, `{_2}` dosyasını değil
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}`, `{_2}` türünde bir eklentidir, içe aktarma eklentisi değildir
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}`, `{_2}` türünde bir eklentidir, dışa aktarma eklentisi değildir
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` bir içe aktarma eklentisidir: dosyayı document.open {{"importer"}"}} ile açın
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` bir içe aktarma eklentisidir: dosyayı document.open {{"importer": "{_2}"}} ile açın
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` bir dışa aktarma eklentisidir: document.export {{"format": "{_2}"}} ile dışa aktarın
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` canlı bir efekttir: effect.apply {{"effect": "{_2}"}} ile uygulayın
|
||||
@msg the importer returned no objects içe aktarma eklentisi hiç nesne döndürmedi
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` içe aktarma eklentisi `{key}` değil, `objects` ve `warnings` döndürür
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads dosya, içe aktarma eklentisinin okuduğu {MAX_INPUT_BYTES} bayttan büyük
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -806,6 +806,8 @@ font-technology Variable Варіативні
|
||||
Delete Width Point Видалити точку ширини
|
||||
Delete Workspace Видалити робоче середовище
|
||||
Delete {kind} Style Видалити стиль {kind}
|
||||
Delete the selected items and their artwork? Видалити вибрані елементи разом із графікою?
|
||||
Delete this layer and its artwork? Видалити цей шар разом із графікою?
|
||||
Delete “{name}”? Видалити "{name}"?
|
||||
Depth: Глибина:
|
||||
Description Опис
|
||||
@@ -1035,6 +1037,7 @@ font-technology Variable Варіативні
|
||||
Enable Contextual Task Bar Увімкнути контекстну панель завдань
|
||||
Enable copying of text, images and other content Дозволити копіювання тексту, зображень та іншого вмісту
|
||||
Enable in-menu font previews Увімкнути попередній перегляд шрифтів у меню
|
||||
Show samples on white Показувати зразки на білому
|
||||
Enable Missing Glyph Protection Увімкнути захист відсутніх гліфів
|
||||
Enable Opacity Mask Увімкнути маску непрозорості
|
||||
Enable plaintext metadata Увімкнути метадані у вигляді простого тексту
|
||||
@@ -2073,6 +2076,10 @@ file-filter Templates Шаблони
|
||||
Long Довгі
|
||||
Stylistic Стилістичні
|
||||
Mojikumi Set Набір mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Набір kinsoku
|
||||
Kinsoku Shori Type Тип kinsoku shori
|
||||
Push In First Спершу втягувати
|
||||
@@ -4741,6 +4748,7 @@ axis Both Обидві
|
||||
@msg not a valid plug-in module: {0} не є допустимим модулем: {0}
|
||||
@msg not an EMF or WMF file не є файлом EMF або WMF
|
||||
@msg not an HP-GL file не є файлом HP-GL
|
||||
@msg not a PICT file не є файлом PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` має бути масивом із 1–255 кольорів #rrggbb
|
||||
@msg not found не знайдено
|
||||
@msg not in pattern editing mode не в режимі редагування візерунка
|
||||
@@ -5123,6 +5131,7 @@ axis Both Обидві
|
||||
@msg choose the font files to add виберіть файли шрифтів для додавання
|
||||
@msg not a file не файл
|
||||
@msg not a font file не файл шрифту
|
||||
@msg the active document changed while the question was open; nothing was deleted активний документ змінився, поки питання було відкрите; нічого не видалено
|
||||
@msg that document is no longer open цей документ більше не відкрито
|
||||
@msg larger than 256 MB більше 256 МБ
|
||||
@msg the files come to more than 1 GB: add fewer at a time файли займають понад 1 ГБ: додавайте менше за раз
|
||||
@@ -6829,7 +6838,9 @@ breakText Glyphs Гліфи
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` «{shown}» відкриває файли {_1}, а не `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` є модулем виду `{_2}`, а не модулем імпорту
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` є модулем виду `{_2}`, а не модулем експорту
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` - модуль імпорту: відкрийте файл за допомогою document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` - модуль імпорту: відкрийте файл за допомогою document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` - модуль експорту: експортуйте за допомогою document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` - динамічний ефект: застосуйте його за допомогою effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects модуль імпорту не повернув жодного об’єкта
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` модуль імпорту повертає `objects` і `warnings`, а не `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads файл більший за {MAX_INPUT_BYTES} байтів, які читає модуль імпорту
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -938,6 +938,8 @@ font-technology Variable Biến thiên
|
||||
Delete Width Point Xóa điểm chiều rộng
|
||||
Delete Workspace Xóa không gian làm việc
|
||||
Delete {kind} Style Xóa kiểu {kind}
|
||||
Delete the selected items and their artwork? Xóa các mục đã chọn và tác phẩm của chúng?
|
||||
Delete this layer and its artwork? Xóa lớp này và tác phẩm của nó?
|
||||
Delete “{name}”? Xóa “{name}”?
|
||||
Depth Độ sâu
|
||||
Depth: Độ sâu:
|
||||
@@ -1198,6 +1200,7 @@ font-technology Variable Biến thiên
|
||||
Enable Contextual Task Bar Bật thanh tác vụ theo ngữ cảnh
|
||||
Enable copying of text, images and other content Bật sao chép văn bản, ảnh và nội dung khác
|
||||
Enable in-menu font previews Bật xem trước phông trong menu
|
||||
Show samples on white Hiển thị mẫu trên nền trắng
|
||||
Enable Missing Glyph Protection Bật bảo vệ glyph bị thiếu
|
||||
Enable Opacity Mask Bật mặt nạ độ mờ
|
||||
Enable plaintext metadata Bật siêu dữ liệu văn bản thuần
|
||||
@@ -2388,6 +2391,10 @@ digits Hindi Hindi
|
||||
Long Dài
|
||||
Stylistic Kiểu cách
|
||||
Mojikumi Set Bộ Mojikumi
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Monitor RGB RGB màn hình
|
||||
Mono Đơn sắc
|
||||
Monochrome images Ảnh đơn sắc
|
||||
@@ -5054,6 +5061,7 @@ theme Appearance Giao diện
|
||||
@msg This shortcut file is in a form VectorCraft can't read. Tệp phím tắt này có dạng mà VectorCraft không đọc được.
|
||||
@msg not an EMF or WMF file không phải tệp EMF hoặc WMF
|
||||
@msg not an HP-GL file không phải tệp HP-GL
|
||||
@msg not a PICT file không phải tệp PICT
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` phải là mảng từ 1 đến 255 màu #rrggbb
|
||||
@msg not found không tìm thấy
|
||||
@msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết
|
||||
@@ -5261,6 +5269,7 @@ theme Appearance Giao diện
|
||||
@msg t, dx and dy must be finite t, dx và dy phải là số hữu hạn
|
||||
@msg Targa files are RGB: colorModel rgb Tệp Targa là RGB: colorModel rgb
|
||||
@msg Text Wrap: select a wrap object Bọc chữ: hãy chọn đối tượng bọc
|
||||
@msg the active document changed while the question was open; nothing was deleted tài liệu đang hoạt động đã thay đổi khi câu hỏi đang mở; không có gì bị xóa
|
||||
@msg that document is no longer open tài liệu đó không còn mở
|
||||
@msg Extrude & Bevel Kéo dài & vát
|
||||
@msg Revolve Xoay tròn
|
||||
@@ -6830,7 +6839,9 @@ breakText Glyphs Glyph
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” mở tệp {_1}, không phải `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` là plug-in loại `{_2}`, không phải plug-in nhập
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` là plug-in loại `{_2}`, không phải plug-in xuất
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` là plug-in nhập: hãy mở tệp bằng document.open {{"importer"}"}}
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` là plug-in nhập: hãy mở tệp bằng document.open {{"importer": "{_2}"}}
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` là plug-in xuất: hãy xuất bằng document.export {{"format": "{_2}"}}
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` là hiệu ứng trực tiếp: hãy áp dụng bằng effect.apply {{"effect": "{_2}"}}
|
||||
@msg the importer returned no objects plug-in nhập không trả về đối tượng nào
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` plug-in nhập trả về `objects` và `warnings`, không phải `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads tệp lớn hơn {MAX_INPUT_BYTES} byte mà plug-in nhập đọc
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -755,6 +755,8 @@ antiAlias Sharp 锐利
|
||||
Delete Width Point 删除宽度点
|
||||
Delete Workspace 删除工作区
|
||||
Delete {kind} Style 删除{kind}样式
|
||||
Delete the selected items and their artwork? 要删除所选项目及其图稿吗?
|
||||
Delete this layer and its artwork? 要删除此图层及其图稿吗?
|
||||
Delete “{name}”? 要删除“{name}”吗?
|
||||
Depth: 深度:
|
||||
Description 描述
|
||||
@@ -973,6 +975,7 @@ antiAlias Sharp 锐利
|
||||
Enable Contextual Task Bar 启用上下文任务栏
|
||||
Enable copying of text, images and other content 启用复制文字、图像与其他内容
|
||||
Enable in-menu font previews 启用菜单内字体预览
|
||||
Show samples on white 在白色背景上显示样本
|
||||
Enable Missing Glyph Protection 启用缺字保护
|
||||
Enable Opacity Mask 启用不透明度蒙版
|
||||
Enable plaintext metadata 启用纯文本元数据
|
||||
@@ -4444,6 +4447,7 @@ imageTrace Outline 轮廓
|
||||
@msg {bpp}-bit bitmaps can't be read 无法读取 {bpp} 位位图
|
||||
@msg not an EMF or WMF file 不是 EMF 或 WMF 文件
|
||||
@msg not an HP-GL file 不是 HP-GL 文件
|
||||
@msg not a PICT file 不是 PICT 文件
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必须是由 1 到 255 个 #rrggbb 格式颜色组成的数组
|
||||
@msg its layers use {_1}, which aren't read yet 其图层使用了 {_1},尚不能读取
|
||||
@msg the EMF header is damaged EMF 文件头已损坏
|
||||
@@ -4895,6 +4899,7 @@ imageTrace Outline 轮廓
|
||||
@msg choose the font files to add 请选择要添加的字体文件
|
||||
@msg not a file 不是文件
|
||||
@msg not a font file 不是字体文件
|
||||
@msg the active document changed while the question was open; nothing was deleted 问题打开期间活动文档已更改;未删除任何内容
|
||||
@msg that document is no longer open 该文档已不再打开
|
||||
@msg larger than 256 MB 大于 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time 文件总计超过 1 GB:请每次少添加一些
|
||||
@@ -5345,6 +5350,10 @@ font-technology Variable 可变
|
||||
Long 长
|
||||
Stylistic 风格化
|
||||
Mojikumi Set 标点挤压集
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Kinsoku 集
|
||||
Kinsoku Shori Type Kinsoku 处理方式
|
||||
Push In First 先推入
|
||||
@@ -7061,7 +7070,9 @@ breakText Glyphs 字形
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` “{shown}” 打开的是 {_1} 文件,不是 `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` 是 `{_2}` 类型的增效工具,不是导入增效工具
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` 是 `{_2}` 类型的增效工具,不是导出增效工具
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` 是导入增效工具:请用 document.open {{"importer"}"}} 打开文件
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` 是导入增效工具:请用 document.open {{"importer": "{_2}"}} 打开文件
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` 是导出增效工具:请用 document.export {{"format": "{_2}"}} 导出
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` 是实时效果:请用 effect.apply {{"effect": "{_2}"}} 应用
|
||||
@msg the importer returned no objects 导入增效工具未返回任何对象
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` 导入增效工具返回 `objects` 和 `warnings`,不是 `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 文件超过导入增效工具读取的 {MAX_INPUT_BYTES} 字节
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -774,6 +774,8 @@ font-technology Variable 可變
|
||||
Delete Width Point 刪除寬度點
|
||||
Delete Workspace 刪除工作區
|
||||
Delete {kind} Style 刪除{kind}樣式
|
||||
Delete the selected items and their artwork? 要刪除所選項目及其圖稿嗎?
|
||||
Delete this layer and its artwork? 要刪除此圖層及其圖稿嗎?
|
||||
Delete “{name}”? 要刪除「{name}」嗎?
|
||||
Depth: 深度:
|
||||
Description 描述
|
||||
@@ -1003,6 +1005,7 @@ font-technology Variable 可變
|
||||
Enable Contextual Task Bar 啟用快捷工作列
|
||||
Enable copying of text, images and other content 啟用複製文字、影像與其他內容
|
||||
Enable in-menu font previews 啟用選單內字體預覽
|
||||
Show samples on white 在白色背景上顯示樣本
|
||||
Enable Missing Glyph Protection 啟用遺失字符保護
|
||||
Enable Opacity Mask 啟用不透明度遮色片
|
||||
Enable plaintext metadata 啟用純文字中繼資料
|
||||
@@ -2212,6 +2215,7 @@ file-filter Templates 範本
|
||||
@msg {bpp}-bit bitmaps can't be read 無法讀取 {bpp} 位元點陣圖
|
||||
@msg not an EMF or WMF file 不是 EMF 或 WMF 檔案
|
||||
@msg not an HP-GL file 不是 HP-GL 檔案
|
||||
@msg not a PICT file 不是 PICT 檔案
|
||||
@msg `pens` must be an array of 1 to 255 #rrggbb colors `pens` 必須是由 1 到 255 個 #rrggbb 格式顏色組成的陣列
|
||||
@msg its layers use {_1}, which aren't read yet 其圖層使用了 {_1},目前尚無法讀取
|
||||
@msg the EMF header is damaged EMF 檔頭已損毀
|
||||
@@ -2662,6 +2666,7 @@ file-filter Templates 範本
|
||||
@msg choose the font files to add 請選擇要加入的字體檔案
|
||||
@msg not a file 不是檔案
|
||||
@msg not a font file 不是字體檔案
|
||||
@msg the active document changed while the question was open; nothing was deleted 問題開啟期間作用中文件已變更;未刪除任何內容
|
||||
@msg that document is no longer open 該文件已不再開啟
|
||||
@msg larger than 256 MB 大於 256 MB
|
||||
@msg the files come to more than 1 GB: add fewer at a time 檔案總計超過 1 GB:請每次少加入一些
|
||||
@@ -3111,6 +3116,10 @@ file-filter Templates 範本
|
||||
Long 長
|
||||
Stylistic 風格化
|
||||
Mojikumi Set 標點間距
|
||||
YakumonoHankaku YakumonoHankaku
|
||||
GyomatsuYakumonoHankaku GyomatsuYakumonoHankaku
|
||||
GyomatsuYakumonoZenkaku GyomatsuYakumonoZenkaku
|
||||
YakumonoZenkaku YakumonoZenkaku
|
||||
Kinsoku Set Kinsoku 集
|
||||
Kinsoku Shori Type Kinsoku 處理方式
|
||||
Push In First 先推入
|
||||
@@ -6867,7 +6876,9 @@ breakText Glyphs 字符
|
||||
@msg “{shown}” opens {_1} files, not `{_2}` 「{shown}」開啟的是 {_1} 檔案,不是 `{_2}`
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an importer `{_1}` 是 `{_2}` 類型的增效模組,不是匯入增效模組
|
||||
@msg `{_1}` is a plug-in of kind `{_2}`, not an exporter `{_1}` 是 `{_2}` 類型的增效模組,不是匯出增效模組
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer"}"}} `{_1}` 是匯入增效模組:請用 document.open {{"importer"}"}} 開啟檔案
|
||||
@msg `{_1}` is an importer: open a file with document.open {{"importer": "{_2}"}} `{_1}` 是匯入增效模組:請用 document.open {{"importer": "{_2}"}} 開啟檔案
|
||||
@msg `{_1}` is an exporter: export with document.export {{"format": "{_2}"}} `{_1}` 是匯出增效模組:請用 document.export {{"format": "{_2}"}} 匯出
|
||||
@msg `{_1}` is a live effect: apply it with effect.apply {{"effect": "{_2}"}} `{_1}` 是即時效果:請用 effect.apply {{"effect": "{_2}"}} 套用
|
||||
@msg the importer returned no objects 匯入增效模組未傳回任何物件
|
||||
@msg an importer returns `objects` and `warnings`, not `{key}` 匯入增效模組傳回 `objects` 和 `warnings`,不是 `{key}`
|
||||
@msg the file is larger than the {MAX_INPUT_BYTES} bytes an importer reads 檔案超過匯入增效模組讀取的 {MAX_INPUT_BYTES} 位元組
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -179,7 +179,7 @@ fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool,
|
||||
let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE));
|
||||
let out = ui.add_enabled_ui(row.enabled, |ui| button.atom_ui(ui)).inner;
|
||||
if let Some(rect) = out.rect(slot) {
|
||||
crate::font_menu::menu_item_sample(ui, rect, family);
|
||||
crate::font_menu::menu_item_sample(ui, rect, family, app.session.prefs.font_samples_on_white);
|
||||
}
|
||||
out.response
|
||||
}
|
||||
|
||||
@@ -829,6 +829,7 @@ fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
}
|
||||
"ui.tileEdgeColor" => crate::dialogs::tile_edge_color::open(app),
|
||||
"ui.layerOptions" => crate::dialogs::layer_options::open(app, p),
|
||||
"ui.layerDelete" => crate::panels::layers::delete(app, p),
|
||||
"ui.rename" => crate::panels::layers::rename(app, p),
|
||||
"ui.newLayer" => crate::dialogs::layer_options::open_new(app, p),
|
||||
"ui.newArtboard" => crate::dialogs::artboard_options::open_new(app),
|
||||
|
||||
@@ -946,6 +946,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{ids?|id?} open Layer Options for Layers panel rows (default: the highlighted rows, else the current layer), filled in from the first: dialog `layerOptions`, fields name, color (a preset name such as \"Light Blue\" or #rrggbb), template, locked, visible, printable, preview, dimImages (bool), dimPercent (0–100); an object's row has name, visible and locked. OK runs layer.setProps (one undo step); for several rows it sends only the fields set in the dialog",
|
||||
),
|
||||
(
|
||||
"ui.layerDelete",
|
||||
"Delete Selection",
|
||||
"",
|
||||
"{ids?|id?, doc?, confirmed?: bool} Delete Selection as the Layers panel runs it (its trash button, rows dropped on the trash, its menu, Delete and Backspace), on the rows layer.delete takes (default: the highlighted rows, else the current layer). When a layer or sublayer among them holds art (an object other than a guide; hidden, locked and nested objects count) it asks first: dialog `confirm` with `message`; OK deletes those rows by id with layer.delete (one undo step), Cancel keeps them. OK runs ui.layerDelete again with the rows, `doc` (the document's uid) and `confirmed: true`: with another document active then, nothing is deleted. Other rows (an empty layer, groups and objects alone) and rows layer.delete refuses go to layer.delete at once → layer.delete's {deleted}, or null while the question is open",
|
||||
),
|
||||
(
|
||||
"ui.rename",
|
||||
"Rename",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer. Layers
|
||||
//! dropped in the empty space under the last row go below the bottom layer. Dropped on the trash
|
||||
//! they are deleted, on New Layer duplicated.
|
||||
//! - Deleting rows from the panel (the trash, rows dropped on it, Delete Selection, Delete and
|
||||
//! Backspace) asks first when a layer or sublayer among them holds art ([`delete`]).
|
||||
//! - The triangle opens or closes a row; Alt-click does the same to everything inside it.
|
||||
//! - Double-clicking a name renames it; double-clicking elsewhere on a row opens its options.
|
||||
//! - A target circle targets its layer, group or object (`layer.target`); dragging it onto another
|
||||
@@ -60,7 +62,7 @@ mod rows;
|
||||
mod thumbs;
|
||||
|
||||
use controls::{bottom_bar, filter_button};
|
||||
pub use controls::{expand, menu, set_filter};
|
||||
pub use controls::{delete, expand, menu, set_filter};
|
||||
pub(crate) use model::shown_name;
|
||||
pub use model::{Filter, ObjectKind, RowState};
|
||||
#[cfg(test)]
|
||||
@@ -381,7 +383,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
&& ui.ctx().memory(|m| m.focused().is_none())
|
||||
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace))
|
||||
{
|
||||
out.actions.push(("layer.delete".into(), json!({})));
|
||||
out.actions.push(("ui.layerDelete".into(), json!({})));
|
||||
}
|
||||
bottom_bar(app, ui, &view, &doc, &mut out.actions);
|
||||
for (c, p) in out.actions {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::model::{ObjectKind, RowState, names, parse_names, toggled};
|
||||
use super::{
|
||||
Document, HashSet, LayersDrag, Node, NodeId, NodeKind, PanelDrag, Sense, Stroke, Ui, Value, VectorcraftApp, View, json, last_selected,
|
||||
Arc, Document, HashSet, LayersDrag, Node, NodeId, NodeKind, PanelDrag, Sense, Stroke, Ui, Value, VectorcraftApp, View, json, last_selected,
|
||||
see_selection, vec2, widgets,
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc
|
||||
if view.rows.is_empty() && app.session.active().is_some_and(|d| !d.selection.is_empty()) {
|
||||
actions.push(("edit.clear".into(), json!({})));
|
||||
} else {
|
||||
actions.push(("layer.delete".into(), json!({})));
|
||||
actions.push(("ui.layerDelete".into(), json!({})));
|
||||
}
|
||||
}
|
||||
// Create New Layer: Alt asks for its options first, Ctrl/Cmd puts it on top of every layer,
|
||||
@@ -83,14 +83,14 @@ pub(super) fn bottom_bar(app: &mut VectorcraftApp, ui: &mut Ui, view: &View, doc
|
||||
}
|
||||
// Rows dropped on the trash are deleted, on New Layer duplicated, as in Illustrator. Both are
|
||||
// outlined while rows (or, on the trash, a target circle) hang over them.
|
||||
for (button, cmd) in [(&trash, "layer.delete"), (&new_layer, "layer.duplicate")] {
|
||||
for (button, cmd) in [(&trash, "ui.layerDelete"), (&new_layer, "layer.duplicate")] {
|
||||
if let Some(d) = button.dnd_release_payload::<LayersDrag>()
|
||||
&& let LayersDrag::Rows(ids) = &*d
|
||||
{
|
||||
actions.push((cmd.into(), json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})));
|
||||
}
|
||||
let over = button.dnd_hover_payload::<LayersDrag>().is_some_and(|d| matches!(*d, LayersDrag::Rows(_)))
|
||||
|| (cmd == "layer.delete" && button.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_))));
|
||||
|| (cmd == "ui.layerDelete" && button.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(_))));
|
||||
if over {
|
||||
widgets::drop_target_outline(&child, button.rect);
|
||||
}
|
||||
@@ -143,7 +143,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
Some(("New Layer…", "ui.newLayer", json!({}), true, false)),
|
||||
Some(("New Sublayer…", "ui.newLayer", json!({"sublayer": true}), current.is_some(), false)),
|
||||
Some(("Duplicate Selection", "layer.duplicate", json!({}), !targets.is_empty(), false)),
|
||||
Some(("Delete Selection", "layer.delete", json!({}), !targets.is_empty(), false)),
|
||||
Some(("Delete Selection", "ui.layerDelete", json!({}), !targets.is_empty(), false)),
|
||||
Some(("Options for Selection…", "ui.layerOptions", json!({}), !targets.is_empty(), false)),
|
||||
None,
|
||||
Some(("Make/Release Clipping Mask", "layer.clippingMask.toggle", json!({}), true, false)),
|
||||
@@ -272,6 +272,72 @@ fn save_selection_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `ui.layerDelete {ids?|id?, doc?, confirmed?}`: Delete Selection as the Layers panel runs it (the
|
||||
/// trash button, rows dropped on it, the panel menu, Delete and Backspace), on the rows
|
||||
/// `layer.delete` takes. When a layer or sublayer among them holds art (an object other than a
|
||||
/// guide, hidden, locked and nested ones included), it asks first: OK deletes those rows, by id, in
|
||||
/// the same document (`doc`, its uid, with `confirmed`); with another document active then, nothing
|
||||
/// is deleted. Other rows, and rows `layer.delete` refuses (an unknown id, every top-level layer),
|
||||
/// go to `layer.delete` at once. → `layer.delete`'s result, or null while the question is open
|
||||
pub fn delete(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let confirmed = crate::menus::opt_bool(p, "confirmed")?.unwrap_or(false);
|
||||
let key = "doc";
|
||||
let doc = match p.get(key) {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| format!("`{key}` must be a whole number of 0 or more"))?),
|
||||
};
|
||||
let st = app.session.active().ok_or("no document open")?;
|
||||
if doc.is_some_and(|d| d != st.uid) {
|
||||
return Err("the active document changed while the question was open; nothing was deleted".into());
|
||||
}
|
||||
let rows: serde_json::Map<String, Value> = ["ids", "id"].iter().filter_map(|k| Some((k.to_string(), p.get(*k)?.clone()))).collect();
|
||||
// An id the engine refuses: `layer.delete` gives the error, and deletes nothing.
|
||||
let row = |v: &Value| v.as_u64().map(NodeId).filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_ruler_guide()));
|
||||
let ids: Option<Vec<NodeId>> = match (p.get("ids"), p.get("id")) {
|
||||
(Some(v), _) => v.as_array().and_then(|a| a.iter().map(row).collect()),
|
||||
(None, Some(v)) => row(v).map(|id| vec![id]),
|
||||
(None, None) => {
|
||||
let rows = st.highlighted_rows();
|
||||
Some(if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows })
|
||||
}
|
||||
};
|
||||
let Some(ids) = ids.filter(|_| !confirmed) else { return app.run("layer.delete", Value::Object(rows)) };
|
||||
let listed: HashSet<NodeId> = ids.iter().copied().collect();
|
||||
let mut roots = vec![];
|
||||
row_roots(&st.doc.layers, &listed, &mut roots);
|
||||
let every_layer = st.doc.layers.iter().all(|l| listed.contains(&l.id));
|
||||
if every_layer || !roots.iter().any(|n| n.is_layer() && holds_art(n)) {
|
||||
return app.run("layer.delete", json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
}
|
||||
let message = match roots.as_slice() {
|
||||
[n] if n.is_layer() => tl!("Delete this layer and its artwork?"),
|
||||
_ => tl!("Delete the selected items and their artwork?"),
|
||||
};
|
||||
let params = json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "doc": st.uid, "confirmed": true});
|
||||
crate::dialogs::confirm::ask(app, message, "", "ui.layerDelete", params);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The rows among `nodes` (and the rows inside them) that are in `listed` and not inside another
|
||||
/// listed row, top-level first.
|
||||
fn row_roots<'a>(nodes: &'a [Arc<Node>], listed: &HashSet<NodeId>, out: &mut Vec<&'a Node>) {
|
||||
for n in nodes {
|
||||
if out.len() == listed.len() {
|
||||
return;
|
||||
}
|
||||
if listed.contains(&n.id) {
|
||||
out.push(n);
|
||||
} else if let Some(children) = n.children() {
|
||||
row_roots(children, listed, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a layer holds art: an object other than a guide, in it or in one of its sublayers.
|
||||
fn holds_art(layer: &Node) -> bool {
|
||||
layer.children().into_iter().flatten().any(|c| if c.is_layer() { holds_art(c) } else { !c.is_guide() })
|
||||
}
|
||||
|
||||
/// `ui.layersExpand {ids?, open?}`: open (or close) rows of the active document's Layers panel;
|
||||
/// without `ids`, every row that holds others.
|
||||
pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
|
||||
@@ -452,6 +452,127 @@ fn dragging_rows_onto_the_trash_deletes_them() {
|
||||
assert!(doc.node(top).is_none());
|
||||
}
|
||||
|
||||
/// The message of the confirmation open now, if one is.
|
||||
fn asked(app: &VectorcraftApp) -> Option<String> {
|
||||
app.ui.dialog.as_ref().filter(|d| d.kind == crate::dialogs::confirm::KIND).map(|d| d.str("message"))
|
||||
}
|
||||
|
||||
/// The trash, the Delete key and rows dropped on the trash ask before deleting a layer that holds
|
||||
/// art, hidden and locked art included: Cancel keeps it, and OK deletes the rows it asked about in
|
||||
/// one undo step, even after another row was highlighted.
|
||||
#[test]
|
||||
fn deleting_a_layer_that_holds_art_asks_first() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
|
||||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||||
let rect = NodeId(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].as_u64().unwrap());
|
||||
run(&mut app, "select.none", json!({}));
|
||||
run(&mut app, "layer.setProps", json!({"ids": [rect.0], "visible": false, "locked": true}));
|
||||
let art = app.session.active().unwrap().doc.layers[0].id;
|
||||
run(&mut app, "layer.new", json!({}));
|
||||
let there = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(art).is_some();
|
||||
let ctx = egui::Context::default();
|
||||
// Rows top first: Layer 2, Layer 1, its rectangle.
|
||||
let (c, _) = frame(&mut app, &ctx, vec![], false);
|
||||
let name = |i: usize| egui::pos2(150.0, c[i].y);
|
||||
let question = Some("Delete this layer and its artwork?".to_string());
|
||||
click_with(&mut app, &ctx, name(1), egui::Modifiers::NONE);
|
||||
let trash = trash_pos(&mut app, &ctx);
|
||||
click_with(&mut app, &ctx, trash, egui::Modifiers::NONE);
|
||||
assert_eq!(asked(&app), question, "the trash asks");
|
||||
assert!(there(&app), "nothing is deleted before OK");
|
||||
crate::dialogs::cancel(&mut app);
|
||||
assert!(app.ui.dialog.is_none() && there(&app), "Cancel keeps the layer");
|
||||
click_with(&mut app, &ctx, name(1), egui::Modifiers::NONE);
|
||||
let del = egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
frame(&mut app, &ctx, vec![del], false);
|
||||
assert_eq!(asked(&app), question, "Delete asks");
|
||||
assert!(there(&app));
|
||||
crate::dialogs::cancel(&mut app);
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
drag(&mut app, &ctx, c[1] - vec2(100.0, 0.0), trash, false);
|
||||
assert_eq!(asked(&app), question, "a drop on the trash asks");
|
||||
assert!(there(&app));
|
||||
// Highlighting Layer 2 now leaves OK deleting the row that was dropped.
|
||||
click_with(&mut app, &ctx, name(0), egui::Modifiers::NONE);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(app.ui.dialog.is_none() && st.doc.node(art).is_none() && st.doc.node(rect).is_none());
|
||||
assert_eq!((st.doc.layers.len(), st.history.undo.len()), (1, undo + 1), "one undo step");
|
||||
run(&mut app, "edit.undo", json!({}));
|
||||
assert!(there(&app));
|
||||
}
|
||||
|
||||
/// The panel menu's Delete Selection (`ui.layerDelete`) asks once for several rows when one of
|
||||
/// them is a layer with art, art in a sublayer included; an empty layer, a layer with only a
|
||||
/// guide, and objects alone go at once, and `layer.delete` itself never asks.
|
||||
#[test]
|
||||
fn delete_selection_asks_only_for_layers_with_art() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
|
||||
let id = |v: serde_json::Value| v["id"].as_u64().unwrap();
|
||||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||||
let bottom = app.session.active().unwrap().doc.layers[0].id.0;
|
||||
let loose = id(run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})));
|
||||
let outer = id(run(&mut app, "layer.new", json!({})));
|
||||
let sub = id(run(&mut app, "layer.newSublayer", json!({})));
|
||||
let nested = id(run(&mut app, "shape.ellipse", json!({"x": 50, "y": 50, "width": 20, "height": 20})));
|
||||
run(&mut app, "select.none", json!({}));
|
||||
let empty = id(run(&mut app, "layer.new", json!({"top": true})));
|
||||
let guided = id(run(&mut app, "layer.new", json!({"top": true})));
|
||||
run(&mut app, "guide.add", json!({"vertical": true, "pos": 50, "layer": guided}));
|
||||
run(&mut app, "select.none", json!({}));
|
||||
let exists = |app: &VectorcraftApp, id: u64| app.session.active().unwrap().doc.node(NodeId(id)).is_some();
|
||||
for (rows, what) in [(json!([empty]), "an empty layer"), (json!([guided]), "a layer with only a guide"), (json!([loose]), "an object")] {
|
||||
app.run("ui.layerDelete", json!({ "ids": rows })).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "{what} goes at once");
|
||||
assert!(!exists(&app, rows[0].as_u64().unwrap()), "{what} is deleted");
|
||||
}
|
||||
run(&mut app, "edit.undo", json!({}));
|
||||
// Art in a sublayer counts; the highlighted rows are the default, and a mixed selection asks once.
|
||||
run(&mut app, "layer.highlight", json!({"ids": [outer, loose]}));
|
||||
app.run("ui.layerDelete", json!({})).unwrap();
|
||||
assert_eq!(asked(&app).as_deref(), Some("Delete the selected items and their artwork?"));
|
||||
assert!(exists(&app, nested) && exists(&app, loose));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(!exists(&app, outer) && !exists(&app, sub) && !exists(&app, nested) && !exists(&app, loose));
|
||||
assert!(app.ui.dialog.is_none(), "asked once");
|
||||
// A sublayer with art asks as a layer does.
|
||||
run(&mut app, "edit.undo", json!({}));
|
||||
app.run("ui.layerDelete", json!({ "id": sub })).unwrap();
|
||||
assert_eq!(asked(&app).as_deref(), Some("Delete this layer and its artwork?"));
|
||||
crate::dialogs::cancel(&mut app);
|
||||
// Scripts deleting with layer.delete are not asked.
|
||||
app.run("layer.delete", json!({ "ids": [outer] })).unwrap();
|
||||
assert!(app.ui.dialog.is_none() && !exists(&app, outer));
|
||||
assert!(exists(&app, bottom));
|
||||
}
|
||||
|
||||
/// Rows `layer.delete` refuses are refused without a question, and OK deletes nothing once another
|
||||
/// document is active.
|
||||
#[test]
|
||||
fn delete_selection_refusals_and_a_document_change() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
|
||||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||||
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}));
|
||||
let only = app.session.active().unwrap().doc.layers[0].id.0;
|
||||
let e = app.run("ui.layerDelete", json!({ "ids": [only] })).unwrap_err();
|
||||
assert!(e.contains("at least one layer") && app.ui.dialog.is_none(), "{e}");
|
||||
for bad in [json!({"ids": [999_999]}), json!({"id": "x"}), json!({"ids": 3}), json!({"confirmed": "yes"}), json!({"doc": -1})] {
|
||||
assert!(app.run("ui.layerDelete", bad.clone()).is_err() && app.ui.dialog.is_none(), "{bad}");
|
||||
}
|
||||
let art = run(&mut app, "layer.new", json!({}))["id"].as_u64().unwrap();
|
||||
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}));
|
||||
app.run("ui.layerDelete", json!({ "ids": [art] })).unwrap();
|
||||
assert!(asked(&app).is_some());
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
let e = crate::dialogs::confirm(&mut app).unwrap_err();
|
||||
assert!(e.contains("nothing was deleted"), "{e}");
|
||||
let first = &app.session.documents()[0].doc;
|
||||
assert!(first.node(NodeId(art)).is_some(), "the first document keeps the layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_layer_commits_on_enter_and_escape_cancels() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
@@ -665,9 +786,11 @@ fn rows_at_every_depth_are_clickable() {
|
||||
assert_eq!(rows_of(&app), vec![sub, b, a]);
|
||||
click_with(&mut app, &ctx, name(5), egui::Modifiers::COMMAND);
|
||||
assert_eq!(rows_of(&app), vec![sub, b, a, c]);
|
||||
// Delete with the panel's keyboard: the highlighted rows go.
|
||||
// Delete with the panel's keyboard: the highlighted rows go once OK answers the question the
|
||||
// sublayer's art asks.
|
||||
let del = egui::Event::Key { key: egui::Key::Delete, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
frame(&mut app, &ctx, vec![del], false);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let d = &app.session.active().unwrap().doc;
|
||||
assert!(d.node(sub).is_none() && d.node(c).is_none());
|
||||
assert_eq!(d.layers.len(), 1);
|
||||
|
||||
@@ -783,15 +783,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if app.session.prefs.show_east_asian_options {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, tl!("Mojikumi Set"));
|
||||
let sets = [tl!("None"), tl!("Line-end Punctuation Half Width")];
|
||||
let current = match shared_para(app, |p| p.mojikumi) {
|
||||
Some(Mojikumi::LineEndHalf) => sets[1],
|
||||
Some(Mojikumi::None) => sets[0],
|
||||
None => "",
|
||||
};
|
||||
let sets = mojikumi_sets();
|
||||
let names = sets.map(|s| s.0);
|
||||
let current = shared_para(app, |p| p.mojikumi).and_then(|m| sets.iter().find(|s| s.1 == m).map(|s| s.0)).unwrap_or("");
|
||||
// Already translated: shown as they are.
|
||||
if let Some(i) = widgets::dropdown_names(ui, "pa-mojikumi", current, &sets, ui.available_width() - 4.0) {
|
||||
format(app, json!({"mojikumi": if i == 1 { "lineEndHalf" } else { "none" }}));
|
||||
if let Some(i) = widgets::dropdown_names(ui, "pa-mojikumi", current, &names, ui.available_width() - 4.0)
|
||||
&& let Some((_, _, key)) = sets.get(i)
|
||||
{
|
||||
format(app, json!({"mojikumi": key}));
|
||||
}
|
||||
});
|
||||
// Which characters may not start or end a line.
|
||||
@@ -909,6 +908,20 @@ fn set_kinsoku_type(app: &mut VectorcraftApp, key: &str) {
|
||||
format(app, json!({"kinsokuType": key}));
|
||||
}
|
||||
|
||||
/// The Mojikumi Set menu's entries, translated: each one's label, its set and its `mojikumi` value
|
||||
/// for `text.setFormat`. The five predefined sets come first, then Line-end Punctuation Half Width,
|
||||
/// the set new type gets.
|
||||
fn mojikumi_sets() -> [(&'static str, Mojikumi, &'static str); 6] {
|
||||
[
|
||||
(tl!("None"), Mojikumi::None, "none"),
|
||||
(tl!("YakumonoHankaku"), Mojikumi::YakumonoHankaku, "yakumonoHankaku"),
|
||||
(tl!("GyomatsuYakumonoHankaku"), Mojikumi::GyomatsuYakumonoHankaku, "gyomatsuYakumonoHankaku"),
|
||||
(tl!("GyomatsuYakumonoZenkaku"), Mojikumi::GyomatsuYakumonoZenkaku, "gyomatsuYakumonoZenkaku"),
|
||||
(tl!("YakumonoZenkaku"), Mojikumi::YakumonoZenkaku, "yakumonoZenkaku"),
|
||||
(tl!("Line-end Punctuation Half Width"), Mojikumi::LineEndHalf, "lineEndHalf"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Bunri-Kinshi's menu item for the paragraphs the panel acts on ([`format`]): (available, checked).
|
||||
/// It is available when none of them has kinsoku None, and checked when every one of them has it.
|
||||
fn bunri_kinshi_item(app: &VectorcraftApp) -> (bool, bool) {
|
||||
|
||||
@@ -107,6 +107,45 @@ fn paragraph_mixed_kinsoku_and_mojikumi_show_blank_dropdowns() {
|
||||
assert_eq!(shared_para(&app, |p| p.mojikumi), Some(Mojikumi::LineEndHalf));
|
||||
}
|
||||
|
||||
/// The Mojikumi Set menu lists None, the four other predefined sets and Line-end Punctuation Half
|
||||
/// Width, each under its own label in every interface language. Each entry sets the selected
|
||||
/// paragraphs' set in one undo step and is shown as the current set; paragraphs with different
|
||||
/// sets show a blank menu, and Reset Panel gives them Line-end Punctuation Half Width.
|
||||
#[test]
|
||||
fn mojikumi_menu_offers_and_sets_every_predefined_set() {
|
||||
let keys: Vec<&str> = mojikumi_sets().iter().map(|s| s.2).collect();
|
||||
assert_eq!(keys, ["none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku", "lineEndHalf"]);
|
||||
for lang in crate::i18n::Lang::all() {
|
||||
crate::i18n::set_current(lang);
|
||||
let labels: std::collections::BTreeSet<&str> = mojikumi_sets().iter().map(|s| s.0).collect();
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
assert_eq!(labels.len(), 6, "{}: {labels:?}", lang.code());
|
||||
}
|
||||
let (mut app, id) = editing_text("「あ」。\nい、");
|
||||
app.session.prefs.show_east_asian_options = true;
|
||||
app.session.set_tool_option("selectAll", &json!(true));
|
||||
let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
|
||||
for (_, set, key) in mojikumi_sets() {
|
||||
app.session.set_tool_option("selectAll", &json!(true));
|
||||
let before = (shared_para(&app, |p| p.mojikumi), steps(&app));
|
||||
format(&mut app, json!({"mojikumi": key}));
|
||||
assert_eq!(shared_para(&app, |p| p.mojikumi), Some(set), "{key}");
|
||||
if before.0 == Some(set) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(steps(&app), before.1 + 1, "{key}: one undo step");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(shared_para(&app, |p| p.mojikumi), before.0, "{key}: undone");
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
}
|
||||
app.run("text.setFormat", json!({"id": id, "start": 0, "end": 0, "mojikumi": "yakumonoHankaku"})).unwrap();
|
||||
app.run("text.setFormat", json!({"id": id, "start": 13, "end": 13, "mojikumi": "gyomatsuYakumonoZenkaku"})).unwrap();
|
||||
app.session.set_tool_option("selectAll", &json!(true));
|
||||
assert_eq!(shared_para(&app, |p| p.mojikumi), None, "mixed sets");
|
||||
reset_panel(&mut app);
|
||||
assert_eq!(shared_para(&app, |p| p.mojikumi), Some(Mojikumi::LineEndHalf), "Reset Panel");
|
||||
}
|
||||
|
||||
/// Kinsoku Shori Type: the menu's item sets the type of the paragraphs the Type tool's selection
|
||||
/// touches in one undo step, and none is checked while the selected paragraphs differ.
|
||||
#[test]
|
||||
|
||||
@@ -519,6 +519,11 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
|
||||
PrefKind::Bool if sp.key == "animatedZoom" => {
|
||||
ui.add_enabled_ui(d.bool("gpuPerformance"), |ui| bool_row(ui, d, sp.key, sp.label));
|
||||
}
|
||||
// Type › Show samples on white draws the samples that Enable in-menu font previews
|
||||
// shows: dimmed while previews are off.
|
||||
PrefKind::Bool if sp.key == "fontSamplesOnWhite" => {
|
||||
ui.add_enabled_ui(d.bool("fontPreview"), |ui| bool_row(ui, d, sp.key, sp.label));
|
||||
}
|
||||
// Windows and Linux only: macOS always has the system's title bar, the web has none.
|
||||
PrefKind::Bool if sp.key == "systemTitleBar" => {
|
||||
if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) {
|
||||
|
||||
@@ -962,6 +962,44 @@ fn scroll_sideways_with_cmd_wheel_dims_with_zoom_with_mouse_wheel() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type › Show samples on white: its checkbox is dimmed while Enable in-menu font previews is
|
||||
/// unchecked in the dialog, and a click on it then leaves the setting as it was.
|
||||
#[test]
|
||||
fn show_samples_on_white_dims_without_in_menu_font_previews() {
|
||||
fn label(s: &egui::Shape, text: &str) -> Option<(egui::Color32, egui::Rect)> {
|
||||
match s {
|
||||
egui::Shape::Text(t) if t.galley.text() == text => t.galley.job.sections.first().map(|s| (s.format.color, t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().find_map(|s| label(s, text)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut a = app();
|
||||
let mut frame = |d: &mut Dialog, events: Vec<egui::Event>| {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 1400.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| category_fields(ui, &mut a, d, "Type"));
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().find_map(|c| label(&c.shape, "Show samples on white"))
|
||||
};
|
||||
let t = Tokens::get(&ctx);
|
||||
for (preview, color, what) in [(true, t.text, "enabled while previews are on"), (false, t.text_disabled, "dimmed while they are off")] {
|
||||
let mut d = Dialog::new("preferences", json!({"fontPreview": preview, "fontSamplesOnWhite": false}));
|
||||
let (shown, at) = frame(&mut d, vec![]).unwrap_or_else(|| panic!("no label ({what})"));
|
||||
assert_eq!(shown, color, "{what}");
|
||||
let press =
|
||||
|pressed| egui::Event::PointerButton { pos: at.center(), button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut d, vec![egui::Event::PointerMoved(at.center()), press(true)]);
|
||||
frame(&mut d, vec![press(false)]);
|
||||
// A click turns it on while it is enabled, and leaves it off while it is dimmed.
|
||||
assert_eq!(d.fields["fontSamplesOnWhite"], json!(preview), "after a click, {what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 0.5.0 saved `powerSaving` for everyone (its default): it reads as Automatic, while the
|
||||
/// choices this version offers are kept (#502).
|
||||
#[test]
|
||||
|
||||
@@ -961,10 +961,14 @@ fn a_preview_stays_put_while_the_menu_is_still() {
|
||||
}
|
||||
|
||||
/// What an open font menu drew: each text with its rectangle and clip rectangle, the rectangles of
|
||||
/// the samples, and the menu's rectangle.
|
||||
/// the samples, each sample's tint, the filled rectangles with their fill, each text's color, and
|
||||
/// the menu's rectangle.
|
||||
struct Drawn {
|
||||
texts: Vec<(String, egui::Rect, egui::Rect)>,
|
||||
samples: Vec<egui::Rect>,
|
||||
tints: Vec<egui::Color32>,
|
||||
fills: Vec<(egui::Rect, egui::Color32)>,
|
||||
text_colors: Vec<(String, egui::Color32)>,
|
||||
menu: egui::Rect,
|
||||
}
|
||||
|
||||
@@ -996,14 +1000,22 @@ impl Drawn {
|
||||
/// size `screen`, draws it until it settles and its samples are rendered, and returns what the
|
||||
/// last frame drew.
|
||||
fn open_menu(id: &str, left: f32, width: f32, screen: egui::Vec2) -> Drawn {
|
||||
open_menu_with(id, left, width, screen, MenuLook::default(), None)
|
||||
}
|
||||
|
||||
/// [`open_menu`] with the menu's look `look`, in the interface colors `tokens` when given.
|
||||
fn open_menu_with(id: &str, left: f32, width: f32, screen: egui::Vec2, look: MenuLook, tokens: Option<crate::theme::Tokens>) -> Drawn {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
if let Some(t) = tokens {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
|
||||
}
|
||||
let field = egui::Rect::from_min_size(egui::pos2(left, 10.0), egui::vec2(width, 30.0));
|
||||
let frame = |events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen)), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(field), |ui| {
|
||||
font_menu(ui, id, "Inter", width, None, MenuLook::default());
|
||||
font_menu(ui, id, "Inter", width, None, look);
|
||||
});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
@@ -1014,21 +1026,33 @@ fn open_menu(id: &str, left: f32, width: f32, screen: egui::Vec2) -> Drawn {
|
||||
frame(vec![]);
|
||||
frame(vec![egui::Event::PointerMoved(click), button(true)]);
|
||||
frame(vec![button(false)]);
|
||||
for _ in 0..5 {
|
||||
// With interface colors given, enough frames for the menu to finish fading in, so the
|
||||
// samples' tints are whole.
|
||||
for _ in 0..if tokens.is_some() { 15 } else { 5 } {
|
||||
frame(vec![]);
|
||||
}
|
||||
crate::font_menu::wait_for_samples(&ctx);
|
||||
let shapes = frame(vec![]);
|
||||
let mut texts = vec![];
|
||||
let mut samples = vec![];
|
||||
let mut tints = vec![];
|
||||
let mut fills = vec![];
|
||||
let mut text_colors = vec![];
|
||||
for s in &shapes {
|
||||
match &s.shape {
|
||||
egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()), s.clip_rect)),
|
||||
egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => samples.push(m.calc_bounds()),
|
||||
egui::Shape::Text(t) => {
|
||||
texts.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()), s.clip_rect));
|
||||
text_colors.push((t.galley.text().to_string(), t.fallback_color));
|
||||
}
|
||||
egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => {
|
||||
samples.push(m.calc_bounds());
|
||||
tints.push(m.vertices.first().map_or(egui::Color32::TRANSPARENT, |v| v.color));
|
||||
}
|
||||
egui::Shape::Rect(r) => fills.push((r.rect, r.fill)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut drawn = Drawn { texts, samples, menu: egui::Rect::NOTHING };
|
||||
let mut drawn = Drawn { texts, samples, tints, fills, text_colors, menu: egui::Rect::NOTHING };
|
||||
let filter = drawn.text(crate::i18n::t("Any Kind")).center();
|
||||
drawn.menu = ctx.layer_id_at(filter).and_then(|l| ctx.memory(|m| m.area_rect(l.id))).expect("the menu is open");
|
||||
drawn
|
||||
@@ -1098,3 +1122,74 @@ fn samples_fill_their_rows_height() {
|
||||
assert!((s.height() - row).abs() < 0.5, "a sample {} high in a row {row} high", s.height());
|
||||
}
|
||||
}
|
||||
|
||||
/// Show samples on white: in every brightness each sample is drawn in the Light brightness's
|
||||
/// text color on a white area, with the row's highlight above and below it, while the families'
|
||||
/// names keep the interface's text color. Off
|
||||
/// (the default), the samples take the interface's text color with nothing white behind them.
|
||||
#[test]
|
||||
fn show_samples_on_white_draws_dark_type_on_white_in_every_brightness() {
|
||||
use crate::theme::{Brightness, Tokens};
|
||||
let dark = Tokens::for_brightness(Brightness::Light).text;
|
||||
let families = FontDb::global().menu_family_list();
|
||||
for b in Brightness::ALL {
|
||||
let t = Tokens::for_brightness(b);
|
||||
for on_white in [false, true] {
|
||||
let look = MenuLook { on_white, ..Default::default() };
|
||||
let d = open_menu_with(&format!("test-on-white-{}-{on_white}", b.id()), 20.0, 260.0, egui::vec2(1200.0, 800.0), look, Some(t));
|
||||
assert!(!d.samples.is_empty(), "{}: samples are drawn", b.id());
|
||||
for (sample, tint) in d.samples.iter().zip(&d.tints) {
|
||||
let on_paper = d.fills.iter().any(|(r, fill)| *fill == egui::Color32::WHITE && r.expand(0.5).contains_rect(*sample));
|
||||
if on_white {
|
||||
assert_eq!(*tint, dark, "{}: a sample on white is dark", b.id());
|
||||
assert!(on_paper, "{}: the sample at {sample:?} is on white", b.id());
|
||||
} else {
|
||||
assert_eq!(*tint, t.text, "{}: a sample takes the interface's text color", b.id());
|
||||
assert!(!on_paper, "{}: nothing white behind the sample at {sample:?}", b.id());
|
||||
}
|
||||
}
|
||||
// The current family's row opens highlighted; on white, the highlight shows above and
|
||||
// below its paper.
|
||||
let lit = d.fills.iter().find(|(r, fill)| *fill == t.selection && d.menu.contains_rect(*r)).map(|(r, _)| *r);
|
||||
let lit = lit.unwrap_or_else(|| panic!("{}: a row is highlighted", b.id()));
|
||||
let paper = d.fills.iter().find(|(r, fill)| *fill == egui::Color32::WHITE && lit.contains_rect(*r)).map(|(r, _)| *r);
|
||||
if on_white {
|
||||
let paper = paper.unwrap_or_else(|| panic!("{}: the highlighted row has paper", b.id()));
|
||||
let inset = crate::font_menu::PAPER_INSET;
|
||||
assert!(
|
||||
(paper.top() - lit.top() - inset).abs() < 0.01 && (lit.bottom() - paper.bottom() - inset).abs() < 0.01,
|
||||
"{}: {paper:?} in {lit:?}",
|
||||
b.id()
|
||||
);
|
||||
} else {
|
||||
assert_eq!(paper, None, "{}: no paper in the highlighted row", b.id());
|
||||
}
|
||||
// The rows' names, not the closed field's.
|
||||
let names: Vec<_> = d
|
||||
.text_colors
|
||||
.iter()
|
||||
.zip(&d.texts)
|
||||
.filter(|((name, _), (_, r, _))| d.menu.contains_rect(*r) && families.iter().any(|f| f == name))
|
||||
.map(|(named, _)| named)
|
||||
.collect();
|
||||
assert!(!names.is_empty(), "{}: families are listed", b.id());
|
||||
assert!(names.iter().all(|(_, c)| *c == t.text), "{}: the names keep the interface's text color: {names:?}", b.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show samples on white is a Type preference, off by default, saved with the other preferences
|
||||
/// and passed to the font menus.
|
||||
#[test]
|
||||
fn show_samples_on_white_is_a_saved_preference_the_menus_follow() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), crate::Services::default());
|
||||
assert!(!MenuLook::of(&app).on_white, "off by default");
|
||||
assert!(app.run("prefs.set", json!({"key": "fontSamplesOnWhite", "value": 1})).is_err(), "a value that is not a boolean is refused");
|
||||
assert!(!app.session.prefs.font_samples_on_white);
|
||||
app.run("prefs.set", json!({"key": "fontSamplesOnWhite", "value": true})).unwrap();
|
||||
assert!(MenuLook::of(&app).on_white);
|
||||
let saved: vectorcraft_engine::Prefs = serde_json::from_str(&serde_json::to_string(&app.session.prefs).unwrap()).unwrap();
|
||||
assert!(saved.font_samples_on_white, "saved and read back");
|
||||
let older: vectorcraft_engine::Prefs = serde_json::from_str("{}").unwrap();
|
||||
assert!(!older.font_samples_on_white, "preferences saved before it read as off");
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@ of another kind, it moves.
|
||||
|
||||
Confirmations: deleting swatches from the Swatches panel opens a `confirm` dialog (fields `message`, `detail`);
|
||||
`ui.dialog.confirm` runs the command it asks about (here `swatch.delete`) and `ui.dialog.cancel` drops it.
|
||||
Deleting Layers panel rows that include a layer or sublayer with art (`engine.execute` of `ui.layerDelete`, which the
|
||||
panel's trash, its menu's Delete Selection and Delete or Backspace run) opens a `confirm` dialog too; its OK deletes
|
||||
those rows with `layer.delete` in the document the question was asked in. `layer.delete` itself deletes at once.
|
||||
|
||||
New swatches and colour groups: `ui.newSwatch {spot?, global?, group?}` opens the `newSwatch` dialog prefilled from the active
|
||||
fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern only takes `name`) and
|
||||
|
||||
@@ -21,7 +21,7 @@ Ordinary File > Save offers Save As when its own location refuses writes with a
|
||||
|
||||
| | Illustrator | VectorCraft | Notes |
|
||||
|---|---:|---:|---|
|
||||
| Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS |
|
||||
| Formats opened or placed | ~30 | 31 | missing: FreeHand, CorelDRAW, DWG, PS |
|
||||
| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) |
|
||||
| Formats exported | ~16 | 18 | missing: DWG; extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD |
|
||||
| **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting |
|
||||
@@ -285,7 +285,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
|
||||
| HEIC / HEIF | open, place | yes (HEVC-coded still images: single pictures and grids of tiles, 8 to 10 bits, with an alpha image; image sequences, other codings and grids of cropped tiles are refused) | no | `.heic`, `.heif`; decoded by heic-rs in the engine's default `heif` feature (a build without it refuses the files with a message); the container's rotation, mirror and crop are applied; without an `nclx` box the HEVC stream's video signal type gives the matrix and range; an RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; EXIF resolution tags size the image, or 72 ppi; bounded decoding; `tests_heifread.rs` |
|
||||
| PCX | open, place | yes (1 bit in the header palette's first two colors, black and white without them; 1 bit in two to four planes and 4 bits with up to 16 colors; 2 bits in the CGA colors the header's CGA palette settings pick; 8 bits with the 256-color palette, gray without one; 24-bit color in three planes) | no | the header's HDpi and VDpi size the image; a file of another depth or plane count, cut short or past the pixel allowance is refused with a message; tests in `crates/engine/src/cmd/fileio/tests_pcxread.rs` and `crates/engine/tests/import_fuzz.rs` |
|
||||
| JPEG 2000 | open, place | yes (JP2 and raw codestream images; multiple codestreams and JPX composition or layers are refused) | no | `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c`, `.jpc`; resolution uses the valid `resd` (display) value on each axis, or else the valid `resc` (capture) value, or 72 ppi; bounded decoding; `tests_jp2read.rs` |
|
||||
| PICT | open, place (legacy) | no | no | rarely used |
|
||||
| PICT | open, place (legacy) | yes (versions 1 and 2: shapes, lines, regions, text, bitmaps, pixel maps and QuickTime JPEG images; placed as one group clipped to the picture's frame) | no | `.pct`, `.pic` and `.pict`; a version 2 picture also opens by its content; a two-color pattern fills with its average color; inverted shapes, transfer modes other than a copy and QuickTime images in other formats come back as warnings; a picture cut short keeps what came before it; tests in `crates/metafile/src/tests/pict.rs`, `crates/engine/src/cmd/fileio/tests_pict.rs` and `crates/engine/tests/import_fuzz.rs` |
|
||||
| Pixar PXR | open, place (legacy) | yes (any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded, in tiles; R alone is gray; alpha matted to black or unassociated) | no | recognized by its magic number; 12-bit samples are clamped to 0.0 to 1.0; null tiles are transparent with A and black without; the offsets aren't read, and the image takes the default resolution; a file cut short, with header values or packets the format doesn't define, or past the pixel allowance is refused with a message; tested on synthetic files only; tests in `crates/engine/src/cmd/fileio/tests_pxrread.rs` and `crates/engine/tests/import_fuzz.rs` |
|
||||
| FreeHand `.fh*` | open (legacy) | no | no | |
|
||||
| CorelDRAW `.cdr` | open | no | no | |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user