mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 23:05:05 +00:00
Merge main into fix/artboard-files-ask-before-replacing
This commit is contained in:
@@ -126,6 +126,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged.
|
||||
- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged.
|
||||
- 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`).
|
||||
- 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged.
|
||||
- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923).
|
||||
- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922).
|
||||
- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925).
|
||||
|
||||
@@ -156,6 +156,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[
|
||||
BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules },
|
||||
BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn },
|
||||
BuiltinStyleLibrary { id: "textures", name: "Textures", make: textures },
|
||||
BuiltinStyleLibrary { id: "painted-sketched", name: "Painted and Sketched", make: painted_sketched },
|
||||
BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes },
|
||||
BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects },
|
||||
BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency },
|
||||
@@ -323,6 +324,59 @@ fn hand_drawn() -> Vec<GraphicStyle> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Painted and Sketched: one catalog paint or sketch effect over the whole appearance, so it
|
||||
/// repaints the fill and the stroke together (watercolor, dry brush, pastels, charcoal and ink).
|
||||
fn painted_sketched() -> Vec<GraphicStyle> {
|
||||
vec![
|
||||
style(
|
||||
"Washed Blue",
|
||||
vec![F(fill("#8ab9c9"))],
|
||||
vec![fx("artistic.watercolor", json!({"brushDetail": 8.0, "shadowIntensity": 2.0, "texture": 2.0}))],
|
||||
),
|
||||
style(
|
||||
"Bristle Ochre",
|
||||
vec![F(fill("#c4975a")), S(stroke("#76533d", 1.0))],
|
||||
vec![fx("artistic.dryBrush", json!({"brushSize": 4.0, "brushDetail": 7.0, "texture": 2.0}))],
|
||||
),
|
||||
style(
|
||||
"Pastel Heather",
|
||||
vec![F(fill("#a78ca5"))],
|
||||
vec![fx(
|
||||
"artistic.roughPastels",
|
||||
json!({
|
||||
"strokeLength": 8.0,
|
||||
"strokeDetail": 9.0,
|
||||
"texture": "canvas",
|
||||
"scaling": 100.0,
|
||||
"relief": 5.0,
|
||||
"lightDirection": "topLeft",
|
||||
"invert": false
|
||||
}),
|
||||
)],
|
||||
),
|
||||
style(
|
||||
"Charcoal Shade",
|
||||
vec![F(fill("#666666"))],
|
||||
vec![fx("sketch.charcoal", json!({"thickness": 3.0, "detail": 3.0, "lightDarkBalance": 55.0}))],
|
||||
),
|
||||
style(
|
||||
"Ink Contour",
|
||||
vec![F(fill("#d9e2df")), S(stroke("#26323b", 1.0))],
|
||||
vec![fx("brushStrokes.inkOutlines", json!({"strokeLength": 6.0, "darkIntensity": 24.0, "lightIntensity": 8.0}))],
|
||||
),
|
||||
style(
|
||||
"Wet Ink",
|
||||
vec![F(fill("#637b85"))],
|
||||
vec![fx("brushStrokes.sumiE", json!({"strokeWidth": 7.0, "strokePressure": 5.0, "contrast": 20.0}))],
|
||||
),
|
||||
style(
|
||||
"Fine Pen",
|
||||
vec![F(fill("#d8d4c6"))],
|
||||
vec![fx("sketch.graphicPen", json!({"strokeLength": 6.0, "lightDarkBalance": 55.0, "strokeDirection": "rightDiagonal"}))],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Textures: fills and strokes painted with the catalog's texture effects (grain, canvas and
|
||||
/// burlap surfaces, craquelure, tiles, patchwork, stained glass and water paper).
|
||||
fn textures() -> Vec<GraphicStyle> {
|
||||
@@ -603,6 +657,36 @@ mod tests {
|
||||
assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painted_and_sketched_follows_textures_with_one_effect_per_style() {
|
||||
let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect();
|
||||
let at = ids.iter().position(|&id| id == "painted-sketched").expect("listed");
|
||||
assert_eq!(ids[at - 1], "textures");
|
||||
let lib = builtin_style_library("painted-sketched").unwrap();
|
||||
assert_eq!(lib.name, "Painted and Sketched");
|
||||
let names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(names, ["Washed Blue", "Bristle Ochre", "Pastel Heather", "Charcoal Shade", "Ink Contour", "Wet Ink", "Fine Pen"]);
|
||||
let effects: Vec<&str> = lib.styles.iter().flat_map(|g| g.appearance.effects.iter().map(|e| e.id.as_str())).collect();
|
||||
assert_eq!(
|
||||
effects,
|
||||
[
|
||||
"artistic.watercolor",
|
||||
"artistic.dryBrush",
|
||||
"artistic.roughPastels",
|
||||
"sketch.charcoal",
|
||||
"brushStrokes.inkOutlines",
|
||||
"brushStrokes.sumiE",
|
||||
"sketch.graphicPen"
|
||||
]
|
||||
);
|
||||
assert!(lib.styles.iter().all(|g| g.appearance.items.iter().all(|i| i.effects().is_empty())), "no effects on a fill or stroke");
|
||||
// No other built-in library holds a style of the same name.
|
||||
for b in STYLE_LIBRARIES.iter().filter(|b| b.id != "painted-sketched") {
|
||||
let other = builtin_style_library(b.id).unwrap();
|
||||
assert!(other.styles.iter().all(|g| !names.contains(&g.name.as_str())), "{}", b.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vcstyles_round_trip() {
|
||||
let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone();
|
||||
|
||||
@@ -28,6 +28,15 @@ pub struct TabStop {
|
||||
pub align_on: char,
|
||||
}
|
||||
|
||||
impl TabStop {
|
||||
/// The largest stop position `text.tabs.set` and a paragraph style take, in points.
|
||||
pub const MAX_POSITION: f64 = 100_000.0;
|
||||
/// The most leader characters a stop repeats.
|
||||
pub const MAX_LEADER: usize = 8;
|
||||
/// The most stops `text.tabs.set` and a paragraph style take in one list.
|
||||
pub const MAX_STOPS: usize = 256;
|
||||
}
|
||||
|
||||
fn default_align_on() -> char {
|
||||
'.'
|
||||
}
|
||||
|
||||
@@ -507,6 +507,23 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Opt
|
||||
Some(v) => Ok(Some(vec![checked_id(s, v, command)?])),
|
||||
}
|
||||
}
|
||||
/// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form:
|
||||
/// for a command that looks its objects up itself, outside the document's layers too.
|
||||
pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result<Option<Vec<NodeId>>> {
|
||||
if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
return ids_param(p, "ids", command);
|
||||
}
|
||||
match p.get("id").filter(|v| !v.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(r)) if r.starts_with('$') => {
|
||||
Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
|
||||
}
|
||||
Some(v) => {
|
||||
let id = v.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {v}: expected a non-negative integer")))?;
|
||||
Ok(Some(vec![id]))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list.
|
||||
pub(crate) fn index(v: u64) -> usize {
|
||||
usize::try_from(v).unwrap_or(usize::MAX)
|
||||
|
||||
@@ -33,8 +33,8 @@ mod symbols;
|
||||
|
||||
pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint};
|
||||
use brushes::{
|
||||
brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove,
|
||||
brush_selection_art, brush_set_current, brush_unused, expand_brush,
|
||||
brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art,
|
||||
brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush,
|
||||
};
|
||||
pub(crate) use dynamic::{edit_part, part_only, selected_part};
|
||||
use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance};
|
||||
@@ -67,6 +67,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
brush_selection_art
|
||||
),
|
||||
cmd!(
|
||||
"brush.placeArt",
|
||||
"Place Brush Art",
|
||||
[],
|
||||
None,
|
||||
"{name, x?, y?} place a copy of the art a scatter, art or pattern brush is made of, centered on (x, y) (default: the center of the first artboard), and select it, as dragging the brush from the Brushes panel onto an empty part of the canvas does; a pattern brush places its tiles left to right (side, outer corner, inner corner, start, end; the ones it has art for) 10 pt apart, each in a group named Side Tile, Outer Corner Tile, Inner Corner Tile, Start Tile or End Tile, with an unpainted rectangle at the back of a tile that has a repeat frame marking that frame; a calligraphic or bristle brush is refused → {id}",
|
||||
has_doc,
|
||||
brush_place_art
|
||||
),
|
||||
cmd!(
|
||||
"brush.apply",
|
||||
"Apply Brush",
|
||||
|
||||
@@ -483,6 +483,93 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": out }))
|
||||
}
|
||||
|
||||
/// The name `brush.placeArt` gives each pattern brush tile it places, by tile, in the order it
|
||||
/// places them.
|
||||
const TILE_NAMES: [&str; 5] = ["Side Tile", "Outer Corner Tile", "Inner Corner Tile", "Start Tile", "End Tile"];
|
||||
|
||||
/// The space between the pattern brush tiles `brush.placeArt` places, in points.
|
||||
const TILE_GAP: f64 = 10.0;
|
||||
|
||||
/// The message for a brush that is not made of art.
|
||||
fn no_art(name: &str) -> String {
|
||||
format!("`{name}` has no art (only scatter, art and pattern brushes do)")
|
||||
}
|
||||
|
||||
/// Tile `i` of `p` ([`TILE_NAMES`] order): its art and its repeat frame, when it has art.
|
||||
fn tile(p: &brush::PatternBrush, i: usize) -> Option<(&Node, Option<vectorcraft_geom::Rect>)> {
|
||||
match i {
|
||||
0 => Some((&p.side, p.side_frame)),
|
||||
1 => p.outer_corner.as_ref().map(|n| (n, p.outer_corner_frame)),
|
||||
2 => p.inner_corner.as_ref().map(|n| (n, p.inner_corner_frame)),
|
||||
3 => p.start.as_ref().map(|n| (n, p.start_frame)),
|
||||
_ => p.end.as_ref().map(|n| (n, p.end_frame)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The art `brush.placeArt` places for brush `b`, in the brush's own coordinates: a scatter or art
|
||||
/// brush's art, or a pattern brush's tiles in a row, each in a group named for its tile
|
||||
/// ([`TILE_NAMES`]), with an unpainted rectangle at the back of a tile that has a repeat frame
|
||||
/// marking that frame. `None` for a brush that is not made of art. Symbol instances in the art are
|
||||
/// measured by their symbols' art in `symbols`.
|
||||
fn placed_art(b: &Brush, symbols: &vectorcraft_doc::SymbolBoxes) -> Option<Node> {
|
||||
match &b.kind {
|
||||
BrushKind::Scatter(sc) => Some(sc.art.clone()),
|
||||
BrushKind::Art(a) => Some(a.art.clone()),
|
||||
BrushKind::Pattern(pb) => {
|
||||
let mut tiles = vec![];
|
||||
let mut x = 0.0;
|
||||
for (i, tile_name) in TILE_NAMES.iter().enumerate() {
|
||||
let Some((art, frame)) = tile(pb, i) else { continue };
|
||||
let mut children = vec![];
|
||||
if let Some(f) = frame {
|
||||
let marker = Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::shapes::rectangle(f),
|
||||
vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0),
|
||||
);
|
||||
children.push(Arc::new(marker));
|
||||
}
|
||||
children.push(Arc::new(art.clone()));
|
||||
let mut g = Node::group(NodeId(0), children);
|
||||
g.name = Some((*tile_name).to_string());
|
||||
let Some(r) = g.geometric_bounds_with(symbols) else { continue };
|
||||
// Each tile sits right of the one before it, centered on the line y = 0.
|
||||
g.transform(vectorcraft_geom::Affine::translate((x - r.x0, -r.center().y)), false);
|
||||
x += r.width() + TILE_GAP;
|
||||
tiles.push(Arc::new(g));
|
||||
}
|
||||
Some(Node::group(NodeId(0), tiles))
|
||||
}
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `brush.placeArt`: a copy of the art brush `name` is made of, centered on (`x`, `y`) (default: the
|
||||
/// center of the first artboard) in the layer new art goes into, and selected, as one undo step.
|
||||
/// Scatter and art brushes place their art; pattern brushes place their tiles ([`placed_art`]).
|
||||
pub(super) fn brush_place_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "brush.placeArt";
|
||||
let name = name_param(p, C)?.to_string();
|
||||
let d = &s.doc()?.doc;
|
||||
let center = d.artboards.first().map(|a| a.rect.center()).unwrap_or(super::Point::ZERO);
|
||||
let at = super::Point::new(super::number_or(p, "x", center.x, C)?, super::number_or(p, "y", center.y, C)?);
|
||||
let b = brush::find(d, &name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
|
||||
let symbols = d.symbol_boxes();
|
||||
let mut art = placed_art(&b, &symbols).ok_or_else(|| bad(C, no_art(&name)))?;
|
||||
if let Some(r) = art.geometric_bounds_with(&symbols) {
|
||||
art.transform(vectorcraft_geom::Affine::translate(at - r.center()), false);
|
||||
}
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let id = s.edit("Place Brush Art", |d, sel| {
|
||||
let art = d.reid(&art);
|
||||
let id = art.id;
|
||||
d.insert(parent, usize::MAX, art)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?);
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Document Info panel data, its categories and the text report (Document Info › Save…, File →
|
||||
//! Package), and Object → Make Pixel Perfect.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Info",
|
||||
["Window", "Document Info"],
|
||||
None,
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
|
||||
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
@@ -358,6 +358,16 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut images: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut image_rows: Vec<ImageRow> = vec![];
|
||||
// Whether the request being served may read each linked file (a relative path read from the
|
||||
// document's folder), checked once per file.
|
||||
let mut readable: HashMap<String, bool> = HashMap::new();
|
||||
let link_doc = super::links::LinkBase::of(st).doc;
|
||||
let mut may_read = |stored: &str| {
|
||||
*readable.entry(stored.to_string()).or_insert_with(|| {
|
||||
let resolved = super::fileio::document_path(stored, link_doc).ok();
|
||||
crate::file_access::check_read(resolved.as_deref().unwrap_or(stored)).is_ok()
|
||||
})
|
||||
};
|
||||
let mut patterns = BTreeSet::new();
|
||||
let mut symbols = BTreeSet::new();
|
||||
let mut spot = BTreeSet::new();
|
||||
@@ -411,12 +421,15 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("textObjects")
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
// A linked file outside a confined request's read roots: `link` is null, and the row
|
||||
// names the file alone.
|
||||
let link = im.link.as_ref().map(|l| (l, may_read(&l.path)));
|
||||
images.insert(
|
||||
im.key.clone(),
|
||||
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }),
|
||||
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": link.and_then(|(l, ok)| ok.then_some(&l.path)) }),
|
||||
);
|
||||
let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string());
|
||||
image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone())));
|
||||
image_rows.push((name, im.width, im.height, link.map(|(l, ok)| if ok { l.path.clone() } else { l.name().to_string() })));
|
||||
Some("images")
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
|
||||
@@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Refresh Deferred Effects",
|
||||
["Effect"],
|
||||
None,
|
||||
"{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
|
||||
"{ids?|id?: [object id] (objects of the document, also in the art of a symbol, pattern, brush or opacity mask; an object without a deferred stage adds nothing; an id of no object is an error; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
|
||||
has_deferred,
|
||||
refresh_deferred
|
||||
),
|
||||
@@ -150,16 +150,27 @@ fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) ->
|
||||
None => rest.push(object),
|
||||
}
|
||||
}
|
||||
if rest.is_empty() {
|
||||
for (id, n) in definition_objects(d, &rest) {
|
||||
out.entry(id).or_insert((n, false));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The objects of `ids` found in the art `d` draws from outside its layers: the art of its
|
||||
/// symbols, patterns and brushes, and opacity masks (in that art or in the layers).
|
||||
fn definition_objects(d: &Document, ids: &[u64]) -> std::collections::HashMap<u64, Arc<Node>> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
if ids.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let ids: std::collections::HashSet<u64> = ids.iter().copied().collect();
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let symbols = d.symbols.iter().map(|s| &*s.art);
|
||||
let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a));
|
||||
let layers = d.layers.iter().map(|a| &**a);
|
||||
let mut look = |n: &Node| {
|
||||
if rest.contains(&n.id.0) {
|
||||
out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false));
|
||||
if ids.contains(&n.id.0) {
|
||||
out.entry(n.id.0).or_insert_with(|| Arc::new(n.clone()));
|
||||
}
|
||||
};
|
||||
for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) {
|
||||
@@ -231,10 +242,19 @@ fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone());
|
||||
let ids = super::named_ids(p)?;
|
||||
let objects = prune(&doc, &fx);
|
||||
if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
// An object of the art drawn outside the layers is valid whether or not it has a stage.
|
||||
let elsewhere: Vec<u64> = ids.iter().flatten().filter(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)).map(|id| id.0).collect();
|
||||
let found = definition_objects(&doc, &elsewhere);
|
||||
if let Some(id) = elsewhere.iter().find(|id| !found.contains_key(id)) {
|
||||
return Err(EngineError::NoNode(NodeId(*id)));
|
||||
}
|
||||
let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect());
|
||||
// Only the objects with a stage have anything to refresh.
|
||||
let ids: Option<Vec<u64>> = ids.map(|ids| {
|
||||
let mut ids: Vec<u64> = ids.iter().map(|id| id.0).filter(|id| objects.contains_key(id)).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
});
|
||||
let counts = fx.refresh(ids.as_deref(), |targets| {
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let mut whole = Vec::new();
|
||||
|
||||
@@ -90,9 +90,9 @@ pub use pdfimport::{LoadOptions, page_document};
|
||||
pub(crate) use save::job_for;
|
||||
pub(crate) use save::save as save_document;
|
||||
pub use save::{
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option,
|
||||
remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder,
|
||||
templates_folder,
|
||||
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths,
|
||||
reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
|
||||
template_filters, templates_dialog_folder, templates_folder,
|
||||
};
|
||||
pub(crate) use screens::{
|
||||
PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS,
|
||||
@@ -124,7 +124,9 @@ pub(crate) use access::list_dir;
|
||||
pub use access::write_file_with;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::{Placed, copy_new, file_metadata};
|
||||
pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file};
|
||||
pub(crate) use access::{
|
||||
absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file,
|
||||
};
|
||||
#[cfg(all(test, windows))]
|
||||
pub(crate) use access::{document_path_on, document_path_with};
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -42,6 +42,29 @@ pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
(out, seen)
|
||||
}
|
||||
|
||||
/// May a reply to the request being served name `path`, a file or folder the client did not give?
|
||||
/// Always without roots in force; with them, only inside the read roots ([`crate::file_access::check_read`]).
|
||||
pub(crate) fn shown(path: &str) -> bool {
|
||||
crate::file_access::check_read(path).is_ok()
|
||||
}
|
||||
|
||||
/// `path` as a reply to the request being served names it: whole when [`shown`], else its file name,
|
||||
/// after the last `/` or `\` whichever platform wrote it (empty for a path that ends in no name, such
|
||||
/// as `..` or a folder ending in a separator).
|
||||
pub(crate) fn shown_path(path: &str) -> std::borrow::Cow<'_, str> {
|
||||
shown_link(path, None)
|
||||
}
|
||||
|
||||
/// A link's `stored` path as a reply to the request being served names it: whole when the file it
|
||||
/// names (`resolved`, else `stored` itself) is [`shown`], else its file name as [`shown_path`] gives it.
|
||||
pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::borrow::Cow<'a, str> {
|
||||
if shown(resolved.unwrap_or(stored)) {
|
||||
return std::borrow::Cow::Borrowed(stored);
|
||||
}
|
||||
let name = stored.rsplit(vectorcraft_doc::links::path_separators(stored)).next().unwrap_or_default();
|
||||
std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name })
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn may_write(path: &str) -> Result<()> {
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused",
|
||||
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused",
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset",
|
||||
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones (a folder outside the automation read roots is left out of settings); any name works as document.exportForScreens' exportPreset",
|
||||
always,
|
||||
screens::presets_list
|
||||
),
|
||||
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Export Presets",
|
||||
[],
|
||||
None,
|
||||
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}",
|
||||
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON; a folder outside the automation read roots is left out); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}",
|
||||
always,
|
||||
screens::presets_export
|
||||
),
|
||||
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens Settings",
|
||||
[],
|
||||
None,
|
||||
"{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)",
|
||||
"{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them; a folder outside the automation read roots is left out)",
|
||||
has_doc,
|
||||
screens::export_settings
|
||||
),
|
||||
|
||||
@@ -1043,11 +1043,47 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value>
|
||||
let plan = save_plan(s, mode, p)?;
|
||||
let job = save_job(s, plan)?;
|
||||
let uid = s.doc()?.uid;
|
||||
let out = job.write_streamed(&mut super::write_file_with)?;
|
||||
let mut out = job.write_streamed(&mut super::write_file_with)?;
|
||||
job.complete(s, uid);
|
||||
leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The reply `out` of a save with the `path` param `given`, as the request being served may see it
|
||||
/// ([`super::access::shown`]). A save without a path writes the document's own file: its `path` is
|
||||
/// left out, and the `files` and `linked` written beside it are named by their file names, when they
|
||||
/// are outside the read roots. The suggested `folder` of a reply without a path is left out when it
|
||||
/// is outside them. Without roots in force `out` is unchanged.
|
||||
pub fn leave_out_unreadable_save_paths(out: &mut Value, given: Option<&str>) {
|
||||
use super::access::{shown, shown_path};
|
||||
|
||||
if crate::file_access::current().is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(o) = out.as_object_mut() else { return };
|
||||
if o.get("folder").and_then(Value::as_str).is_some_and(|f| !shown(f)) {
|
||||
o.remove("folder");
|
||||
}
|
||||
// Every path the reply names then is the one the client gave, or beside it.
|
||||
if given.is_some_and(|g| !g.is_empty()) {
|
||||
return;
|
||||
}
|
||||
if o.get("path").and_then(Value::as_str).is_some_and(|p| !shown(p)) {
|
||||
o.remove("path");
|
||||
}
|
||||
for key in ["files", "linked"] {
|
||||
if let Some(Value::Array(rows)) = o.get_mut(key) {
|
||||
for row in rows {
|
||||
if let Value::String(p) = row
|
||||
&& !shown(p)
|
||||
{
|
||||
*row = json!(shown_path(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
match (&st.path, st.is_dirty()) {
|
||||
@@ -1134,7 +1170,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file and EPS (below): a .ai file is a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS takes the options of document.exportEps: by default one file whose bounding box fits the visible art (the first artboard when no art shows), carrying the whole editable document; useArtboards: true (also when artboard, artboards or range names artboards; file.formatOptions then reports it true) makes the chosen artboard the page, drawn with its background color (without Use Artboards, only the first artboard's page when no art shows has its color), and with two or more chosen the file at the path is the central file of the visible art (again the first artboard when none shows) and each artboard is also written to <name>_<artboard>.eps beside it, holding that artboard; selectedOnly is refused; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -644,12 +644,86 @@ impl Output {
|
||||
/// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with
|
||||
/// the params remembered in the document (`document.exportSettings`).
|
||||
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A folder the client gave, as against the one its `exportPreset` keeps.
|
||||
let given = p.get("folder").is_some();
|
||||
let p = with_preset(s, p)?;
|
||||
let r = export(s, &p)?;
|
||||
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
|
||||
let mut r = export(s, &p).map_err(|e| match hidden {
|
||||
Some(folder) => leave_out_folder_from_error(e, folder),
|
||||
None => e,
|
||||
})?;
|
||||
remember(s, &p);
|
||||
if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) {
|
||||
leave_out_unreadable_folder(&mut r, folder);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// An error of an export into `folder`, a folder outside the read roots that the client did not
|
||||
/// give: `folder` as the path the error is about names it by its own name
|
||||
/// ([`super::access::shown_path`]), and the rest of the message stays.
|
||||
fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError {
|
||||
let name = super::access::shown_path(folder);
|
||||
match e {
|
||||
EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)),
|
||||
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) },
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start
|
||||
/// of `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or
|
||||
/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message
|
||||
/// names stays whole, even one inside `folder`.
|
||||
fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String {
|
||||
const BEFORE: [&str; 2] = [": ", "cannot resolve "];
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let mut rest = msg;
|
||||
loop {
|
||||
if let Some(after) = rest.strip_prefix(folder)
|
||||
&& after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' '))
|
||||
{
|
||||
out.push_str(name);
|
||||
rest = after;
|
||||
}
|
||||
let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break };
|
||||
out.push_str(&rest[..i + len]);
|
||||
rest = &rest[i + len..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being
|
||||
/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's
|
||||
/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`),
|
||||
/// as the reply of an export to a ZIP names them.
|
||||
fn leave_out_unreadable_folder(r: &mut Value, folder: &str) {
|
||||
if super::access::shown(folder) {
|
||||
return;
|
||||
}
|
||||
let Some(o) = r.as_object_mut() else { return };
|
||||
o.remove("path");
|
||||
let inside = format!("{folder}/");
|
||||
if let Some(Value::Array(files)) = o.get_mut("files") {
|
||||
for file in files {
|
||||
// A ZIP's files are named relative to it already.
|
||||
if let Some(name) = file.as_str().and_then(|path| path.strip_prefix(&inside)) {
|
||||
*file = json!(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export settings kept in the document or a preset, as the request being served may see them: a
|
||||
/// `folder` outside the read roots ([`super::access::shown`]) is left out.
|
||||
fn leave_out_unreadable_settings_folder(mut settings: Map<String, Value>) -> Map<String, Value> {
|
||||
if settings.get("folder").and_then(Value::as_str).is_some_and(|f| !f.is_empty() && !super::access::shown(f)) {
|
||||
settings.remove("folder");
|
||||
}
|
||||
settings
|
||||
}
|
||||
|
||||
// ---------- export presets ----------
|
||||
|
||||
/// A saved batch export: what to write and how to name and sort it, to use again
|
||||
@@ -727,7 +801,13 @@ pub fn preset_list(saved: &[ScreenExportPreset]) -> Value {
|
||||
}
|
||||
|
||||
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "presets": preset_list(&s.prefs.screen_export_presets) }))
|
||||
let saved: Vec<ScreenExportPreset> = s
|
||||
.prefs
|
||||
.screen_export_presets
|
||||
.iter()
|
||||
.map(|p| ScreenExportPreset { name: p.name.clone(), settings: leave_out_unreadable_settings_folder(p.settings.clone()) })
|
||||
.collect();
|
||||
Ok(json!({ "presets": preset_list(&saved) }))
|
||||
}
|
||||
|
||||
/// The [`PRESET_KEYS`] of `given`, refused as an error of command `cmd` when an export would
|
||||
@@ -831,6 +911,8 @@ pub(super) fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(too_many());
|
||||
}
|
||||
let count = presets.len();
|
||||
let presets: Vec<ScreenExportPreset> =
|
||||
presets.into_iter().map(|p| ScreenExportPreset { settings: leave_out_unreadable_settings_folder(p.settings), ..p }).collect();
|
||||
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FILE_FORMAT.into(), presets }).map_err(|e| bad(EXPORT, e.to_string()))?;
|
||||
if text.len() > MAX_PRESET_FILE_BYTES {
|
||||
return Err(bad(EXPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes")));
|
||||
@@ -1388,12 +1470,31 @@ pub(crate) fn store_settings(s: &mut Session, mut settings: Map<String, Value>)
|
||||
|
||||
/// `document.exportSettings`: the Export for Screens settings the document last exported with.
|
||||
pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "settings": s.doc()?.doc.export_settings }))
|
||||
Ok(json!({ "settings": leave_out_unreadable_settings_folder(s.doc()?.doc.export_settings.clone()) }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, ScreenSize, safe_file_name};
|
||||
use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name};
|
||||
|
||||
/// A hidden export folder is named by its name where a message is about it, and a root that
|
||||
/// starts with the same text, or a longer path, stays whole.
|
||||
#[test]
|
||||
fn a_hidden_folder_is_named_by_its_name_in_a_message() {
|
||||
let f = "/work/out";
|
||||
let cases = [
|
||||
(
|
||||
"automation path rejected: outside the write root /work/out/in: /work/out",
|
||||
"automation path rejected: outside the write root /work/out/in: out",
|
||||
),
|
||||
("automation path rejected: cannot resolve /work/out: denied", "automation path rejected: cannot resolve out: denied"),
|
||||
("/work/out/2x/a.png: disk full", "out/2x/a.png: disk full"),
|
||||
("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"),
|
||||
];
|
||||
for (msg, want) in cases {
|
||||
assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Files given one name are numbered in turn, each numbering going on from the last one (so
|
||||
/// thousands of them don't each try every number used before), and a file asking again for
|
||||
|
||||
@@ -48,6 +48,19 @@ fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// A path outside a confined request's read roots shows as its file name, after the last `/` or `\`
|
||||
/// on every platform; one that ends in no name shows as nothing. Without roots it shows whole.
|
||||
#[test]
|
||||
fn a_hidden_path_shows_as_its_file_name_on_every_platform() {
|
||||
let windows = "C:\\Users\\someone\\art\\red.png";
|
||||
crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || {
|
||||
for (path, name) in [("/home/someone/art/red.png", "red.png"), (windows, "red.png"), ("/home/someone/..", ""), ("C:\\Users\\someone\\", "")] {
|
||||
assert_eq!(shown_path(path), name, "{path}");
|
||||
}
|
||||
});
|
||||
assert_eq!(shown_path(windows), windows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_every_readable_format() {
|
||||
let mut s = session(200.0, 100.0, 1);
|
||||
|
||||
@@ -667,6 +667,118 @@ fn a_numbered_file_is_written_into_the_first_rows_folder() {
|
||||
assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1, "one sub-folder");
|
||||
}
|
||||
|
||||
/// An export into the folder an export preset keeps, for a client whose read roots don't hold that
|
||||
/// folder: the reply leaves out the ZIP's path and names the files relative to the folder. A folder
|
||||
/// inside the read roots, and one the client gives, stay whole.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let mut s = session(1);
|
||||
let tmp = Folder::new("preset-roots");
|
||||
let (inside, out) = (tmp.0.join("in"), tmp.0.join("out"));
|
||||
for f in [&inside, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let folder = out.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
|
||||
let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap();
|
||||
let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap();
|
||||
let run = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, p: Value| confine(roots.as_ref(), || export(s, p));
|
||||
|
||||
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept"}));
|
||||
assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}");
|
||||
assert!(out.join("1x/Artboard-1.png").is_file());
|
||||
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true}));
|
||||
assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}");
|
||||
assert!(!r.to_string().contains(&folder), "{r}");
|
||||
// Inside the read roots, or given by the client: whole.
|
||||
assert_eq!(run(&mut s, &both, json!({"exportPreset": "Kept"}))["files"], json!([format!("{folder}/1x/Artboard-1.png")]));
|
||||
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true}));
|
||||
assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}");
|
||||
}
|
||||
|
||||
/// An export into the folder an export preset keeps, refused because that folder is outside the
|
||||
/// client's read and write roots: the error names the folder by its name only. A folder the client
|
||||
/// gives stays whole in the error.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_name() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let mut s = session(1);
|
||||
let tmp = Folder::new("preset-refused");
|
||||
let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out"));
|
||||
for f in [&inside, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let folder = out.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "png"}], "folder": folder});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
|
||||
let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let mut run = |p: Value| confine(confined.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap_err().to_string();
|
||||
|
||||
let e = run(json!({"exportPreset": "Kept"}));
|
||||
assert!(e.contains("outside the write root") && e.contains("kept-out") && !e.contains(&folder), "{e}");
|
||||
let e = run(json!({"exportPreset": "Kept", "folder": folder}));
|
||||
assert!(e.contains(&folder), "{e}");
|
||||
assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written");
|
||||
// A preset folder that holds the write root: the root stays whole in the error.
|
||||
let parent = tmp.0.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "png"}], "folder": parent});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap();
|
||||
let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string();
|
||||
let root = inside.display().to_string();
|
||||
let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert!(e.contains(&format!("root {root}: {name}")), "{e}");
|
||||
}
|
||||
|
||||
/// The export settings the document keeps and the saved presets, read by a client whose read roots
|
||||
/// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and
|
||||
/// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest.
|
||||
/// Inside the read roots the folder stays.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn kept_settings_leave_out_a_folder_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let mut s = session(1);
|
||||
let tmp = Folder::new("settings-roots");
|
||||
let (inside, out) = (tmp.0.join("in"), tmp.0.join("out"));
|
||||
for f in [&inside, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let folder = out.to_string_lossy().replace('\\', "/");
|
||||
let settings = json!({"formats": [{"format": "svg"}], "folder": folder});
|
||||
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
|
||||
export(&mut s, json!({"exportPreset": "Kept"}));
|
||||
let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[inside.as_path()]).unwrap();
|
||||
let ask = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, cmd: &str, p: Value| {
|
||||
confine(roots.as_ref(), || s.execute(cmd, &p).unwrap())
|
||||
};
|
||||
let saved = |list: &Value| list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Kept").unwrap()["settings"].clone();
|
||||
|
||||
let r = ask(&mut s, &confined, "document.exportSettings", json!({}));
|
||||
assert_eq!((r["settings"].get("folder"), &r["settings"]["formats"]), (None, &json!([{"format": "svg"}])), "{r}");
|
||||
let r = ask(&mut s, &confined, "document.exportPresets.list", json!({}));
|
||||
assert_eq!(saved(&r), json!({"formats": [{"format": "svg"}]}), "{r}");
|
||||
let r = ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"]}));
|
||||
let data = r["data"].as_str().unwrap();
|
||||
assert!(!data.contains(&folder) && data.contains("svg"), "{data}");
|
||||
let file = inside.join("kept.vcexportpresets");
|
||||
let path = file.to_string_lossy().replace('\\', "/");
|
||||
ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"], "path": path}));
|
||||
assert!(!std::fs::read_to_string(&file).unwrap().contains(&folder));
|
||||
|
||||
// Inside the read roots: whole.
|
||||
assert_eq!(ask(&mut s, &both, "document.exportSettings", json!({}))["settings"]["folder"], json!(folder));
|
||||
assert_eq!(saved(&ask(&mut s, &both, "document.exportPresets.list", json!({})))["folder"], json!(folder));
|
||||
assert!(ask(&mut s, &both, "document.exportPresets.export", json!({"names": ["Kept"]}))["data"].as_str().unwrap().contains(&folder));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_settings_apply_to_their_rows_and_rows_win() {
|
||||
let mut s = session(1);
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_doc::{Document, LinkInfo};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fileio::{create_dir, write_file};
|
||||
use super::fileio::{create_dir, document_path, shown_link, shown_path, write_file};
|
||||
use super::links::LinkBase;
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param, unique_name};
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Package",
|
||||
[],
|
||||
None,
|
||||
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
|
||||
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder. The warnings and the report name a file outside the automation read roots by its file name",
|
||||
has_doc,
|
||||
package
|
||||
)]
|
||||
@@ -178,8 +178,10 @@ impl<'a> Collector<'a> {
|
||||
|
||||
/// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the
|
||||
/// copy of `source` finds it only by its name, beside it.
|
||||
/// `path` is the link's path as the warning names it.
|
||||
fn renamed(&mut self, lines: &mut Vec<String>, source: &str, path: &str, name: &str, copy: &str) {
|
||||
if Path::new(copy).file_name().and_then(std::ffi::OsStr::to_str) != Some(name) {
|
||||
let source = shown_path(source);
|
||||
let message = format!(
|
||||
"{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview"
|
||||
);
|
||||
@@ -207,11 +209,13 @@ impl<'a> Collector<'a> {
|
||||
let section = self.sections.len();
|
||||
self.sections.push(vec![]);
|
||||
let mut lines = vec![];
|
||||
// The packaged document's sections read as before; a placed document's name its file.
|
||||
// The packaged document's sections read as before; a placed document's name its file. The
|
||||
// report and the warnings name a file outside a confined request's read roots by its name.
|
||||
let shown_source = shown_path(source);
|
||||
let heading = |lines: &mut Vec<String>, title: &str| {
|
||||
lines.push(title.into());
|
||||
if depth > 0 {
|
||||
lines.push(format!("Document: {source}"));
|
||||
lines.push(format!("Document: {shown_source}"));
|
||||
}
|
||||
};
|
||||
// A file's saved path is only meaningful inside its own document.
|
||||
@@ -243,17 +247,21 @@ impl<'a> Collector<'a> {
|
||||
heading(&mut lines, "LINKED FILES");
|
||||
for file in linked_files(doc) {
|
||||
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
|
||||
// The stored path, whole when the place it names (read from this document's folder) is
|
||||
// inside the read roots. A file found elsewhere does not show a stored path outside them.
|
||||
let resolved = document_path(&file.path, Some(source)).ok();
|
||||
let shown = shown_link(&file.path, resolved.as_deref()).into_owned();
|
||||
if let Some(ancestor) = self.visiting.get(found_path).cloned() {
|
||||
// The ancestor is already included (possibly the root document). A
|
||||
// cyclic reference cannot have a final content hash until both files
|
||||
// have been written, so leave its size/hash unspecified, not stale.
|
||||
self.warn(&mut lines, format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path));
|
||||
self.warn(&mut lines, format!("{shown}: circular placed-document link to {ancestor}; stopped following the cycle"));
|
||||
local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None });
|
||||
continue;
|
||||
}
|
||||
if let Some(existing) = self.copies.get(found_path).cloned() {
|
||||
if by_name {
|
||||
self.renamed(&mut lines, source, &file.path, &file.name, &existing.name);
|
||||
self.renamed(&mut lines, source, &shown, &file.name, &existing.name);
|
||||
}
|
||||
// Another document may place other pages of a PDF copied already.
|
||||
if self.opts.copy_fonts
|
||||
@@ -265,8 +273,8 @@ impl<'a> Collector<'a> {
|
||||
continue;
|
||||
}
|
||||
let Some(mut bytes) = file.bytes else {
|
||||
lines.push(format!("{}: not found ({}), not copied", file.name, file.path));
|
||||
self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) });
|
||||
lines.push(format!("{}: not found ({shown}), not copied", file.name));
|
||||
self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({shown_source})", file.name) });
|
||||
continue;
|
||||
};
|
||||
if self.copies.len() >= 10_000 {
|
||||
@@ -277,7 +285,7 @@ impl<'a> Collector<'a> {
|
||||
let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken });
|
||||
let destination_file = format!("{dir}{unique}");
|
||||
if by_name {
|
||||
self.renamed(&mut lines, source, &file.path, &file.name, &destination_file);
|
||||
self.renamed(&mut lines, source, &shown, &file.name, &destination_file);
|
||||
}
|
||||
// A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file
|
||||
// read from its editing data links are copied beside it.
|
||||
@@ -285,7 +293,7 @@ impl<'a> Collector<'a> {
|
||||
let placed_file = pages.is_some();
|
||||
if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) {
|
||||
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
||||
self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links"));
|
||||
} else {
|
||||
self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?;
|
||||
}
|
||||
@@ -296,7 +304,7 @@ impl<'a> Collector<'a> {
|
||||
}
|
||||
if placed_file && super::place::document::is_native(&file.name, &bytes) {
|
||||
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
||||
self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links"));
|
||||
} else {
|
||||
match vectorcraft_format::load_file(&bytes) {
|
||||
Ok(saved) => {
|
||||
@@ -320,8 +328,7 @@ impl<'a> Collector<'a> {
|
||||
Err(e) => self.warn(
|
||||
&mut lines,
|
||||
format!(
|
||||
"{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked",
|
||||
file.path
|
||||
"{shown}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked"
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -329,7 +336,7 @@ impl<'a> Collector<'a> {
|
||||
}
|
||||
Err(e) => self.warn(
|
||||
&mut lines,
|
||||
format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path),
|
||||
format!("{shown}: unreadable linked document ({e}); copied unchanged, its own links were not collected"),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -337,7 +344,7 @@ impl<'a> Collector<'a> {
|
||||
let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) };
|
||||
self.copies.insert(found_path.to_string(), copy.clone());
|
||||
local.insert(file.path.clone(), copy);
|
||||
lines.push(format!("{} → {destination_file}", file.path));
|
||||
lines.push(format!("{shown} → {destination_file}"));
|
||||
self.entries.push((destination_file, bytes));
|
||||
}
|
||||
lines.push(String::new());
|
||||
@@ -518,7 +525,7 @@ fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Res
|
||||
// The report.
|
||||
if let Some(info) = info {
|
||||
let report_name = free_name(&format!("{stem} Report.txt"), &mut taken);
|
||||
let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n");
|
||||
let mut text = format!("Package: {doc_name}\nFrom: {}\n\n", shown_path(&doc_path));
|
||||
lines.iter().for_each(|l| text.push_str(&format!("{l}\n")));
|
||||
text.push_str(&info);
|
||||
entries.push((report_name, text.into_bytes()));
|
||||
|
||||
@@ -23,8 +23,10 @@ pub struct BuiltinPatternLibrary {
|
||||
}
|
||||
|
||||
/// The built-in pattern libraries (Window → Swatch Libraries → Patterns), in menu order.
|
||||
pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] =
|
||||
&[BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }];
|
||||
pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[
|
||||
BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns },
|
||||
BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures },
|
||||
];
|
||||
|
||||
/// The library category of the built-in pattern libraries.
|
||||
pub const CATEGORY: &str = "patterns";
|
||||
@@ -154,6 +156,106 @@ fn geometric_patterns(d: &mut Document) {
|
||||
add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]);
|
||||
}
|
||||
|
||||
/// SplitMix64, a small generator with a fixed seed per pattern, so a grit pattern draws the same
|
||||
/// marks every time it is made.
|
||||
struct SplitMix64(u64);
|
||||
|
||||
impl SplitMix64 {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A number from `lo` up to, but not including, `hi`.
|
||||
fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (self.next() >> 11) as f64 / (1u64 << 53) as f64 * (hi - lo)
|
||||
}
|
||||
}
|
||||
|
||||
/// A round dot of radius `r` at (`x`, `y`).
|
||||
fn dot(x: f64, y: f64, r: f64) -> PathData {
|
||||
shapes::ellipse(Rect::new(x - r, y - r, x + r, y + r))
|
||||
}
|
||||
|
||||
/// An irregular fleck of four to seven corners about (`x`, `y`), at most `r` from it.
|
||||
fn fleck(rng: &mut SplitMix64, x: f64, y: f64, r: f64) -> PathData {
|
||||
use std::f64::consts::TAU;
|
||||
let n = 4 + (rng.next() % 4) as usize;
|
||||
let turn = rng.range(0.0, TAU);
|
||||
let points: Vec<(f64, f64)> = (0..n)
|
||||
.map(|k| {
|
||||
let a = turn + TAU * (k as f64 + rng.range(-0.3, 0.3)) / n as f64;
|
||||
let reach = r * rng.range(0.55, 1.0);
|
||||
(x + reach * a.cos(), y + reach * a.sin())
|
||||
})
|
||||
.collect();
|
||||
polygon(&points)
|
||||
}
|
||||
|
||||
/// A thin straight scratch `len` long and `width` wide, centered on (`x`, `y`) at a random angle.
|
||||
fn scratch(rng: &mut SplitMix64, x: f64, y: f64, len: f64, width: f64) -> PathData {
|
||||
let (s, c) = rng.range(0.0, std::f64::consts::PI).sin_cos();
|
||||
let (hx, hy, wx, wy) = (c * len / 2.0, s * len / 2.0, -s * width / 2.0, c * width / 2.0);
|
||||
polygon(&[(x - hx - wx, y - hy - wy), (x + hx - wx, y + hy - wy), (x + hx + wx, y + hy + wy), (x - hx + wx, y - hy + wy)])
|
||||
}
|
||||
|
||||
/// A grit pattern of `count` marks scattered over a square grid tile of side `size`, drawn from
|
||||
/// `seed` (`mark` draws one at a point). A mark that reaches past an edge is kept whole: the tiling
|
||||
/// draws the part outside the tile on the next tile, so the tiles join without a seam.
|
||||
fn grit(d: &mut Document, name: &str, size: f64, seed: u64, count: usize, mark: impl Fn(&mut SplitMix64, f64, f64) -> PathData) {
|
||||
let mut rng = SplitMix64(seed);
|
||||
let marks = (0..count)
|
||||
.map(|_| {
|
||||
let (x, y) = (rng.range(0.0, size), rng.range(0.0, size));
|
||||
mark(&mut rng, x, y)
|
||||
})
|
||||
.collect();
|
||||
add(d, name, size, size, TileType::Grid, marks);
|
||||
}
|
||||
|
||||
/// Grit Textures: seamless black grain, grit, sand, speckle, stipple, scratch and fleck textures on
|
||||
/// a clear background, each drawn from its own fixed seed.
|
||||
fn grit_textures(d: &mut Document) {
|
||||
grit(d, "Fine Grain", 24.0, 1, 320, |rng, x, y| dot(x, y, rng.range(0.15, 0.35)));
|
||||
grit(d, "Coarse Grit", 30.0, 2, 150, |rng, x, y| {
|
||||
let r = rng.range(0.45, 1.1);
|
||||
fleck(rng, x, y, r)
|
||||
});
|
||||
grit(d, "Sand", 24.0, 3, 280, |rng, x, y| {
|
||||
let r = rng.range(0.2, 0.45);
|
||||
fleck(rng, x, y, r)
|
||||
});
|
||||
grit(d, "Speckle", 30.0, 4, 90, |rng, x, y| dot(x, y, rng.range(0.3, 0.9)));
|
||||
// Stipple: one dot in each cell of a 12 × 12 grid, moved a little off the cell's center.
|
||||
let mut rng = SplitMix64(5);
|
||||
let (size, cells) = (24.0, 12);
|
||||
let step = size / f64::from(cells);
|
||||
let dots = (0..cells)
|
||||
.flat_map(|row| (0..cells).map(move |col| (col, row)))
|
||||
.map(|(col, row)| {
|
||||
let x = (f64::from(col) + 0.5) * step + rng.range(-0.3, 0.3) * step;
|
||||
let y = (f64::from(row) + 0.5) * step + rng.range(-0.3, 0.3) * step;
|
||||
dot(x, y, rng.range(0.3, 0.45))
|
||||
})
|
||||
.collect();
|
||||
add(d, "Stipple", size, size, TileType::Grid, dots);
|
||||
grit(d, "Scratches", 36.0, 6, 45, |rng, x, y| {
|
||||
let (len, width) = (rng.range(2.0, 7.0), rng.range(0.15, 0.35));
|
||||
scratch(rng, x, y, len, width)
|
||||
});
|
||||
grit(d, "Sparse Flecks", 36.0, 7, 32, |rng, x, y| {
|
||||
let r = rng.range(0.35, 0.8);
|
||||
fleck(rng, x, y, r)
|
||||
});
|
||||
grit(d, "Dense Flecks", 18.0, 8, 220, |rng, x, y| {
|
||||
let r = rng.range(0.2, 0.5);
|
||||
fleck(rng, x, y, r)
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -168,4 +270,60 @@ mod tests {
|
||||
assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}");
|
||||
}
|
||||
}
|
||||
|
||||
fn grit_doc() -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
grit_textures(&mut d);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grit_textures_draw_the_same_marks_every_time() {
|
||||
let (a, b) = (grit_doc(), grit_doc());
|
||||
assert_eq!(a.patterns.len(), 8);
|
||||
assert_eq!(a.patterns, b.patterns);
|
||||
}
|
||||
|
||||
/// Every grit tile is 18 to 36 pt with at most 600 marks, each with an area, touching the tile
|
||||
/// and near it. Marks reach past the edges, and the tiling draws a tile's art on the next tile
|
||||
/// across each edge a mark crosses. No mark is another mark moved by a whole tile, which the tiling would draw
|
||||
/// twice in one place.
|
||||
#[test]
|
||||
fn grit_tiles_join_without_a_seam() {
|
||||
let mut crossing = 0;
|
||||
for p in &grit_doc().patterns {
|
||||
let (w, h) = (p.width(), p.height());
|
||||
assert!((18.0..=36.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name);
|
||||
assert!(!p.art.is_empty() && p.art.len() <= 600, "{}: {} marks", p.name, p.art.len());
|
||||
let bounds: Vec<Rect> = p
|
||||
.art
|
||||
.iter()
|
||||
.map(|n| match &n.kind {
|
||||
vectorcraft_doc::NodeKind::Path { path, .. } => path.bounds().unwrap_or_else(|| panic!("{}: an empty mark", p.name)),
|
||||
_ => panic!("{}: a mark that is not a path", p.name),
|
||||
})
|
||||
.collect();
|
||||
let near = |a: &Rect, b: Rect| {
|
||||
(a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9
|
||||
};
|
||||
let band = p.tile.inflate(w / 4.0, h / 4.0);
|
||||
for b in &bounds {
|
||||
assert!(b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name);
|
||||
assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name);
|
||||
assert!(b.x0 >= band.x0 && b.y0 >= band.y0 && b.x1 <= band.x1 && b.y1 <= band.y1, "{}: {b:?} is too far out", p.name);
|
||||
// The tile past each edge the mark crosses draws this tile's art.
|
||||
let (dx, dy) = (f64::from(i8::from(b.x1 > w) - i8::from(b.x0 < 0.0)), f64::from(i8::from(b.y1 > h) - i8::from(b.y0 < 0.0)));
|
||||
for (x, y) in [(dx, 0.0), (0.0, dy), (dx, dy)] {
|
||||
let next = p.tile + kurbo::Vec2::new(x * w, y * h);
|
||||
assert!(p.offsets_covering(next).contains(&kurbo::Vec2::ZERO), "{}: {b:?} is cut off at {next:?}", p.name);
|
||||
}
|
||||
crossing += usize::from(dx != 0.0 || dy != 0.0);
|
||||
for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] {
|
||||
let moved = *b + kurbo::Vec2::new(dx, dy);
|
||||
assert!(!bounds.iter().any(|o| near(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(crossing > 0, "no mark reaches past an edge");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"brush.move",
|
||||
"brush.new",
|
||||
"brush.options",
|
||||
"brush.placeArt",
|
||||
"brush.remove",
|
||||
"brush.selectionArt",
|
||||
"brush.setCurrent",
|
||||
|
||||
@@ -274,7 +274,60 @@ impl FolderStore {
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
builder.create(dir).map_err(|e| format!("{}: {e}", dir.display()))
|
||||
builder.create(dir).map_err(|e| Self::folder_error(dir, &e, true))
|
||||
}
|
||||
|
||||
/// The first folder on the way to `dir` (`dir` included), from the top down, that is not a
|
||||
/// folder (`true`) or is missing (`false`). None when every one of them is a folder or one of
|
||||
/// them can't be checked.
|
||||
fn in_the_way(dir: &std::path::Path) -> Option<(&std::path::Path, bool)> {
|
||||
let mut way: Vec<&std::path::Path> = dir.ancestors().filter(|p| !p.as_os_str().is_empty()).collect();
|
||||
way.reverse();
|
||||
for p in way {
|
||||
match std::fs::metadata(p) {
|
||||
Ok(m) if m.is_dir() => {}
|
||||
Ok(_) => return Some((p, true)),
|
||||
Err(m) if m.kind() == std::io::ErrorKind::NotFound => return Some((p, false)),
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The message for error `e` on folder `dir`, which was being made (`making`) or read. It
|
||||
/// names the first folder on the way to `dir` that is not a folder, or is missing (when
|
||||
/// `making`, the one that couldn't be made), and otherwise `dir`.
|
||||
fn folder_error(dir: &std::path::Path, e: &std::io::Error, making: bool) -> String {
|
||||
match Self::in_the_way(dir) {
|
||||
Some((p, true)) => format!("{} is not a folder: {e}", p.display()),
|
||||
Some((p, false)) if making => format!("couldn't make {}: {e}", p.display()),
|
||||
Some((p, false)) if p != dir => format!("{} is missing", p.display()),
|
||||
_ => format!("{}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for error `e` on file `f`. It names the first folder on the way to `f` that is
|
||||
/// not a folder or is missing, and otherwise `f`.
|
||||
fn file_error(f: &std::path::Path, e: &std::io::Error) -> String {
|
||||
match f.parent().and_then(Self::in_the_way) {
|
||||
Some((p, true)) => format!("{} is not a folder: {e}", p.display()),
|
||||
Some((p, false)) => format!("{} is missing", p.display()),
|
||||
None => format!("{}: {e}", f.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome `r` of removing file `f`. A file that is not there counts as removed, unless a
|
||||
/// file is on the way to it: Windows reports that as a missing file, and it is named as for a
|
||||
/// listing.
|
||||
pub(crate) fn removed(f: &std::path::Path, r: std::io::Result<()>) -> std::result::Result<(), String> {
|
||||
match r {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => match f.parent().and_then(Self::in_the_way) {
|
||||
Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())),
|
||||
_ => Ok(()),
|
||||
},
|
||||
Err(e) => Err(Self::file_error(f, &e)),
|
||||
Ok(()) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix)
|
||||
@@ -282,7 +335,7 @@ impl FolderStore {
|
||||
/// linked to a folder of its own in a shared recovery folder looked free, and claiming it
|
||||
/// wrote the copies through the link.
|
||||
fn own_area(dir: &std::path::Path) -> std::result::Result<(), String> {
|
||||
let m = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let m = std::fs::symlink_metadata(dir).map_err(|e| Self::folder_error(dir, &e, false))?;
|
||||
if !m.file_type().is_dir() {
|
||||
return Err(format!("{}: not a folder of its own", dir.display()));
|
||||
}
|
||||
@@ -294,11 +347,17 @@ impl FolderStore {
|
||||
}
|
||||
|
||||
/// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist.
|
||||
/// A file on the way to `dir` is an error: Windows reports such a folder as missing.
|
||||
fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result<Vec<String>, String> {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
|
||||
Err(e) => return Err(format!("{}: {e}", dir.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return match Self::in_the_way(dir) {
|
||||
Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())),
|
||||
_ => Ok(vec![]),
|
||||
};
|
||||
}
|
||||
Err(e) => return Err(Self::folder_error(dir, &e, false)),
|
||||
};
|
||||
Ok(entries
|
||||
.filter_map(|e| e.ok())
|
||||
@@ -333,7 +392,7 @@ impl FolderStore {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => Ok(Lock::Busy),
|
||||
std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy),
|
||||
_ => Err(format!("{}: {e}", path.display())),
|
||||
_ => Err(Self::file_error(path, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +404,7 @@ impl FolderStore {
|
||||
Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy),
|
||||
// The lock file was removed meanwhile, and locking it failed instead.
|
||||
Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy),
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())),
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(Self::file_error(path, &e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,10 +452,10 @@ impl RecoveryStore for FolderStore {
|
||||
if let Some(dir) = f.parent() {
|
||||
Self::own_area(dir)?;
|
||||
}
|
||||
if !std::fs::symlink_metadata(&f).map_err(|e| format!("{}: {e}", f.display()))?.file_type().is_file() {
|
||||
if !std::fs::symlink_metadata(&f).map_err(|e| Self::file_error(&f, &e))?.file_type().is_file() {
|
||||
return Err(format!("{}: not a plain file", f.display()));
|
||||
}
|
||||
std::fs::read(&f).map_err(|e| format!("{}: {e}", f.display()))
|
||||
std::fs::read(&f).map_err(|e| Self::file_error(&f, &e))
|
||||
}
|
||||
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
|
||||
let f = self.file(name)?;
|
||||
@@ -404,14 +463,11 @@ impl RecoveryStore for FolderStore {
|
||||
Self::create_area(dir)?;
|
||||
Self::own_area(dir)?;
|
||||
}
|
||||
vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display()))
|
||||
vectorcraft_format::write_atomic(&f, bytes).map_err(|e| Self::file_error(&f, &e))
|
||||
}
|
||||
fn remove(&self, name: &str) -> std::result::Result<(), String> {
|
||||
let f = self.file(name)?;
|
||||
match std::fs::remove_file(&f) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", f.display())),
|
||||
_ => Ok(()),
|
||||
}
|
||||
Self::removed(&f, std::fs::remove_file(&f))
|
||||
}
|
||||
fn location(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Tab Stops",
|
||||
["Window", "Tabs"],
|
||||
None,
|
||||
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); supplied align, leader and alignOn must be strings, and alignOn cannot be empty",
|
||||
"{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept",
|
||||
has_selection,
|
||||
set
|
||||
),
|
||||
@@ -38,10 +38,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
|
||||
const C: &str = "text.tabs.set";
|
||||
let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?;
|
||||
let max = TabStop::MAX_STOPS;
|
||||
if arr.len() > max {
|
||||
return Err(bad(C, format!("at most {max} tab stops")));
|
||||
}
|
||||
let mut stops = vec![];
|
||||
for s in arr {
|
||||
let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?;
|
||||
if !(0.0..=100_000.0).contains(&position) {
|
||||
if !(0.0..=TabStop::MAX_POSITION).contains(&position) {
|
||||
return Err(bad(C, "positions must be between 0 and 100000 pt"));
|
||||
}
|
||||
let align = match s.get("align") {
|
||||
@@ -60,16 +64,25 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> {
|
||||
None | Some(Value::Null) => "",
|
||||
Some(Value::String(value)) => value.as_str(),
|
||||
Some(_) => return Err(bad(C, "`leader` must be a string")),
|
||||
};
|
||||
let (label, max) = ("`leader`", TabStop::MAX_LEADER);
|
||||
if leader.chars().count() > max {
|
||||
return Err(bad(C, format!("{label} must be at most {max} characters")));
|
||||
}
|
||||
.chars()
|
||||
.take(8)
|
||||
.collect();
|
||||
let align_on = match s.get("alignOn") {
|
||||
None | Some(Value::Null) => '.',
|
||||
Some(Value::String(value)) => value.chars().next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?,
|
||||
Some(Value::String(value)) => {
|
||||
let mut chars = value.chars();
|
||||
let c = chars.next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?;
|
||||
if chars.next().is_some() {
|
||||
let label = "`alignOn`";
|
||||
return Err(bad(C, format!("{label} must be one character")));
|
||||
}
|
||||
c
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`alignOn` must be a string")),
|
||||
};
|
||||
stops.push(TabStop { position, align, leader, align_on });
|
||||
stops.push(TabStop { position, align, leader: leader.to_string(), align_on });
|
||||
}
|
||||
stops.sort_by(|a, b| a.position.total_cmp(&b.position));
|
||||
stops.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6);
|
||||
@@ -128,7 +141,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -139,6 +152,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A leader of more than eight characters, an `alignOn` of more than one character and a list
|
||||
/// of more than 256 stops are errors, not cut short.
|
||||
#[test]
|
||||
fn long_leaders_long_align_on_and_too_many_stops_are_errors() {
|
||||
let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string();
|
||||
assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters"));
|
||||
assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character"));
|
||||
let many: Vec<Value> = (0..257).map(|i| json!({"position": i})).collect();
|
||||
assert!(err(Value::from(many.clone())).contains("at most 256 tab stops"));
|
||||
let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap();
|
||||
assert_eq!(ok.len(), 256);
|
||||
let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap();
|
||||
assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_get_clear_tabs() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef};
|
||||
use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TabStop, TextStyleDef};
|
||||
|
||||
use super::clipboard::resources::import_text_styles;
|
||||
use super::typecmd::{refresh_bounds, text_targets};
|
||||
@@ -125,7 +125,7 @@ macro_rules! style_cmds {
|
||||
}
|
||||
|
||||
/// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`.
|
||||
const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind";
|
||||
const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new or paraStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = style_cmds!(
|
||||
@@ -148,13 +148,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"paraStyle",
|
||||
"Paragraph Styles",
|
||||
"Paragraph",
|
||||
"; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt"
|
||||
"; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position"
|
||||
));
|
||||
v.push(spec(
|
||||
"paraStyle.edit",
|
||||
"Edit Paragraph Style",
|
||||
&[],
|
||||
"{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt",
|
||||
"{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position",
|
||||
|s, p| edit_style(s, p, Kind::Para),
|
||||
true,
|
||||
));
|
||||
@@ -569,8 +569,9 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma
|
||||
char_attrs(&st)
|
||||
}
|
||||
Kind::Para => {
|
||||
let st = with_attrs(&ParaStyle::default(), m, &c)?;
|
||||
let mut st = with_attrs(&ParaStyle::default(), m, &c)?;
|
||||
para_ranges(&st, m, kept, &c)?;
|
||||
sort_tabs(&mut st, m, kept);
|
||||
para_attrs(&st)
|
||||
}
|
||||
};
|
||||
@@ -587,7 +588,8 @@ const PARA_DISTANCE: f64 = 1296.0;
|
||||
/// Check the paragraph attributes of `st` (read from `attrs`) that differ from `kept` against the
|
||||
/// ranges `text.setFormat` keeps them to: the indents and the space before and after, each
|
||||
/// Hyphenation option, each Justification option (Minimum, Desired and Maximum in order, as
|
||||
/// `attrs` gives them) and the position of each tab stop. A value `kept` has is not checked.
|
||||
/// `attrs` gives them), and the number of tab stops with the position and leader of each stop
|
||||
/// `kept` does not hold. A value `kept` has is not checked.
|
||||
fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>, c: &str) -> Result<()> {
|
||||
let within = |key: &str, value: f64, lo: f64, hi: f64| {
|
||||
if (lo..=hi).contains(&value) { Ok(()) } else { Err(bad(c, format!("`{key}` must be from {lo} to {hi}"))) }
|
||||
@@ -644,18 +646,37 @@ fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<Str
|
||||
}
|
||||
}
|
||||
if changed("tabs") {
|
||||
let kept_tabs: Vec<f64> = kept
|
||||
.and_then(|k| k.get("tabs"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|stops| stops.iter().filter_map(|s| s.get("position").and_then(Value::as_f64)).collect())
|
||||
.unwrap_or_default();
|
||||
for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(&stop.position)) {
|
||||
within("tabs.position", stop.position, 0.0, 100_000.0)?;
|
||||
let kept_tabs = kept_tab_stops(kept);
|
||||
let max = TabStop::MAX_STOPS;
|
||||
if st.tabs.len() > max.max(kept_tabs.len()) {
|
||||
return Err(bad(c, format!("at most {max} tab stops")));
|
||||
}
|
||||
for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(stop)) {
|
||||
within("tabs.position", stop.position, 0.0, TabStop::MAX_POSITION)?;
|
||||
let (label, max) = ("`tabs.leader`", TabStop::MAX_LEADER);
|
||||
if stop.leader.chars().count() > max {
|
||||
return Err(bad(c, format!("{label} must be at most {max} characters")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tab stops `kept` holds, each as it reads (a stop that does not read is left out).
|
||||
fn kept_tab_stops(kept: Option<&Map<String, Value>>) -> Vec<TabStop> {
|
||||
let stops = kept.and_then(|k| k.get("tabs")).and_then(Value::as_array);
|
||||
stops.into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect()
|
||||
}
|
||||
|
||||
/// `st`'s tab stops sorted by position, keeping the first of stops within 1e-6 pt of each other,
|
||||
/// as `text.tabs.set` keeps them, when `attrs` changes them from `kept`.
|
||||
fn sort_tabs(st: &mut ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>) {
|
||||
if attrs.contains_key("tabs") && kept.and_then(|k| k.get("tabs")) != attrs.get("tabs") {
|
||||
st.tabs.sort_by(|a, b| a.position.total_cmp(&b.position));
|
||||
st.tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a
|
||||
/// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes.
|
||||
fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> {
|
||||
@@ -1011,10 +1032,29 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
|
||||
|
||||
/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps
|
||||
/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes,
|
||||
/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name
|
||||
/// is empty, which are skipped. → (the styles, the names in `src` of those skipped)
|
||||
/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, whose
|
||||
/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range
|
||||
/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in
|
||||
/// `src` of those skipped)
|
||||
fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) {
|
||||
const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"];
|
||||
const RANGED: [&str; 16] = [
|
||||
"size",
|
||||
"h_scale",
|
||||
"v_scale",
|
||||
"leading",
|
||||
"baseline_shift",
|
||||
"tcyUpDown",
|
||||
"tcyLeftRight",
|
||||
"stroke_width",
|
||||
"left_indent",
|
||||
"right_indent",
|
||||
"first_line_indent",
|
||||
"space_before",
|
||||
"space_after",
|
||||
"hyphenation",
|
||||
"justification",
|
||||
"tabs",
|
||||
];
|
||||
let (mut defs, mut skipped) = (vec![], vec![]);
|
||||
for def in kind.defs(src) {
|
||||
let name = def.name.as_str();
|
||||
|
||||
@@ -1729,3 +1729,77 @@ fn paragraph_style_attributes_are_checked_before_a_definition_changes() {
|
||||
assert!(error.contains(&format!("`{key}`")), "{attrs}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph style's changed tab stops are checked stop by stop: a stop the style holds is kept
|
||||
/// as it is, and a stop at a held position with another alignment is a changed stop. A changed
|
||||
/// stop's leader has at most eight characters and its Align On one character, a changed list has
|
||||
/// at most 256 stops (or as many as the style holds, here 300), and the stops are stored sorted by position,
|
||||
/// keeping the first of stops at one position.
|
||||
#[test]
|
||||
fn paragraph_style_tab_stops_are_checked_by_stop_and_sorted() {
|
||||
let (mut s, _) = session_with_text("Text");
|
||||
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap();
|
||||
let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Body").unwrap().attrs.clone();
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let held: Vec<Value> =
|
||||
(0..300).map(|i| json!({"position": 200000.0 + f64::from(i), "align": "left", "leader": "123456789", "alignOn": "."})).collect();
|
||||
doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), Value::from(held.clone()));
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let mut moved = held.clone();
|
||||
moved[0]["align"] = json!("right");
|
||||
for (stops, why) in [
|
||||
(Value::from(moved), "`tabs.position`"),
|
||||
(json!([{"position": 36.0, "leader": "123456789"}]), "`tabs.leader` must be at most 8 characters"),
|
||||
(Value::from((0..301).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>()), "at most 256 tab stops"),
|
||||
(json!([{"position": 36.0, "alignOn": ",."}]), "expected a character"),
|
||||
] {
|
||||
for cmd in ["paraStyle.edit", "paraStyle.setAttrs"] {
|
||||
let params = match cmd {
|
||||
"paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": {"tabs": stops}}),
|
||||
_ => json!({"name": "Body", "attrs": {"tabs": stops}}),
|
||||
};
|
||||
let error = s.execute(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains(why), "{cmd}: {error}");
|
||||
}
|
||||
}
|
||||
let many = Value::from((0..257).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>());
|
||||
let error = s.execute("paraStyle.new", &json!({"name": "Other", "attrs": {"tabs": many}})).unwrap_err().to_string();
|
||||
assert!(error.contains("at most 256 tab stops"), "{error}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// The held stops stay while one is dropped and another attribute changes.
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": &held[1..], "left_indent": 4.0}})).unwrap();
|
||||
assert_eq!(def(&s)["tabs"].as_array().map(Vec::len), Some(299));
|
||||
let stops = json!([{"position": 72.0, "align": "right"}, {"position": 36.0}, {"position": 36.0000001, "align": "center"}, {"position": 0.0, "leader": "12345678"}]);
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap();
|
||||
assert_eq!(
|
||||
def(&s)["tabs"],
|
||||
json!([{"position": 0.0, "align": "left", "leader": "12345678", "alignOn": "."}, {"position": 36.0, "align": "left", "alignOn": "."},
|
||||
{"position": 72.0, "align": "right", "alignOn": "."}])
|
||||
);
|
||||
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": []}})).unwrap();
|
||||
assert_eq!(def(&s)["tabs"], json!([]), "an empty list stays set");
|
||||
}
|
||||
|
||||
/// Load Paragraph Styles and Load All Styles skip a paragraph style whose indents, spacing,
|
||||
/// Hyphenation or Justification options or tab stops are out of the range `paraStyle.new` takes,
|
||||
/// and load the others with their stops.
|
||||
#[test]
|
||||
fn loading_skips_paragraph_styles_out_of_range() {
|
||||
let file = [
|
||||
("Good", json!({"left_indent": 6.0, "tabs": [{"position": 36.0, "align": "decimal", "alignOn": ","}]})),
|
||||
("Indent", json!({"left_indent": 2000.0})),
|
||||
("Space", json!({"space_after": -1300.0})),
|
||||
("Hyphens", json!({"hyphenation": {"words_longer_than": 40}})),
|
||||
("Spacing", json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}})),
|
||||
("Leader", json!({"tabs": [{"position": 10.0, "leader": "123456789"}]})),
|
||||
];
|
||||
let data = styles_file(&[], &file);
|
||||
for cmd in ["paraStyle.load", "textStyle.loadAll"] {
|
||||
let (mut s, _) = session_with_text("Title");
|
||||
let r = s.execute(cmd, &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
|
||||
let r = if cmd == "textStyle.loadAll" { r["paraStyles"].clone() } else { r };
|
||||
assert_eq!(r, json!({"added": ["Good"], "existing": [], "skipped": ["Indent", "Space", "Hyphens", "Spacing", "Leader"]}), "{cmd}");
|
||||
let good = super::style_attrs(&s.doc().unwrap().doc, true, "Good").unwrap();
|
||||
assert_eq!(good["tabs"], json!([{"position": 36.0, "align": "decimal", "alignOn": ","}]), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,8 @@ pub struct Session {
|
||||
pub(crate) press_point: Option<Option<(usize, cmd::gradient::StopOwner)>>,
|
||||
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
|
||||
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
|
||||
/// The rulers' origin being dragged out of their corner ([`Session::ruler_origin_drag`]).
|
||||
pub(crate) ruler_origin: Option<vectorcraft_tools::rulerguide::OriginDrag>,
|
||||
/// Which selected type overflows its frame, for the ports every tool but the Type tools shows.
|
||||
pub(crate) port_marks: vectorcraft_tools::text::PortMarks,
|
||||
/// The last search for the files of missing fonts (`text.findFontFiles`), until another
|
||||
|
||||
@@ -123,6 +123,7 @@ impl Session {
|
||||
press_type_selection: None,
|
||||
press_point: None,
|
||||
ruler_guide: None,
|
||||
ruler_origin: None,
|
||||
port_marks: Default::default(),
|
||||
font_search: None,
|
||||
link_search: None,
|
||||
|
||||
@@ -82,7 +82,13 @@ impl Session {
|
||||
&& params.get("ids").is_none_or(Value::is_null)
|
||||
&& params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$'));
|
||||
if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() {
|
||||
cmd::given_objects(self, params, id)?;
|
||||
// `effect.refreshDeferred` looks its objects up itself: they can also be in the art of
|
||||
// a symbol, pattern, brush or opacity mask.
|
||||
if id == "effect.refreshDeferred" {
|
||||
cmd::given_object_ids(params, id)?;
|
||||
} else {
|
||||
cmd::given_objects(self, params, id)?;
|
||||
}
|
||||
}
|
||||
if SELECTS_GIVEN_IDS.contains(&id) {
|
||||
self.check_given_ids(id, params)?;
|
||||
|
||||
@@ -253,6 +253,118 @@ fn selection_art_is_the_art_new_brush_takes() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "only brush.new is an undo step");
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> Vec<NodeId> {
|
||||
s.doc().unwrap().selection.objects.clone()
|
||||
}
|
||||
|
||||
/// The JSON of object `id`, as `brush.options` params take art.
|
||||
fn art_json(s: &mut Session, id: NodeId) -> Value {
|
||||
s.execute("brush.selectionArt", &json!({"ids": [id.0]})).unwrap()["art"].clone()
|
||||
}
|
||||
|
||||
/// `brush.placeArt` places a copy of an art or scatter brush's art centered where it is dropped
|
||||
/// (by default on the first artboard's center), selected, in one undo step; brushes without art,
|
||||
/// unknown brushes and a position that is not a number are refused.
|
||||
#[test]
|
||||
fn place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0);
|
||||
s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap();
|
||||
let steps = undo_steps(&s);
|
||||
let id = NodeId(s.execute("brush.placeArt", &json!({"name": "Bar", "x": 100, "y": 80})).unwrap()["id"].as_u64().unwrap());
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
assert_eq!(selected(&s), vec![id]);
|
||||
assert_ne!(id, r, "a copy, not the art the brush was made from");
|
||||
assert!(close(bounds(&s, id), Rect::new(80.0, 76.0, 120.0, 84.0), 1e-6), "{:?}", bounds(&s, id));
|
||||
// Without a position: the first artboard's center (the document is 400 × 300).
|
||||
let scatter = NodeId(s.execute("brush.placeArt", &json!({"name": "Dots"})).unwrap()["id"].as_u64().unwrap());
|
||||
let c = bounds(&s, scatter).center();
|
||||
assert!((c.x - 200.0).abs() < 1e-6 && (c.y - 150.0).abs() < 1e-6, "{c:?}");
|
||||
let steps = undo_steps(&s);
|
||||
for (p, msg) in [
|
||||
(json!({"name": "3 pt. Round"}), "`3 pt. Round` has no art (only scatter, art and pattern brushes do)"),
|
||||
(json!({"name": "Bristle Round"}), "`Bristle Round` has no art (only scatter, art and pattern brushes do)"),
|
||||
(json!({"name": "Nope"}), "no brush named `Nope`"),
|
||||
] {
|
||||
let e = s.execute("brush.placeArt", &p).unwrap_err();
|
||||
assert!(e.to_string().contains(msg), "{p}: {e}");
|
||||
}
|
||||
assert!(s.execute("brush.placeArt", &json!({"name": "Bar", "x": "100"})).is_err());
|
||||
assert_eq!(undo_steps(&s), steps, "a refused call adds no undo step");
|
||||
}
|
||||
|
||||
/// Brush art holding an instance of a symbol whose art lies away from the symbol's origin (as
|
||||
/// imported symbols can) is centered by that art, not by the box an instance has without its
|
||||
/// document.
|
||||
#[test]
|
||||
fn placed_art_with_a_symbol_instance_is_centered_by_the_symbols_art() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 300.0, 10.0, 100.0, 40.0);
|
||||
let art = node(&s, r);
|
||||
s.execute("edit.clear", &json!({"ids": [r.0]})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let d = std::sync::Arc::make_mut(&mut st.doc);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Off".into(), art: std::sync::Arc::new(art) });
|
||||
let layer = d.layers[0].id;
|
||||
let inst = d.alloc_id();
|
||||
let xf = vectorcraft_geom::Affine::translate((50.0, 50.0));
|
||||
d.insert(Some(layer), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Off".into(), xf })).unwrap();
|
||||
let inst = inst.0;
|
||||
s.execute("brush.new", &json!({"type": "art", "name": "Stamp", "ids": [inst]})).unwrap();
|
||||
let id = NodeId(s.execute("brush.placeArt", &json!({"name": "Stamp", "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap());
|
||||
let st = s.doc().unwrap();
|
||||
let b = st.doc.bounds_of(&[id], false).unwrap();
|
||||
assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 150.0).abs() < 1e-6, "{b:?}");
|
||||
assert!((b.width() - 100.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
/// A pattern brush with side, outer corner and start tiles, the side tile with a repeat frame
|
||||
/// wider than its art: → its name.
|
||||
fn framed_pattern_brush(s: &mut Session) -> String {
|
||||
let side = rect(s, 0.0, 0.0, 20.0, 10.0);
|
||||
let corner = rect(s, 0.0, 40.0, 10.0, 10.0);
|
||||
let start = rect(s, 0.0, 80.0, 6.0, 10.0);
|
||||
let name = s.execute("brush.new", &json!({"type": "pattern", "name": "Rail", "ids": [side.0]})).unwrap()["name"].as_str().unwrap().to_string();
|
||||
let params = json!({
|
||||
"side": art_json(s, side),
|
||||
"side_frame": Rect::new(-2.0, 0.0, 22.0, 10.0),
|
||||
"outer_corner": art_json(s, corner),
|
||||
"start": art_json(s, start),
|
||||
});
|
||||
s.execute("brush.options", &json!({"name": name, "params": params})).unwrap();
|
||||
name
|
||||
}
|
||||
|
||||
/// `brush.placeArt` lays a pattern brush's tiles out left to right, each in a group named for its
|
||||
/// tile, with an unpainted rectangle at the back of a tile with a repeat frame marking that frame.
|
||||
#[test]
|
||||
fn pattern_brushes_place_their_tiles_in_a_row() {
|
||||
let mut s = session();
|
||||
let name = framed_pattern_brush(&mut s);
|
||||
let id = NodeId(s.execute("brush.placeArt", &json!({"name": name, "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap());
|
||||
let placed = node(&s, id);
|
||||
let tiles: Vec<Node> = placed.children().unwrap().iter().map(|c| (**c).clone()).collect();
|
||||
let names: Vec<&str> = tiles.iter().map(|t| t.name.as_deref().unwrap_or("")).collect();
|
||||
assert_eq!(names, ["Side Tile", "Outer Corner Tile", "Start Tile"]);
|
||||
let boxes: Vec<Rect> = tiles.iter().map(|t| t.geometric_bounds().unwrap()).collect();
|
||||
assert!(boxes.windows(2).all(|w| (w[1].x0 - w[0].x1 - 10.0).abs() < 1e-6), "10 pt apart, left to right: {boxes:?}");
|
||||
// The side tile: the frame's rectangle, unpainted, behind the art.
|
||||
let side = tiles[0].children().unwrap();
|
||||
assert_eq!(side.len(), 2);
|
||||
let marker = &side[0];
|
||||
assert!(marker.appearance.items.iter().all(|i| match i {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.is_none(),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(st) => st.paint.is_none(),
|
||||
}));
|
||||
let frame = marker.geometric_bounds().unwrap();
|
||||
assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.height() - 10.0).abs() < 1e-6, "{frame:?}");
|
||||
assert_eq!(tiles[1].children().unwrap().len(), 1, "no frame, no rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_options_rename_duplicate_and_delete_update_strokes() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -4489,6 +4489,41 @@ fn cmd_ruler_drags_make_guides_of_the_active_artboard_when_the_preference_is_on(
|
||||
assert_eq!(owners(&mut s)[4..], [Value::Null, json!(1)]);
|
||||
}
|
||||
|
||||
/// The rulers' origin dragged out of their corner snaps as a drawn point does: onto a shape's
|
||||
/// corner and edge, the artboard's centre, and with Snap to Point along a ruler guide; with
|
||||
/// nothing on it stays at the pointer. The drag changes nothing, and its guides go when it ends.
|
||||
#[test]
|
||||
fn the_ruler_origin_dragged_from_the_corner_snaps_as_a_drawn_point() {
|
||||
use vectorcraft_geom::Point;
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap();
|
||||
let (n, doc) = (undo_len(&s), s.doc().unwrap().doc.clone());
|
||||
let v = ViewInfo::default();
|
||||
let at = |s: &mut Session, x, y| s.ruler_origin_drag(Some(Point::new(x, y)), v).unwrap();
|
||||
assert_eq!(at(&mut s, 101.5, 98.5), Point::new(100.0, 100.0), "the corner");
|
||||
assert!(!s.overlays(v).is_empty(), "the guide showing it");
|
||||
assert_eq!(at(&mut s, 130.0, 202.0), Point::new(130.0, 200.0), "the bottom edge");
|
||||
assert_eq!(at(&mut s, 398.0, 301.5), Point::new(400.0, 300.0), "the artboard's centre");
|
||||
assert_eq!(at(&mut s, 302.0, 50.0).x, 300.0, "along the ruler guide");
|
||||
assert_eq!(s.ruler_origin_drag(None, v), None, "off the canvas");
|
||||
assert!(s.overlays(v).is_empty());
|
||||
assert_eq!(at(&mut s, 101.5, 98.5), Point::new(100.0, 100.0));
|
||||
s.end_ruler_origin_drag();
|
||||
assert!(s.overlays(v).is_empty());
|
||||
assert_eq!((undo_len(&s), &*s.doc().unwrap().doc), (n, &*doc), "nothing changed");
|
||||
// Nothing on: it stays at the pointer.
|
||||
let off = ViewInfo { smart_guides: false, snap_to_point: false, ..v };
|
||||
assert_eq!(s.ruler_origin_drag(Some(Point::new(101.5, 98.5)), off), Some(Point::new(101.5, 98.5)));
|
||||
s.end_ruler_origin_drag();
|
||||
// Snap to Grid: onto a gridline crossing.
|
||||
let grid = ViewInfo { snap_to_grid: true, ..v };
|
||||
let p = s.ruler_origin_drag(Some(Point::new(101.5, 98.5)), grid).unwrap();
|
||||
s.end_ruler_origin_drag();
|
||||
assert_eq!(p, Point::new(p.x.round(), p.y.round()), "{p:?}");
|
||||
assert_ne!(p, Point::new(101.5, 98.5));
|
||||
}
|
||||
|
||||
/// Guides saved before guides had layers are put on a layer when the document opens.
|
||||
#[test]
|
||||
fn guides_from_older_files_are_put_on_a_layer() {
|
||||
|
||||
@@ -280,6 +280,103 @@ fn a_missing_link_is_reported_not_copied() {
|
||||
assert!(report.contains("blue.png: not found"), "{report}");
|
||||
}
|
||||
|
||||
/// Packaged for a client whose read root holds neither the document's file nor its links: the report,
|
||||
/// its Document Info part included, names them by their file names, and `document.info` gives no
|
||||
/// link. The person's report names them whole.
|
||||
#[test]
|
||||
fn a_confined_package_names_files_outside_the_read_root_by_name() {
|
||||
let dir = Folder::new("package-roots");
|
||||
let mut s = poster(&dir);
|
||||
std::fs::create_dir_all(dir.file("in")).unwrap();
|
||||
std::fs::remove_file(dir.file("art/blue.png")).unwrap();
|
||||
open(&mut s, &dir.file("poster.vectorcraft"));
|
||||
let report = |r: &Value| {
|
||||
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
|
||||
String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap()
|
||||
};
|
||||
let person = report(&run(&mut s, "file.package", json!({})));
|
||||
assert!(person.contains(&format!("From: {}\n", dir.file("poster.vectorcraft"))), "{person}");
|
||||
assert!(person.contains(&format!("{} → Links/red.png", dir.file("art/red.png"))), "{person}");
|
||||
assert!(person.contains(&format!("blue.png: not found ({})", dir.file("art/blue.png"))), "{person}");
|
||||
|
||||
let in_root = dir.0.join("in");
|
||||
let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap();
|
||||
let client = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
|
||||
let text = report(&client);
|
||||
assert!(text.contains("From: poster.vectorcraft\n"), "{text}");
|
||||
assert!(text.contains("\nred.png → Links/red.png\n") && text.contains("\nblue.png: not found (blue.png), not copied\n"), "{text}");
|
||||
let base = dir.0.to_string_lossy().into_owned();
|
||||
assert!(!text.contains(&base), "{text}");
|
||||
for key in ["missingLinks", "warnings", "files"] {
|
||||
assert!(!client[key].to_string().contains(&base), "{key}: {client}");
|
||||
}
|
||||
// Document Info, whose report the package holds, gives no link either.
|
||||
for p in [json!({}), json!({"format": "text"})] {
|
||||
let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", p.clone()));
|
||||
assert!(!info.to_string().contains(&base), "{p}: {info}");
|
||||
}
|
||||
let info = run(&mut s, "document.info", json!({"format": "text"}));
|
||||
assert!(info.to_string().contains(&dir.file("art/red.png")), "the person's: {info}");
|
||||
}
|
||||
|
||||
/// A missing link stored as a path relative to the document names a file inside a confined
|
||||
/// request's read root: the package report and Document Info give its stored path.
|
||||
#[test]
|
||||
fn a_relative_link_inside_the_read_root_keeps_its_path() {
|
||||
let dir = Folder::new("package-roots-relative");
|
||||
write(&dir.file("art/near.png"), &png(8, 8, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &dir.file("art/near.png"));
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") };
|
||||
im.link = Some(vectorcraft_doc::LinkInfo::new("art/near.png"));
|
||||
write(&dir.file("near.vectorcraft"), &vectorcraft_format::save(&doc, false));
|
||||
// Missing, the link keeps its stored path when the document opens (the package copies the
|
||||
// image the document holds).
|
||||
std::fs::remove_file(dir.file("art/near.png")).unwrap();
|
||||
open(&mut s, &dir.file("near.vectorcraft"));
|
||||
let roots = crate::file_access::AutomationRoots::new(&[dir.0.as_path()], &[]).unwrap();
|
||||
let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
|
||||
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
|
||||
let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap();
|
||||
assert!(text.contains("\nart/near.png → Links/near.png\n"), "{text}");
|
||||
let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({})));
|
||||
let links: Vec<&Value> = info["images"].as_object().unwrap().values().map(|i| &i["link"]).collect();
|
||||
assert_eq!(links, [&json!("art/near.png")], "{info}");
|
||||
let text = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({"format": "text"})));
|
||||
assert!(text.to_string().contains("art/near.png"), "{text}");
|
||||
}
|
||||
|
||||
/// A link whose stored path names a place outside a confined request's read root, with its file
|
||||
/// found by name beside the document inside the root: the package report and reply give the file
|
||||
/// name, not the stored path.
|
||||
#[test]
|
||||
fn a_stored_path_outside_the_read_root_is_left_out_when_its_file_is_found_inside() {
|
||||
let dir = Folder::new("package-roots-stale");
|
||||
write(&dir.file("in/near.png"), &png(8, 8, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &dir.file("in/near.png"));
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") };
|
||||
let stale = dir.file("out/near.png");
|
||||
im.link = Some(vectorcraft_doc::LinkInfo::new(&stale));
|
||||
write(&dir.file("in/near.vectorcraft"), &vectorcraft_format::save(&doc, false));
|
||||
// Missing when the document opens, the link keeps its stored path; the file then appears beside it.
|
||||
std::fs::remove_file(dir.file("in/near.png")).unwrap();
|
||||
open(&mut s, &dir.file("in/near.vectorcraft"));
|
||||
write(&dir.file("in/near.png"), &png(8, 8, RED));
|
||||
let in_root = dir.0.join("in");
|
||||
let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap();
|
||||
let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
|
||||
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
|
||||
let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap();
|
||||
assert!(text.contains("\nnear.png → Links/near.png\n"), "{text}");
|
||||
assert!(!text.contains(&stale), "{text}");
|
||||
for key in ["missingLinks", "warnings", "files"] {
|
||||
assert!(!r[key].to_string().contains(&stale), "{key}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file missing from a document that moved keeps the place its relative path names from the
|
||||
/// document's folder: the packaged document writes its relative path from there.
|
||||
#[test]
|
||||
|
||||
@@ -1153,6 +1153,98 @@ fn deferred_stages_in_symbol_art_are_listed_and_refreshed() {
|
||||
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([]));
|
||||
}
|
||||
|
||||
/// `effect.refreshDeferred` takes the id of an object in the art of a symbol, pattern, brush or
|
||||
/// opacity mask, as `effect.deferredStatus` lists it: one with a deferred stage is refreshed, and
|
||||
/// one without (or whose stage is gone) adds nothing to the counts. The id of art that is no
|
||||
/// longer in the document is an error.
|
||||
#[test]
|
||||
fn refresh_takes_objects_in_definition_art() {
|
||||
let mut s = session();
|
||||
const FX: &str = "plugin.test.engine.deferred.nested";
|
||||
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.nested", "effect", "{}"), &out));
|
||||
// A symbol whose art holds an object with the effect and one without.
|
||||
let staged = rect(&mut s, 10.0, "#ff0000");
|
||||
let plain = rect(&mut s, 30.0, "#ff0000");
|
||||
run(&mut s, "effect.apply", json!({"ids": [staged.0], "effect": FX}));
|
||||
run(&mut s, "select.set", json!({"ids": [staged.0, plain.0]}));
|
||||
run(&mut s, "symbol.new", json!({"name": "Nested"}));
|
||||
// A pattern, an art brush (its source deleted) and an opacity mask, none with a stage.
|
||||
let tile = rect(&mut s, 50.0, "#00ff00");
|
||||
run(&mut s, "select.set", json!({"ids": [tile.0]}));
|
||||
run(&mut s, "object.pattern.make", json!({"name": "Tile", "width": 20, "height": 20}));
|
||||
run(&mut s, "object.pattern.done", json!({}));
|
||||
let bar = rect(&mut s, 70.0, "#0000ff");
|
||||
run(&mut s, "select.set", json!({"ids": [bar.0]}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Bar"}));
|
||||
run(&mut s, "edit.clear", json!({"ids": [bar.0]}));
|
||||
let masked = rect(&mut s, 90.0, "#ff0000");
|
||||
let mask = rect(&mut s, 90.0, "#ffffff");
|
||||
run(&mut s, "select.set", json!({"ids": [masked.0, mask.0]}));
|
||||
run(&mut s, "transparency.makeOpacityMask", json!({}));
|
||||
// A layer object with a stage that stays deferred, and one without.
|
||||
let kept = rect(&mut s, 130.0, "#ff0000");
|
||||
run(&mut s, "effect.apply", json!({"ids": [kept.0], "effect": FX}));
|
||||
let bare = rect(&mut s, 150.0, "#ff0000");
|
||||
|
||||
// The ids of the objects of `art` that are not in the layers.
|
||||
let nested = |s: &Session, art: &vectorcraft_doc::Node| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let mut ids = Vec::new();
|
||||
vectorcraft_doc::swatches::walk_with_masks(art, &mut |n| {
|
||||
if d.node(n.id).is_none() {
|
||||
ids.push(n.id.0);
|
||||
}
|
||||
});
|
||||
ids
|
||||
};
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let in_symbol = nested(&s, &d.symbols[0].art);
|
||||
let in_pattern = d.patterns.iter().flat_map(|p| p.art.iter().flat_map(|a| nested(&s, a))).collect::<Vec<_>>();
|
||||
let brushes = vectorcraft_brush::library(&d);
|
||||
let in_brush = brushes.iter().flat_map(|b| b.art().into_iter().flat_map(|a| nested(&s, a)).collect::<Vec<_>>()).collect::<Vec<_>>();
|
||||
let in_mask = d.node(masked).and_then(|n| n.extras().mask.as_ref()).map(|m| nested(&s, &m.art)).unwrap_or_default();
|
||||
for (what, ids) in [("symbol", &in_symbol), ("pattern", &in_pattern), ("brush", &in_brush), ("opacity mask", &in_mask)] {
|
||||
assert!(!ids.is_empty(), "the {what} art has an object outside the layers");
|
||||
}
|
||||
|
||||
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
|
||||
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
|
||||
s.doc_mut().unwrap().plugin_effects = fx.clone();
|
||||
fx.view(|| drawn(&s));
|
||||
fx.first_view_done();
|
||||
let listed = |s: &mut Session| {
|
||||
run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::<Vec<_>>()
|
||||
};
|
||||
let deferred = listed(&mut s);
|
||||
let [nested_staged, last] = deferred[..] else { panic!("two stages: {deferred:?}") };
|
||||
assert!(in_symbol.contains(&nested_staged) && last == kept.0, "{deferred:?} {in_symbol:?}");
|
||||
let Some(&nested_plain) = in_symbol.iter().find(|i| **i != nested_staged) else { panic!("{in_symbol:?}") };
|
||||
|
||||
// Objects without a stage, each on its own and together: nothing to refresh.
|
||||
let none = json!({"refreshed": 0, "stillDeferred": 0, "failed": 0});
|
||||
let mut plain_ids = vec![nested_plain, in_pattern[0], in_brush[0], in_mask[0], bare.0];
|
||||
for id in &plain_ids {
|
||||
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": id})), none, "object {id}");
|
||||
}
|
||||
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), none);
|
||||
assert_eq!(listed(&mut s), [nested_staged, kept.0]);
|
||||
|
||||
// The staged object of the symbol's art, by its id, with the others.
|
||||
step.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
plain_ids.push(nested_staged);
|
||||
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0}));
|
||||
assert_eq!(listed(&mut s), [kept.0]);
|
||||
// Its stage is gone; the object is still there.
|
||||
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": nested_staged})), none);
|
||||
|
||||
// Art no longer in the document, and an id of no object.
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns.clear();
|
||||
assert!(s.execute("effect.refreshDeferred", &json!({"id": in_pattern[0]})).is_err(), "the pattern is gone");
|
||||
assert!(s.execute("effect.refreshDeferred", &json!({"ids": [nested_plain, 999_999]})).is_err(), "not an object of the document");
|
||||
assert_eq!(listed(&mut s), [kept.0]);
|
||||
}
|
||||
|
||||
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
|
||||
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
|
||||
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
|
||||
|
||||
@@ -483,6 +483,105 @@ fn the_folder_store_reads_no_linked_entries() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// A file where a folder of the store's path should be: the error names that file, not the area
|
||||
/// folder or the copy below it, for a write, a lock, a read, a listing, a removal and a recovery
|
||||
/// save.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn folder_errors_name_a_file_in_the_way() {
|
||||
let base = std::env::temp_dir().join(format!("vc-recovery-file-in-way-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let file = base.join("file");
|
||||
std::fs::write(&file, b"").unwrap();
|
||||
let folder = file.join("recovery");
|
||||
let store = FolderStore::new(&folder);
|
||||
let mut s = Session::new();
|
||||
s.prefs.recovery_folder = folder.to_string_lossy().to_string();
|
||||
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
let errors = [
|
||||
("write", store.write("1-1/Untitled-1-1.vectorcraft", b"copy").err()),
|
||||
("lock", store.lock("1-2").err()),
|
||||
("read", store.read("1-1/Untitled-1-1.vectorcraft").err()),
|
||||
("list", store.list().err()),
|
||||
("remove", store.remove("1-1/Untitled-1-1.vectorcraft").err()),
|
||||
("save", s.execute("file.recovery.save", &json!({})).err().map(|e| e.to_string())),
|
||||
];
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let named = format!("{} is not a folder: ", file.display());
|
||||
let wrong: Vec<_> = errors.iter().filter(|(_, e)| !e.as_deref().is_some_and(|e| e.contains(&named))).collect();
|
||||
assert!(wrong.is_empty(), "{wrong:?}");
|
||||
}
|
||||
|
||||
/// A removal that finds the copy missing because a file is on the way to it (how Windows reports a
|
||||
/// file in the way) names that file. A copy that is just not there still counts as removed.
|
||||
#[test]
|
||||
fn a_removal_reported_missing_names_a_file_in_the_way() {
|
||||
let base = std::env::temp_dir().join(format!("vc-recovery-remove-in-way-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let file = base.join("file");
|
||||
std::fs::write(&file, b"").unwrap();
|
||||
let missing = || std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
let behind = FolderStore::removed(&file.join("recovery").join("1-1").join("Untitled-1-1.vectorcraft"), Err(missing()));
|
||||
let gone = FolderStore::removed(&base.join("recovery").join("1-1").join("Untitled-1-1.vectorcraft"), Err(missing()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let named = format!("{} is not a folder: ", file.display());
|
||||
assert!(behind.as_ref().is_err_and(|e| e.starts_with(&named)), "{behind:?}");
|
||||
assert_eq!(gone, Ok(()));
|
||||
}
|
||||
|
||||
/// A folder of the store's path that is missing and can't be made: a write or a lock names it as
|
||||
/// the folder it couldn't make, and a read names it as missing. An area folder that can't be made
|
||||
/// in a store folder that is there is named the same way.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn folder_errors_name_a_missing_folder_that_cannot_be_made() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let base = std::env::temp_dir().join(format!("vc-recovery-missing-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let closed = base.join("closed");
|
||||
std::fs::create_dir_all(&closed).unwrap();
|
||||
std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
let gone = closed.join("gone");
|
||||
let store = FolderStore::new(gone.join("recovery"));
|
||||
let unmade = format!("couldn't make {}: ", gone.display());
|
||||
let area = FolderStore::new(&closed).write("1-1/Untitled-1-1.vectorcraft", b"copy").err();
|
||||
let errors = [
|
||||
("write", store.write("1-1/Untitled-1-1.vectorcraft", b"copy").err(), unmade.clone()),
|
||||
("lock", store.lock("1-2").err(), unmade),
|
||||
("read", store.read("1-1/Untitled-1-1.vectorcraft").err(), format!("{} is missing", gone.display())),
|
||||
("area", area, format!("couldn't make {}: ", closed.join("1-1").display())),
|
||||
];
|
||||
std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
// An account that can write anywhere (root) makes the folders, so the test has nothing to check.
|
||||
if errors[0].1.is_none() {
|
||||
return;
|
||||
}
|
||||
let wrong: Vec<_> = errors.iter().filter(|(_, e, named)| !e.as_deref().is_some_and(|e| e.starts_with(named.as_str()))).collect();
|
||||
assert!(wrong.is_empty(), "{wrong:?}");
|
||||
}
|
||||
|
||||
/// When every folder on the way is there, an error names the folder it happened on.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn folder_errors_name_the_folder_when_every_folder_is_there() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let base = std::env::temp_dir().join(format!("vc-recovery-unreadable-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let unreadable = base.join("unreadable");
|
||||
std::fs::create_dir_all(&unreadable).unwrap();
|
||||
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o300)).unwrap();
|
||||
let listed = FolderStore::new(&unreadable).list();
|
||||
std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
// An account that can read any folder (root) lists it, so the test has nothing to check.
|
||||
let Err(err) = listed else { return };
|
||||
assert!(err.starts_with(&format!("{}: ", unreadable.display())), "{err}");
|
||||
}
|
||||
|
||||
/// A store in memory that notes each removal of an area (its lock file and folder, then its empty
|
||||
/// folder) and whether the area was still held then.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -40,6 +40,50 @@ fn objects(s: &Session) -> usize {
|
||||
s.doc().unwrap().doc.node_count()
|
||||
}
|
||||
|
||||
/// A client confined by automation roots: a save without a path writes the document's own file, and
|
||||
/// when that file is outside the read roots the reply leaves out its `path` and names the files
|
||||
/// written beside it by their file names. A reply without a path leaves out a suggested `folder`
|
||||
/// outside them. A path inside the read roots, and one the client gives, stay whole.
|
||||
#[test]
|
||||
fn a_confined_save_leaves_out_paths_outside_the_read_root() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let d = dir("roots");
|
||||
let (inside, outside) = (d.join("in"), d.join("out"));
|
||||
for f in [&inside, &outside] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||||
rect(&mut s);
|
||||
let own = path(&outside, "doc.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": own})).unwrap();
|
||||
let person = s.execute("document.save", &json!({"separateArtboards": true})).unwrap();
|
||||
let files: Vec<String> = person["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
|
||||
assert_eq!((files.len(), files[0].as_str()), (3, own.as_str()), "{person}");
|
||||
|
||||
let write_only = AutomationRoots::new(&[inside.as_path()], &[outside.as_path()]).unwrap();
|
||||
let both = AutomationRoots::new(&[inside.as_path(), outside.as_path()], &[outside.as_path()]).unwrap();
|
||||
let run = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, id: &str, p: Value| {
|
||||
confine(roots.as_ref(), || s.execute(id, &p)).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
};
|
||||
let hidden = outside.to_string_lossy().into_owned();
|
||||
let r = run(&mut s, &write_only, "document.save", json!({}));
|
||||
assert!(r.get("path").is_none() && r["bytes"].as_u64().is_some_and(|n| n > 0), "{r}");
|
||||
assert!(!r.to_string().contains(&hidden), "{r}");
|
||||
let r = run(&mut s, &write_only, "document.save", json!({"separateArtboards": true}));
|
||||
let names: Vec<Value> = files.iter().map(|f| json!(std::path::Path::new(f).file_name().unwrap().to_string_lossy())).collect();
|
||||
assert_eq!((r.get("path"), r["files"].as_array()), (None, Some(&names)), "{r}");
|
||||
// Inside the read roots, or given by the client: whole.
|
||||
assert_eq!(run(&mut s, &both, "document.save", json!({}))["path"], json!(own));
|
||||
assert_eq!(run(&mut s, &write_only, "document.save", json!({"path": own}))["path"], json!(own));
|
||||
// A save without a path known suggests the document's folder.
|
||||
let r = run(&mut s, &write_only, "file.saveCopy", json!({}));
|
||||
assert!(r.get("folder").is_none() && r["name"] == "doc copy.vectorcraft" && r.get("dataBase64").is_some(), "{r}");
|
||||
assert_eq!(run(&mut s, &both, "file.saveCopy", json!({}))["folder"], json!(hidden));
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_svg_path_writes_svg_and_save_writes_it_back() {
|
||||
let d = dir("svg");
|
||||
|
||||
@@ -87,6 +87,87 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
|
||||
assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painted_and_sketched_styles_set_every_effect_setting_and_reopen_applied() {
|
||||
let mut s = session();
|
||||
let list = run(&mut s, "graphicStyle.libraries", json!({}));
|
||||
let libs = list["libraries"].as_array().unwrap();
|
||||
let at = libs.iter().position(|l| l["id"] == "painted-sketched").expect("listed");
|
||||
assert_eq!((libs[at]["name"].as_str(), libs[at]["count"].as_u64()), (Some("Painted and Sketched"), Some(7)));
|
||||
assert_eq!(libs[at - 1]["id"], "textures");
|
||||
let g = run(&mut s, "graphicStyle.library", json!({"library": "Painted and Sketched"}));
|
||||
let listed: Vec<(&str, &str, Vec<&str>)> =
|
||||
g["styles"].as_array().unwrap().iter().map(|x| (x["name"].as_str().unwrap(), x["fill"].as_str().unwrap(), names(&x["effects"]))).collect();
|
||||
let want = [
|
||||
("Washed Blue", "#8ab9c9", "artistic.watercolor"),
|
||||
("Bristle Ochre", "#c4975a", "artistic.dryBrush"),
|
||||
("Pastel Heather", "#a78ca5", "artistic.roughPastels"),
|
||||
("Charcoal Shade", "#666666", "sketch.charcoal"),
|
||||
("Ink Contour", "#d9e2df", "brushStrokes.inkOutlines"),
|
||||
("Wet Ink", "#637b85", "brushStrokes.sumiE"),
|
||||
("Fine Pen", "#d8d4c6", "sketch.graphicPen"),
|
||||
];
|
||||
assert_eq!(listed, want.iter().map(|&(n, f, e)| (n, f, vec![e])).collect::<Vec<_>>());
|
||||
// Every setting of each effect is given, with the catalog's key and kind of value.
|
||||
let (_, lib) = cmd::stylelib::library(&s, "painted-sketched").unwrap();
|
||||
for st in &lib.styles {
|
||||
let e = &st.appearance.effects[0];
|
||||
let defaults = vectorcraft_render::effects::effect_info(&e.id).unwrap().defaults;
|
||||
let (set, all) = (e.params.as_object().unwrap(), defaults.as_object().unwrap());
|
||||
let keys = |m: &serde_json::Map<String, Value>| m.keys().cloned().collect::<Vec<_>>();
|
||||
assert_eq!(keys(set), keys(all), "{}", st.name);
|
||||
for (k, v) in set {
|
||||
let kind = |v: &Value| (v.is_number(), v.is_string(), v.is_boolean());
|
||||
assert_eq!(kind(v), kind(&all[k]), "{} / {k}", st.name);
|
||||
}
|
||||
}
|
||||
let strokes: Vec<(&str, f64)> =
|
||||
lib.styles.iter().filter(|g| g.appearance.stroke_paint() != Paint::None).map(|g| (g.name.as_str(), g.appearance.stroke_width())).collect();
|
||||
assert_eq!(strokes, [("Bristle Ochre", 1.0), ("Ink Contour", 1.0)]);
|
||||
|
||||
// Each one adds and applies to a rectangle in one undo step.
|
||||
let mut ids = vec![];
|
||||
for (i, (name, fill, effect)) in want.iter().enumerate() {
|
||||
let id = rect(&mut s, 10.0 + 25.0 * i as f64, 20.0, 20.0, 30.0);
|
||||
let before = undo_len(&s);
|
||||
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": name, "apply": true, "ids": [id.0]}));
|
||||
assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![*name], Some(*name)));
|
||||
assert_eq!(undo_len(&s), before + 1, "{name}");
|
||||
let node = doc(&s).node(id).unwrap();
|
||||
assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex(fill).unwrap()), "{name}");
|
||||
assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), [*effect]);
|
||||
assert_eq!(node.appearance.effects[0].params, lib.style(name).unwrap().appearance.effects[0].params, "{name}");
|
||||
ids.push((id, *name));
|
||||
}
|
||||
// Adding one again reuses it: no new style, no undo step.
|
||||
let before = (undo_len(&s), doc(&s).graphic_styles.len());
|
||||
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Wet Ink"}));
|
||||
assert_eq!((names(&r["added"]), names(&r["existing"])), (vec![], vec!["Wet Ink"]));
|
||||
assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before);
|
||||
// An unknown style or library changes nothing.
|
||||
assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "painted-sketched", "names": ["Wet Ink", "Nope"]})).is_err());
|
||||
assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "painted-and-sketched", "name": "Wet Ink"})).is_err());
|
||||
assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before);
|
||||
|
||||
// Saved and opened again, the styles keep their effects and the rectangles stay linked to them.
|
||||
let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string();
|
||||
run(&mut s, "document.open", json!({"name": "painted.vectorcraft", "dataBase64": b64}));
|
||||
for (id, name) in &ids {
|
||||
let d = doc(&s);
|
||||
let style = d.graphic_style(name).unwrap();
|
||||
assert!(style.same_look(lib.style(name).unwrap()), "{name}");
|
||||
let node = d.node(*id).unwrap();
|
||||
assert_eq!((node.extras().graphic_style, &node.appearance.effects), (Some(style.id), &style.appearance.effects), "{name}");
|
||||
}
|
||||
|
||||
// With nothing selected, `apply` only adds; empty `names` adds the whole library in order.
|
||||
run(&mut s, "file.new", json!({"width": 100, "height": 100}));
|
||||
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Fine Pen", "apply": true}));
|
||||
assert!(r.get("applied").is_none() && doc(&s).graphic_style("Fine Pen").is_some());
|
||||
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "names": []}));
|
||||
assert_eq!((names(&r["added"]), names(&r["existing"])), (want[..6].iter().map(|w| w.0).collect::<Vec<_>>(), vec!["Fine Pen"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_dedupes_names_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -798,6 +798,39 @@ fn a_pattern_library_adds_only_the_picked_pattern_and_it_undoes_and_saves() {
|
||||
assert_eq!(back.swatch("Checkerboard").map(|w| &w.paint), doc(&s).swatch("Checkerboard").map(|w| &w.paint));
|
||||
}
|
||||
|
||||
/// Grit Textures is listed after Geometric Patterns. One of its patterns comes in with its swatch
|
||||
/// once, as one undo step that also takes both away, and both stay after a save and reopen.
|
||||
#[test]
|
||||
fn a_grit_texture_adds_once_undoes_and_saves() {
|
||||
let mut s = session();
|
||||
let list = run(&mut s, "swatch.library.list", json!({}));
|
||||
let ids: Vec<&str> =
|
||||
list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["geometric-patterns", "grit-textures"]);
|
||||
let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"}));
|
||||
let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]);
|
||||
let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count());
|
||||
let empty = counts(doc(&s));
|
||||
let undo = undo_len(&s);
|
||||
let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]}));
|
||||
assert_eq!(r["added"], json!(["Stipple"]));
|
||||
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1));
|
||||
let def = doc(&s).pattern("Stipple").expect("the pattern comes with its swatch").clone();
|
||||
assert!(def.art.len() >= 144, "{} marks", def.art.len());
|
||||
assert!(matches!(doc(&s).swatch("Stipple").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Stipple"));
|
||||
let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]}));
|
||||
assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Stipple"])));
|
||||
assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(counts(doc(&s)), empty);
|
||||
assert!(doc(&s).pattern("Stipple").is_none() && doc(&s).swatch("Stipple").is_none());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap();
|
||||
assert_eq!(back.pattern("Stipple"), Some(&def));
|
||||
assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint));
|
||||
}
|
||||
|
||||
/// A device name is saved with a `-` after it, which can be another library's own name: neither
|
||||
/// library replaces the other, and saving either again replaces only itself.
|
||||
#[test]
|
||||
|
||||
@@ -751,6 +751,27 @@ impl Session {
|
||||
self.apply_actions(acts).map(drop)
|
||||
}
|
||||
|
||||
/// The rulers' origin dragged out of their corner, whatever the tool, with the pointer at `p` in
|
||||
/// document space (none off the canvas): where it goes, snapped as a drawn point is
|
||||
/// ([`vectorcraft_tools::rulerguide::OriginDrag`]), the targets gathered on the drag's first
|
||||
/// call. It changes nothing: the caller sets the origin where the button is released
|
||||
/// (`document.setRulerOrigin`) and ends the drag ([`Session::end_ruler_origin_drag`]).
|
||||
pub fn ruler_origin_drag(&mut self, p: Option<Point>, view: ViewInfo) -> Option<Point> {
|
||||
let drag = match self.ruler_origin.take() {
|
||||
Some(d) => Some(d),
|
||||
None => self.with_tool_cx(view, |_, cx| Some(vectorcraft_tools::rulerguide::OriginDrag::new(cx))),
|
||||
};
|
||||
let Some(mut drag) = drag else { return p };
|
||||
let at = self.with_tool_cx(view, |_, cx| drag.pointer(cx, p));
|
||||
self.ruler_origin = Some(drag);
|
||||
at
|
||||
}
|
||||
|
||||
/// The drag of the rulers' origin ended, released or cancelled: its guides go.
|
||||
pub fn end_ruler_origin_drag(&mut self) {
|
||||
self.ruler_origin = None;
|
||||
}
|
||||
|
||||
/// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the
|
||||
/// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event.
|
||||
pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
@@ -1112,6 +1133,9 @@ impl Session {
|
||||
v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx)));
|
||||
self.ruler_guide = Some(g);
|
||||
}
|
||||
if let Some(d) = &self.ruler_origin {
|
||||
v.extend(d.overlays());
|
||||
}
|
||||
let deferred_preview = self.unapplied_interaction_preview();
|
||||
if let Some(d) = self.active() {
|
||||
let w = self.prefs.perspective_widget;
|
||||
|
||||
@@ -115,15 +115,27 @@ fn both_lexers_take_the_same_characters_into_names() {
|
||||
assert!(checker_member("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}") && one_identifier("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}"));
|
||||
for &c in NAME_CHARS {
|
||||
let escape = format!("\\u{:04X}", u32::from(c));
|
||||
let braced = format!("\\u{{{:X}}}", u32::from(c));
|
||||
for (src, name) in [
|
||||
(format!("a{c}b"), format!("a{c}b")),
|
||||
(format!("{c}b"), format!("{c}b")),
|
||||
(format!("a{escape}b"), format!("a{c}b")),
|
||||
(format!("{escape}b"), format!("{c}b")),
|
||||
(format!("a{braced}b"), format!("a{c}b")),
|
||||
(format!("{braced}b"), format!("{c}b")),
|
||||
] {
|
||||
assert_eq!(checker_member(&src, &name), one_identifier(&src, &name), "{src:?} (U+{:04X})", u32::from(c));
|
||||
}
|
||||
}
|
||||
// Braced escapes past U+FFFF and with leading zeros are read by both.
|
||||
for (src, name) in [("\\u{1D49C}b", "\u{1D49C}b"), ("a\\u{0000000062}", "ab")] {
|
||||
assert!(checker_member(src, name) && one_identifier(src, name), "{src:?}");
|
||||
}
|
||||
// Out of range, a surrogate and an empty one: the parser reads no identifier, and the checker's
|
||||
// name ends before the escape.
|
||||
for src in ["a\\u{110000}", "a\\u{D800}", "a\\u{}"] {
|
||||
assert!(checker_member(src, "a") && !one_identifier(src, "a"), "{src:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,6 +13,7 @@ use super::{
|
||||
};
|
||||
use crate::heap::{Class, NativeKind, ObjId};
|
||||
use crate::interp::{Abrupt, Interp, JsResult, strict_eq};
|
||||
use crate::string_builtins::flat_charged;
|
||||
use crate::value::{Str, Value, to_uint32};
|
||||
|
||||
pub(super) fn install(interp: &mut Interp) -> JsResult<()> {
|
||||
@@ -278,17 +279,20 @@ fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Int
|
||||
if len == 0 {
|
||||
return Ok(Value::str(""));
|
||||
}
|
||||
let flat = sep.flat();
|
||||
let sep = &flat[..];
|
||||
// A result that cannot fit is refused before any element is read.
|
||||
let least = (sep.len() as u64).saturating_mul(u64::from(len - 1));
|
||||
if least > (interp.limits.heap_bytes / 4) as u64 {
|
||||
return Err(Abrupt::Limit(crate::LimitKind::Heap));
|
||||
}
|
||||
interp.join_stack.push(o);
|
||||
let r = join_elements(interp, o, len, sep, item);
|
||||
interp.join_stack.pop();
|
||||
r
|
||||
// The separator stays rooted for the whole join, so its flattened copy stays counted.
|
||||
interp.rooted(&[Value::String(sep.clone())], |interp| {
|
||||
let flat = flat_charged(interp, &sep)?;
|
||||
let sep = &flat[..];
|
||||
// A result that cannot fit is refused before any element is read.
|
||||
let least = (sep.len() as u64).saturating_mul(u64::from(len - 1));
|
||||
if least > (interp.limits.heap_bytes / 4) as u64 {
|
||||
return Err(Abrupt::Limit(crate::LimitKind::Heap));
|
||||
}
|
||||
interp.join_stack.push(o);
|
||||
let r = join_elements(interp, o, len, sep, item);
|
||||
interp.join_stack.pop();
|
||||
r
|
||||
})
|
||||
}
|
||||
|
||||
fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult<Option<Str>>) -> JsResult<Value> {
|
||||
@@ -301,7 +305,10 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(
|
||||
let Some(s) = item(interp, v)? else { continue };
|
||||
out.push_repeated(interp, sep, k.saturating_sub(seps))?;
|
||||
seps = k;
|
||||
out.push_units(interp, &s.flat())?;
|
||||
interp.rooted(&[Value::String(s.clone())], |interp| {
|
||||
let units = flat_charged(interp, &s)?;
|
||||
out.push_units(interp, &units)
|
||||
})?;
|
||||
}
|
||||
out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?;
|
||||
Ok(out.finish(interp))
|
||||
@@ -472,7 +479,7 @@ fn unshift(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul
|
||||
with_this(interp, &this, |interp, o| {
|
||||
let len = u64::from(length_of(interp, o)?);
|
||||
let count = args.len() as u64;
|
||||
// With no arguments each element is still read and written back in place (steps 6 and 7).
|
||||
// With no arguments each element is still read and written back in place (step 6).
|
||||
let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)];
|
||||
let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?;
|
||||
while let Some(k) = it.next(interp)? {
|
||||
|
||||
@@ -13,6 +13,7 @@ use std::collections::HashSet;
|
||||
use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer};
|
||||
use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes};
|
||||
use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult};
|
||||
use crate::string_builtins::flat_charged;
|
||||
use crate::value::{Str, Value, number_to_str};
|
||||
|
||||
pub(super) fn install(interp: &mut Interp) -> JsResult<()> {
|
||||
@@ -473,7 +474,7 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult<Se
|
||||
let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize;
|
||||
s.gap = vec![u16::from(b' '); n];
|
||||
}
|
||||
Value::String(t) => s.gap = t.flat().iter().take(10).copied().collect(),
|
||||
Value::String(t) => s.gap = flat_charged(interp, &t)?.iter().take(10).copied().collect(),
|
||||
_ => {}
|
||||
}
|
||||
Ok(s)
|
||||
@@ -632,7 +633,10 @@ fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResul
|
||||
Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }),
|
||||
Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()),
|
||||
Value::Number(_) => out.push_str(interp, "null"),
|
||||
Value::String(s) => write_quoted(interp, out, &s.flat()),
|
||||
Value::String(s) => interp.rooted(std::slice::from_ref(v), |i| {
|
||||
let units = flat_charged(i, s)?;
|
||||
write_quoted(i, out, &units)
|
||||
}),
|
||||
Value::Undefined | Value::Object(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
+71
-17
@@ -27,6 +27,7 @@ use crate::heap::{
|
||||
ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, ENV_BYTES, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OBJECT_BYTES, OPEN, ObjId,
|
||||
Object, PROP_BYTES, Prop, PropMap, ROPE_BYTES, Roots, WRITABLE, code_bytes, key, string_bytes,
|
||||
};
|
||||
use crate::string_builtins::flat_charged;
|
||||
use crate::value::{Str, Value, array_index, index_key, number_to_str, string_to_number, to_int32, to_uint32};
|
||||
use crate::{LimitKind, Limits, Options};
|
||||
|
||||
@@ -205,6 +206,12 @@ pub(crate) struct Interp {
|
||||
pub(crate) date_host: crate::date_builtins::DateHost,
|
||||
/// The bytes native code holds outside the heap's cells through [`HeldBytes`].
|
||||
pub(crate) held: Rc<HeldCount>,
|
||||
/// Bytes of the copies [`Self::get_own_property`] made flattening the rope of a String
|
||||
/// object, which the heap's total adds before the next limit check.
|
||||
flattened: std::cell::Cell<usize>,
|
||||
/// Fuel for the units those copies took and the rope nodes it read in place, which the next
|
||||
/// step takes.
|
||||
pub(crate) flattened_fuel: std::cell::Cell<u64>,
|
||||
/// The global code's default XML namespace URI (ECMA-357 section 12.1).
|
||||
pub(crate) xml_default_uri: Key,
|
||||
/// Set by the host to end the current slice of a resumable run at the next safe point.
|
||||
@@ -450,6 +457,8 @@ impl Interp {
|
||||
files: None,
|
||||
date_host: crate::date_builtins::DateHost::default(),
|
||||
held: Rc::default(),
|
||||
flattened: std::cell::Cell::new(0),
|
||||
flattened_fuel: std::cell::Cell::new(0),
|
||||
xml_default_uri: Rc::from(Vec::new()),
|
||||
pause: Rc::default(),
|
||||
in_slice: false,
|
||||
@@ -569,6 +578,7 @@ impl Interp {
|
||||
|
||||
fn tick(&mut self) -> JsResult<()> {
|
||||
self.use_fuel()?;
|
||||
crate::regexp_vm::use_fuel_n(self, self.flattened_fuel.replace(0))?;
|
||||
self.settle_held();
|
||||
if self.heap.bytes > self.gc_threshold {
|
||||
self.collect_garbage();
|
||||
@@ -595,10 +605,12 @@ impl Interp {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Takes the bytes that dropped [`HeldBytes`] released off the heap's total.
|
||||
/// Takes the bytes that dropped [`HeldBytes`] released off the heap's total, and adds the
|
||||
/// bytes of the ropes [`Self::get_own_property`] flattened.
|
||||
fn settle_held(&mut self) {
|
||||
let released = self.held.released.replace(0);
|
||||
self.heap.bytes = self.heap.bytes.saturating_sub(released);
|
||||
let flattened = self.flattened.replace(0);
|
||||
self.heap.bytes = self.heap.bytes.saturating_sub(released).saturating_add(flattened);
|
||||
}
|
||||
|
||||
/// Collects garbage now and sets when the next collection runs.
|
||||
@@ -680,6 +692,7 @@ impl Interp {
|
||||
self.heap.collect(&roots);
|
||||
// The collection counts the live total again, so bytes already released are not in it.
|
||||
self.held.released.set(0);
|
||||
self.flattened.set(0);
|
||||
self.heap.bytes = self.heap.bytes.saturating_add(self.held_bytes).saturating_add(self.held.now.get());
|
||||
}
|
||||
|
||||
@@ -1235,7 +1248,33 @@ impl Interp {
|
||||
if let Some(i) = array_index(k)
|
||||
&& (i as usize) < s.len()
|
||||
{
|
||||
let unit = s.flat().get(i as usize).copied().unwrap_or(0);
|
||||
let unit = if s.is_rope() {
|
||||
let owed = self.flattened_fuel.get();
|
||||
let flattened = self.flattened.get().saturating_add(string_bytes(s.len()));
|
||||
if self.heap.bytes.saturating_add(flattened) > self.limits.heap_bytes {
|
||||
// The copy does not fit without a collection, which needs `&mut self`:
|
||||
// the unit is read along the rope instead, a unit of fuel per node.
|
||||
let Some((unit, visited)) = s.unit_at(i as usize, self.fuel.saturating_sub(owed)) else {
|
||||
return Err(Abrupt::Limit(LimitKind::Fuel));
|
||||
};
|
||||
self.flattened_fuel.set(owed.saturating_add(visited));
|
||||
unit
|
||||
} else {
|
||||
// Flattening copies the units. The run ends here when the fuel for
|
||||
// the copy is not left; otherwise the next step takes it, and the
|
||||
// next limit check adds the copy's bytes.
|
||||
let fuel = owed.saturating_add((s.len() / crate::string_builtins::UNITS_PER_FUEL) as u64 + 1);
|
||||
if fuel > self.fuel {
|
||||
return Err(Abrupt::Limit(LimitKind::Fuel));
|
||||
}
|
||||
self.flattened_fuel.set(fuel);
|
||||
self.flattened.set(flattened);
|
||||
s.flat().get(i as usize).copied()
|
||||
}
|
||||
} else {
|
||||
s.flat().get(i as usize).copied()
|
||||
}
|
||||
.unwrap_or(0);
|
||||
return Ok(Some(Prop::Data { value: Value::String(Str::from_units(vec![unit])), flags: ENUMERABLE }));
|
||||
}
|
||||
}
|
||||
@@ -1312,7 +1351,7 @@ impl Interp {
|
||||
if let Some(i) = array_index(k)
|
||||
&& (i as usize) < s.len()
|
||||
{
|
||||
let unit = s.flat().get(i as usize).copied().unwrap_or(0);
|
||||
let unit = flat_charged(self, s)?.get(i as usize).copied().unwrap_or(0);
|
||||
return Ok(Value::String(Str::from_units(vec![unit])));
|
||||
}
|
||||
}
|
||||
@@ -2420,7 +2459,7 @@ impl Interp {
|
||||
let strict = self.strict();
|
||||
self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?;
|
||||
self.prepare_source(s.len())?;
|
||||
let units = s.flat();
|
||||
let units = self.flat_reserved(s)?;
|
||||
let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?;
|
||||
self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?;
|
||||
let frame = self.frame()?;
|
||||
@@ -2585,7 +2624,7 @@ impl Interp {
|
||||
Value::Null => 0.0,
|
||||
Value::Bool(b) => f64::from(u8::from(*b)),
|
||||
Value::Number(n) => *n,
|
||||
Value::String(s) => string_to_number(&s.flat()),
|
||||
Value::String(s) => string_to_number(&flat_charged(self, s)?),
|
||||
Value::Object(_) => {
|
||||
let p = self.to_primitive(v, Hint::Number)?;
|
||||
return self.to_number(&p);
|
||||
@@ -2619,19 +2658,28 @@ impl Interp {
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub(crate) fn to_key(&mut self, v: &Value) -> JsResult<Key> {
|
||||
match v {
|
||||
Value::String(s) => {
|
||||
let rope = s.is_rope();
|
||||
let flat = s.flat();
|
||||
if rope {
|
||||
self.heap.bytes += string_bytes(flat.len());
|
||||
}
|
||||
Ok(flat)
|
||||
}
|
||||
Value::String(s) => self.flat_reserved(s),
|
||||
Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)),
|
||||
_ => Ok(self.to_str(v)?.flat()),
|
||||
_ => {
|
||||
let s = self.to_str(v)?;
|
||||
self.flat_reserved(&s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that
|
||||
/// copy's bytes are reserved and added to the heap first, without taking fuel. The rope stays
|
||||
/// rooted while the copy is reserved; everything else the caller still needs must be
|
||||
/// reachable from the roots.
|
||||
fn flat_reserved(&mut self, s: &Str) -> JsResult<Key> {
|
||||
if s.is_rope() {
|
||||
let bytes = string_bytes(s.len());
|
||||
self.rooted(&[Value::String(s.clone())], |i| i.reserve_heap(bytes))?;
|
||||
self.heap.bytes = self.heap.bytes.saturating_add(bytes);
|
||||
}
|
||||
Ok(s.flat())
|
||||
}
|
||||
|
||||
/// ToObject (section 9.9).
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub(crate) fn to_object(&mut self, v: &Value) -> JsResult<ObjId> {
|
||||
@@ -2754,7 +2802,11 @@ impl Interp {
|
||||
(self.to_primitive(x, Hint::Number)?, py)
|
||||
};
|
||||
if let (Value::String(a), Value::String(b)) = (&px, &py) {
|
||||
return Ok(Some(*a.flat() < *b.flat()));
|
||||
// Both stay rooted, so the first copy is still counted while the second is made.
|
||||
return self.rooted(&[px.clone(), py.clone()], |i| {
|
||||
let (a, b) = (flat_charged(i, a)?, flat_charged(i, b)?);
|
||||
Ok(Some(*a < *b))
|
||||
});
|
||||
}
|
||||
let nx = self.to_number(&px)?;
|
||||
let ny = self.to_number(&py)?;
|
||||
@@ -2779,7 +2831,9 @@ impl Interp {
|
||||
| (Value::String(_), Value::String(_))
|
||||
| (Value::Bool(_), Value::Bool(_))
|
||||
| (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)),
|
||||
(Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => return Ok(*n == string_to_number(&s.flat())),
|
||||
(Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => {
|
||||
return Ok(*n == string_to_number(&flat_charged(self, s)?));
|
||||
}
|
||||
(Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))),
|
||||
(_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))),
|
||||
(Value::Number(_) | Value::String(_), Value::Object(_)) => y = self.to_primitive(&y, Hint::Default)?,
|
||||
|
||||
@@ -231,6 +231,7 @@ impl Realm {
|
||||
return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 });
|
||||
}
|
||||
interp.fuel = interp.limits.fuel;
|
||||
interp.flattened_fuel.set(0);
|
||||
interp.source_units = 0;
|
||||
interp.throw_site = None;
|
||||
interp.last_thrown = Value::Undefined;
|
||||
|
||||
@@ -109,10 +109,11 @@ fn push_held<T>(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec<T>, it
|
||||
}
|
||||
|
||||
/// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that copy's
|
||||
/// bytes are reserved and added to the heap, and its units charged as fuel, first.
|
||||
/// bytes are reserved and added to the heap, and its units charged as fuel, first. The rope stays
|
||||
/// rooted while the copy is reserved, so what it holds is still counted.
|
||||
pub(crate) fn flat_charged(interp: &mut Interp, s: &Str) -> JsResult<Rc<[u16]>> {
|
||||
if s.is_rope() {
|
||||
reserve_units(interp, s.len())?;
|
||||
interp.rooted(&[Value::String(s.clone())], |interp| reserve_units(interp, s.len()))?;
|
||||
}
|
||||
Ok(s.flat())
|
||||
}
|
||||
|
||||
@@ -309,6 +309,131 @@ fn flattening_a_rope_counts_against_the_heap() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators_and_builtins_count_a_flattened_rope_against_the_heap() {
|
||||
// Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap.
|
||||
let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';";
|
||||
let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() };
|
||||
let not_stopped: Vec<_> =
|
||||
["X < 'a'", "X == 1", "+X", "X[0]", "({})[{ toString: function () { return X; } }]", "JSON.stringify(0, null, X)", "[1].join(X)"]
|
||||
.into_iter()
|
||||
.filter(|read| {
|
||||
let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u"));
|
||||
let result = Realm::with_options(Options::default(), limits.clone()).run(&src);
|
||||
!matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap))
|
||||
})
|
||||
.collect();
|
||||
assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flattened_temporary_stays_counted_while_the_next_copy_is_made() {
|
||||
// Ropes of 262,145 units, each about half the heap once flat: one fits, two don't. Each is
|
||||
// made by a function, so nothing but the native code holds it while it is flattened.
|
||||
let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; }";
|
||||
let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() };
|
||||
let run = |read: &str| Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0"));
|
||||
assert!(run("f('a')[0]").is_ok(), "one flattened copy fits");
|
||||
let not_stopped: Vec<_> = [
|
||||
"({ valueOf: function () { return f('a'); } }) < ({ valueOf: function () { return f('b'); } })",
|
||||
"JSON.stringify({ toJSON: function () { return f('a'); } })",
|
||||
"[1, 2].join({ toString: function () { return f('a'); } })",
|
||||
"[{ toString: function () { return f('a'); } }].join('')",
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|read| !matches!(run(read), Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)))
|
||||
.collect();
|
||||
assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_converted_rope_counts_what_it_holds_while_its_copy_is_made() {
|
||||
// `mk` returns a rope whose left half is a flat string of about half the heap, held by
|
||||
// nothing else. Its flattened copy is the other half, so the two don't fit together.
|
||||
let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; } \
|
||||
function mk() { var a = f('a'); a[0]; return a + 'b'; }";
|
||||
let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() };
|
||||
let not_stopped: Vec<_> = [
|
||||
"+({ valueOf: mk })",
|
||||
"({})[{ toString: mk }]",
|
||||
"({ valueOf: mk }) == 1",
|
||||
"++({ get x() { return mk(); } }).x",
|
||||
"JSON.stringify(0, null, (function () { var o = new String(''); o.toString = mk; return o; })())",
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|read| {
|
||||
let result = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0"));
|
||||
!matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap))
|
||||
})
|
||||
.collect();
|
||||
assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_string_object_counts_its_flattened_rope_against_the_heap() {
|
||||
// The same three ropes, each wrapped in a String object and read by index.
|
||||
let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z'; \
|
||||
String.prototype.first = function () { return this[0]; };";
|
||||
let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() };
|
||||
// Wrapping alone reads no units, so it makes no copy.
|
||||
let lengths =
|
||||
Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} Object(s).length + new String(t).length + Object(u).length"));
|
||||
assert_eq!(lengths, Ok(JsValue::Number(1_572_866.0)));
|
||||
// Two copies fit. The third read finds no room for its copy and reads the unit along the
|
||||
// rope, so the live heap stays within the limit.
|
||||
for (read, expected) in [
|
||||
("Object(X)[0]", "xxx"),
|
||||
("new String(X)[0]", "xxx"),
|
||||
("0 in Object(X)", "truetruetrue"),
|
||||
("Object(X).hasOwnProperty(0)", "truetruetrue"),
|
||||
("X.first()", "xxx"),
|
||||
("Object(X)[524288]", "undefinedyz"),
|
||||
] {
|
||||
let src = format!("{build} '' + ({}) + ({}) + ({})", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u"));
|
||||
let mut realm = Realm::with_options(Options::default(), limits.clone());
|
||||
assert_eq!(realm.run(&src), Ok(JsValue::String(expected.into())), "{read}");
|
||||
let interp = realm.interp();
|
||||
interp.collect_garbage();
|
||||
assert!(interp.heap.bytes <= limits.heap_bytes, "{read}: {} bytes live", interp.heap.bytes);
|
||||
}
|
||||
let wrapped =
|
||||
Realm::with_options(Options::default(), Limits::default()).run(&format!("{build} Object(t)[524288] + new String(u)[524288] + t.first()"));
|
||||
assert_eq!(wrapped, Ok(JsValue::String("yzx".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_string_object_takes_fuel_for_the_rope_it_flattens() {
|
||||
let limits = Limits { fuel: 40_000, ..Limits::default() };
|
||||
let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; Object(s).length";
|
||||
assert!(Realm::with_options(Options::default(), limits.clone()).run(build).is_ok());
|
||||
for read in ["Object(s)[0]", "0 in new String(s)"] {
|
||||
let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &format!("{build}; {read}"));
|
||||
assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{read}: {e:?}");
|
||||
}
|
||||
// A run that ends with an error right after the read leaves the next run its whole fuel.
|
||||
let mut realm = Realm::with_options(Options::default(), Limits { fuel: 100_000, ..Limits::default() });
|
||||
let defined = realm.run("var s = ' '; for (var i = 0; i < 20; i++) s = s + s; Object.defineProperty(new String(s), 0, { value: 'a' })");
|
||||
assert!(matches!(defined, Err(ref e) if e.kind == RunErrorKind::Thrown), "{defined:?}");
|
||||
assert!(realm.run("for (var j = 0; j < 5000; j++) {} j").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_keeps_its_operands_while_a_string_receiver_is_boxed() {
|
||||
// `g` leaves a garbage string, so the heap is close to its limit when the rope receiver of a
|
||||
// non-strict function is boxed. The callee and the argument object are off the stack by
|
||||
// then, and must survive anything boxing the receiver does.
|
||||
let build = "var s = 'x'; for (var i = 0; i < 17; i++) s = s + s; function f(c) { return s + c; } \
|
||||
function mk() { var a = f('a'); a[0]; return a + 'b'; } 0";
|
||||
let mut realm = Realm::with_options(Options::default(), Limits::default());
|
||||
assert_eq!(realm.run(build), Ok(JsValue::Number(0.0)));
|
||||
let interp = realm.interp();
|
||||
interp.collect_garbage();
|
||||
interp.limits.heap_bytes = interp.heap.bytes + 2 * crate::heap::string_bytes(131_073) + (64 << 10);
|
||||
interp.reset_gc_threshold();
|
||||
let src = "(function () { var g = f('g'); g[0]; })(); (function (o) { return o.x; }).call(mk(), { x: 1 })";
|
||||
assert_eq!(realm.run(src), Ok(JsValue::Number(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiling_a_pattern_counts_against_the_heap() {
|
||||
let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length";
|
||||
|
||||
@@ -163,6 +163,34 @@ impl Str {
|
||||
flat
|
||||
}
|
||||
|
||||
/// The code unit at `i` read along the rope without flattening it, visiting at most
|
||||
/// `max_nodes` nodes: `None` when that is not enough, otherwise the unit (when `i` is in
|
||||
/// range) and the number of nodes visited.
|
||||
pub(crate) fn unit_at(&self, mut i: usize, max_nodes: u64) -> Option<(Option<u16>, u64)> {
|
||||
let mut node = self.clone();
|
||||
let mut visited = 0u64;
|
||||
loop {
|
||||
visited += 1;
|
||||
if visited > max_nodes {
|
||||
return None;
|
||||
}
|
||||
let next = match &*node.0.borrow() {
|
||||
Node::Flat(f) => return Some((f.get(i).copied(), visited)),
|
||||
Node::Concat { left, right, .. } => {
|
||||
let left_len = left.len();
|
||||
if i < left_len {
|
||||
left.clone()
|
||||
} else {
|
||||
i -= left_len;
|
||||
right.clone()
|
||||
}
|
||||
}
|
||||
Node::Taken => return Some((None, visited)),
|
||||
};
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn to_rust_string(&self) -> String {
|
||||
String::from_utf16_lossy(&self.flat())
|
||||
}
|
||||
|
||||
@@ -1492,7 +1492,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"css.exportFile",
|
||||
"Export CSS…",
|
||||
"",
|
||||
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}",
|
||||
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}",
|
||||
),
|
||||
(
|
||||
"file.print",
|
||||
|
||||
@@ -88,12 +88,25 @@ pub(crate) struct Scan {
|
||||
/// Scan `src` (already through [`unescaped`]): the script itself (`file` none) or a file it includes
|
||||
/// (`file`: its path inside the script's folder), with the names it declares (`declared`, from
|
||||
/// [`declarations`] on the same `src`), the `#includepath` folders `paths` in force at its start and
|
||||
/// its variables holding what `known` says.
|
||||
pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str>, paths: Rc<[String]>, known: &Known, acc: &mut Acc) -> Scan {
|
||||
/// its variables holding what `known` says. Each of the `\u{…}` escapes `braced` that [`unescaped`]
|
||||
/// read is syntax later than ECMAScript 3, reported when the scan reaches the escape's line.
|
||||
pub(crate) fn scan<'s>(
|
||||
src: &'s str,
|
||||
declared: &Declared<'s>,
|
||||
braced: &[Braced],
|
||||
file: Option<&str>,
|
||||
paths: Rc<[String]>,
|
||||
known: &Known,
|
||||
acc: &mut Acc,
|
||||
) -> Scan {
|
||||
let mut lexer = Lexer::new(src, acc.limits.nesting);
|
||||
let mut a = Analyzer::new(acc, file, paths, known, declared);
|
||||
let mut breaks = LineBreaks::default();
|
||||
let mut braced = braced.iter().peekable();
|
||||
for t in lexer.by_ref() {
|
||||
while let Some((line, escape)) = braced.next_if(|(line, _)| *line <= t.line) {
|
||||
a.acc.problem(report::NEWER_SYNTAX, Some(*line), file, Some(escape));
|
||||
}
|
||||
if let Some(code) = t.problem {
|
||||
a.problem(code, t.line);
|
||||
}
|
||||
@@ -103,6 +116,9 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str>
|
||||
Read::Token(sig) => a.token(sig),
|
||||
}
|
||||
}
|
||||
for (line, escape) in braced {
|
||||
a.acc.problem(report::NEWER_SYNTAX, Some(*line), file, Some(escape));
|
||||
}
|
||||
if let Some(line) = lexer.unclosed_xml {
|
||||
a.problem(report::UNTERMINATED_XML, line);
|
||||
}
|
||||
@@ -115,29 +131,61 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str>
|
||||
Scan { includes, module, lines: lexer.lines(), known }
|
||||
}
|
||||
|
||||
/// `src` with the `\uXXXX` escapes in its names written as the characters they stand for (ECMA-262
|
||||
/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character a
|
||||
/// name may not hold where the escape stands stays as written. Line breaks don't change, so every
|
||||
/// line keeps its number.
|
||||
pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> Cow<'a, str> {
|
||||
match unescape_names(&src, acc) {
|
||||
Some(out) => Cow::Owned(out),
|
||||
None => src,
|
||||
/// A `\u{…}` escape in a name, which ECMAScript 3 does not have: its line and its text.
|
||||
pub(crate) type Braced = (usize, String);
|
||||
|
||||
/// `src` with the `\uXXXX` and `\u{…}` escapes in its names and private names written as the
|
||||
/// characters they stand for (ECMA-262, Names and Keywords), and `src` itself when no name has one,
|
||||
/// with the `\u{…}` escapes read, each listed once per line in a row. An escape for a character a
|
||||
/// name may not hold where the escape stands stays as written, and so does a private name that
|
||||
/// starts a line where the name it spells would start a preprocessor line. Line breaks don't
|
||||
/// change, so every line keeps its number.
|
||||
pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> (Cow<'a, str>, Vec<Braced>) {
|
||||
let mut braced = vec![];
|
||||
match unescape_names(&src, acc, &mut braced) {
|
||||
Some(out) => (Cow::Owned(out), braced),
|
||||
None => (src, braced),
|
||||
}
|
||||
}
|
||||
|
||||
fn unescape_names(src: &str, acc: &mut Acc) -> Option<String> {
|
||||
fn unescape_names(src: &str, acc: &mut Acc, braced: &mut Vec<Braced>) -> Option<String> {
|
||||
if !src.contains("\\u") {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut changed = false;
|
||||
let mut lexer = Lexer::new(src, acc.limits.nesting);
|
||||
for t in lexer.by_ref() {
|
||||
if t.kind == T::Ident && t.text.contains("\\u") {
|
||||
let name = unescape_name(t.text);
|
||||
changed |= name != t.text;
|
||||
out.push_str(&name);
|
||||
// One more than the report keeps, so that the report is marked truncated.
|
||||
let room = acc.limits.entries.saturating_add(1);
|
||||
// Only blanks since the last line break (or the start).
|
||||
let mut line_start = true;
|
||||
let mut found = vec![];
|
||||
while let Some(t) = lexer.next() {
|
||||
// A private name is `#` and a name.
|
||||
let (mark, name) = match t.kind {
|
||||
T::Ident => ("", t.text),
|
||||
T::Other => t.text.strip_prefix('#').map_or(("", ""), |name| ("#", name)),
|
||||
_ => ("", ""),
|
||||
};
|
||||
let starts_line = line_start;
|
||||
line_start = t.kind == T::Newline || (line_start && t.kind == T::Space);
|
||||
if name.contains("\\u") {
|
||||
found.clear();
|
||||
let written = unescape_name(name, t.line, &mut found, room);
|
||||
for escape in found.drain(..) {
|
||||
if braced.last() != Some(&escape) && braced.len() < room {
|
||||
braced.push(escape);
|
||||
}
|
||||
}
|
||||
// A private name that starts a line stays as written where the name it spells would
|
||||
// start a preprocessor line.
|
||||
if mark == "#" && starts_line && !lexer.private_line(&written) {
|
||||
out.push_str(t.text);
|
||||
continue;
|
||||
}
|
||||
changed |= written != name;
|
||||
out.push_str(mark);
|
||||
out.push_str(&written);
|
||||
} else {
|
||||
out.push_str(t.text);
|
||||
}
|
||||
@@ -146,25 +194,31 @@ fn unescape_names(src: &str, acc: &mut Acc) -> Option<String> {
|
||||
changed.then_some(out)
|
||||
}
|
||||
|
||||
/// The name `text` with each `\uXXXX` escape for a character a name may hold where it stands written
|
||||
/// as that character: a character a name may start with first, and one a name may hold after that.
|
||||
fn unescape_name(text: &str) -> String {
|
||||
/// The name `text`, on `line`, with each escape for a character a name may hold where it stands
|
||||
/// written as that character: a character a name may start with first, and one a name may hold
|
||||
/// after that. Each `\u{…}` escape read goes into `braced`, unless the last one there is the same
|
||||
/// escape on the same line or `braced` holds `room` of them.
|
||||
fn unescape_name(text: &str, line: usize, braced: &mut Vec<Braced>, room: usize) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(i) = rest.find("\\u") {
|
||||
out.push_str(rest.get(..i).unwrap_or_default());
|
||||
let after = rest.get(i + 2..).unwrap_or_default();
|
||||
let at = rest.get(i..).unwrap_or_default();
|
||||
let first = out.is_empty();
|
||||
let c = crate::lex::escaped(rest.get(i..).unwrap_or_default())
|
||||
.filter(|&c| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) });
|
||||
let c = crate::lex::escape(at).filter(|&(c, _)| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) });
|
||||
match c {
|
||||
Some(c) => {
|
||||
Some((c, len)) => {
|
||||
out.push(c);
|
||||
rest = after.get(4..).unwrap_or_default();
|
||||
let source = at.get(..len).unwrap_or_default();
|
||||
let repeated = braced.last().is_some_and(|(l, s)| *l == line && s == source);
|
||||
if source.starts_with("\\u{") && !repeated && braced.len() < room {
|
||||
braced.push((line, source.to_string()));
|
||||
}
|
||||
rest = at.get(len..).unwrap_or_default();
|
||||
}
|
||||
None => {
|
||||
out.push_str("\\u");
|
||||
rest = after;
|
||||
rest = at.get(2..).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,9 +141,9 @@ pub(crate) fn follow(
|
||||
read.insert(path.clone(), (status, Some(file)));
|
||||
let (text, _) = crate::decode(&b);
|
||||
let mut tallies = Acc::new(limits);
|
||||
let text = analyze::unescaped(text, &mut tallies);
|
||||
let (text, _) = analyze::unescaped(text, &mut tallies);
|
||||
let declared = analyze::declarations(&text, &mut tallies);
|
||||
let scan = analyze::scan(&text, &declared, Some(&path), Rc::clone(&inc.paths), known, &mut tallies);
|
||||
let scan = analyze::scan(&text, &declared, &[], Some(&path), Rc::clone(&inc.paths), known, &mut tallies);
|
||||
acc.steps = acc.steps.saturating_add(tallies.steps);
|
||||
known.add(scan.known, limits.entries);
|
||||
checked.push(ReadFile { path: path.clone(), bytes: b, paths: Rc::clone(&inc.paths) });
|
||||
|
||||
@@ -41,7 +41,9 @@ pub(crate) enum T {
|
||||
/// end or the next `{`.
|
||||
XmlMore,
|
||||
Punct,
|
||||
/// A character no token starts with, or `#` and a name away from the start of a line.
|
||||
/// A character no token starts with, or a private name: `#` and a name away from the start of a
|
||||
/// line, or at its start where the name starts with an escape, or inside braces where the name
|
||||
/// is not a directive's.
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -166,14 +168,33 @@ pub(crate) fn ident_part(c: char) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The character the `\uXXXX` escape at the start of `s` stands for: `None` when `s` doesn't start
|
||||
/// with `\u` and four hexadecimal digits, or when they give a lone surrogate.
|
||||
pub(crate) fn escaped(s: &str) -> Option<char> {
|
||||
s.strip_prefix("\\u")?
|
||||
.get(..4)
|
||||
.filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
|
||||
.and_then(char::from_u32)
|
||||
/// The character the escape at the start of `s` stands for and the escape's length in bytes: `\u`
|
||||
/// and four hexadecimal digits, or `\u{…}` with one or more hexadecimal digits of a value up to
|
||||
/// U+10FFFF, leading zeros allowed (ECMA-262, UnicodeEscapeSequence and CodePoint). `None` when `s`
|
||||
/// starts with neither, or when the escape gives a lone surrogate.
|
||||
pub(crate) fn escape(s: &str) -> Option<(char, usize)> {
|
||||
let rest = s.strip_prefix("\\u")?;
|
||||
let Some(braced) = rest.strip_prefix('{') else {
|
||||
let c = rest
|
||||
.get(..4)
|
||||
.filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
|
||||
.and_then(char::from_u32)?;
|
||||
return Some((c, 6));
|
||||
};
|
||||
// The value stops growing past U+10FFFF, so any number of digits is read once and never
|
||||
// overflows.
|
||||
let mut value: u32 = 0;
|
||||
for (i, b) in braced.bytes().enumerate() {
|
||||
match b {
|
||||
b'}' if i > 0 => return char::from_u32(value).map(|c| (c, i + 4)),
|
||||
_ => {
|
||||
let digit = char::from(b).to_digit(16)?;
|
||||
value = (value * 16 + digit).min(0x11_0000);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `Other_ID_Start` (Unicode PropList 17.0): characters kept in `ID_Start` though their category is
|
||||
@@ -394,18 +415,66 @@ impl<'s> Lexer<'s> {
|
||||
}
|
||||
|
||||
/// The rest of a name (its first character included): the characters [`ident_part`] allows,
|
||||
/// written out or as `\uXXXX` escapes. An escape for any other character, or one without four
|
||||
/// hexadecimal digits, ends the name.
|
||||
/// written out or as `\uXXXX` or `\u{…}` escapes ([`escape`]). An escape for any other
|
||||
/// character, or a malformed one, ends the name. Each character of an escape is a step.
|
||||
fn name(&mut self) {
|
||||
loop {
|
||||
match self.peek() {
|
||||
Some('\\') if escaped(self.rest()).is_some_and(ident_part) => self.skip(6),
|
||||
Some('\\') => match escape(self.rest()) {
|
||||
Some((c, len)) if ident_part(c) => {
|
||||
self.pos += len;
|
||||
self.steps += len;
|
||||
}
|
||||
_ => break,
|
||||
},
|
||||
Some(c) if ident_part(c) => self.bump(c),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A name starts right after the `#` here: its first character written out, or an escape for a
|
||||
/// character a name may start with.
|
||||
fn private_name_follows(&self) -> bool {
|
||||
let after = self.rest().get(1..).unwrap_or_default();
|
||||
match after.chars().next() {
|
||||
Some('\\') => escape(after).is_some_and(|(c, _)| ident_start(c)),
|
||||
Some(c) => ident_start(c),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `#` at the start of this line starts a private name, not a preprocessor line: the name
|
||||
/// starts with an escape, which no directive's name holds, or [`Lexer::private_line`] holds for
|
||||
/// it.
|
||||
fn private_at_line_start(&self) -> bool {
|
||||
if !self.private_name_follows() {
|
||||
return false;
|
||||
}
|
||||
let after = self.rest().get(1..).unwrap_or_default();
|
||||
after.starts_with('\\') || self.private_line(after)
|
||||
}
|
||||
|
||||
/// A line that starts with `#` and then `rest` is a private name here, not a preprocessor line:
|
||||
/// the line is inside braces or in the code of an XML literal, and the name `rest` starts with
|
||||
/// is not a directive's. The name runs over every character a name may hold, written out or as
|
||||
/// an escape, so `#include$size` is not `#include`.
|
||||
pub fn private_line(&self, rest: &str) -> bool {
|
||||
let in_code = !self.braces.is_empty() || self.deeper_braces > 0 || !self.xml.is_empty();
|
||||
let mut end = 0;
|
||||
while let Some(more) = rest.get(end..) {
|
||||
let len = match more.chars().next() {
|
||||
Some('\\') => escape(more).filter(|&(c, _)| ident_part(c)).map(|(_, len)| len),
|
||||
Some(c) if ident_part(c) => Some(c.len_utf8()),
|
||||
_ => None,
|
||||
};
|
||||
let Some(len) = len else { break };
|
||||
end += len;
|
||||
}
|
||||
let name = rest.get(..end).unwrap_or_default();
|
||||
in_code && !catalog::DIRECTIVES.iter().any(|d| d.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn digits(&mut self, ok: fn(char) -> bool) {
|
||||
while let Some(c) = self.peek().filter(|&c| ok(c) || c == '_') {
|
||||
self.bump(c);
|
||||
@@ -871,10 +940,10 @@ impl<'s> Iterator for Lexer<'s> {
|
||||
} else if self.starts("/*") {
|
||||
problem = self.block_comment();
|
||||
T::Comment
|
||||
} else if c == '#' && self.line_start {
|
||||
} else if c == '#' && self.line_start && !self.private_at_line_start() {
|
||||
self.until_line_break();
|
||||
T::Directive
|
||||
} else if c == '#' && self.peek_at(1).is_some_and(ident_start) {
|
||||
} else if c == '#' && self.private_name_follows() {
|
||||
self.bump('#');
|
||||
self.name();
|
||||
T::Other
|
||||
@@ -906,7 +975,7 @@ impl<'s> Iterator for Lexer<'s> {
|
||||
self.close_level();
|
||||
self.bump('}');
|
||||
return Some(self.markup_token(start, line, T::XmlMore, stmt, expr));
|
||||
} else if ident_start(c) || (c == '\\' && escaped(self.rest()).is_some_and(ident_start)) {
|
||||
} else if ident_start(c) || (c == '\\' && escape(self.rest()).is_some_and(|(c, _)| ident_start(c))) {
|
||||
self.name();
|
||||
T::Ident
|
||||
} else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) {
|
||||
|
||||
@@ -166,14 +166,14 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits:
|
||||
if invalid {
|
||||
acc.problem(report::NOT_UTF8, None, None, None);
|
||||
}
|
||||
let text = analyze::unescaped(text, &mut acc);
|
||||
let (text, braced) = analyze::unescaped(text, &mut acc);
|
||||
let declared = analyze::declarations(&text, &mut acc);
|
||||
let none = analyze::Known::default();
|
||||
// With a loader, this first scan finds the includes and what the script's variables hold; its
|
||||
// tallies are left out.
|
||||
let mut tallies = report::Acc::new(limits);
|
||||
let pre = acc.clone();
|
||||
let scan = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc });
|
||||
let scan = analyze::scan(&text, &declared, &braced, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc });
|
||||
acc.steps = acc.steps.saturating_add(tallies.steps);
|
||||
if scan.module {
|
||||
// A module's report holds its kind and its lines, and none of what the scan found.
|
||||
@@ -183,7 +183,7 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits:
|
||||
return acc.finish(Kind::Module, scan.lines);
|
||||
}
|
||||
let Some(loader) = loader else {
|
||||
let mut acc = settle(acc, pre, &text, &declared, scan.known);
|
||||
let mut acc = settle(acc, pre, &text, &declared, &braced, scan.known);
|
||||
includes::not_read(&mut acc, scan.includes);
|
||||
return acc.finish(Kind::Script, scan.lines);
|
||||
};
|
||||
@@ -191,17 +191,23 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits:
|
||||
let read = includes::follow(&mut acc, scan.includes, loader, name, bytes.len(), &mut known);
|
||||
// Each file's text and the names it declares are worked out once for all the scans below.
|
||||
let decoded: Vec<_> = read.iter().map(|file| decode(&file.bytes)).collect();
|
||||
let texts: Vec<_> = decoded.into_iter().map(|(text, invalid)| (analyze::unescaped(text, &mut acc), invalid)).collect();
|
||||
let declarations: Vec<_> = texts.iter().map(|(text, _)| analyze::declarations(text, &mut acc)).collect();
|
||||
let texts: Vec<_> = decoded
|
||||
.into_iter()
|
||||
.map(|(text, invalid)| {
|
||||
let (text, braced) = analyze::unescaped(text, &mut acc);
|
||||
(text, braced, invalid)
|
||||
})
|
||||
.collect();
|
||||
let declarations: Vec<_> = texts.iter().map(|(text, ..)| analyze::declarations(text, &mut acc)).collect();
|
||||
// A variable may get what it holds from another file's variable: the files are scanned again,
|
||||
// each with what is known so far, while that grows, at most three more times.
|
||||
for _ in 0..3 {
|
||||
let before = known.len();
|
||||
let mut tallies = report::Acc::new(limits);
|
||||
let more = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &known, &mut tallies).known;
|
||||
let more = analyze::scan(&text, &declared, &[], None, std::rc::Rc::from([]), &known, &mut tallies).known;
|
||||
known.add(more, limits.entries);
|
||||
for ((file, (text, _)), declared) in read.iter().zip(&texts).zip(&declarations) {
|
||||
let more = analyze::scan(text, declared, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known;
|
||||
for ((file, (text, ..)), declared) in read.iter().zip(&texts).zip(&declarations) {
|
||||
let more = analyze::scan(text, declared, &[], Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known;
|
||||
known.add(more, limits.entries);
|
||||
}
|
||||
acc.steps = acc.steps.saturating_add(tallies.steps);
|
||||
@@ -210,12 +216,12 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits:
|
||||
}
|
||||
}
|
||||
// The scans for the report: every file starts with what any file's variables hold.
|
||||
let lines = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &known, &mut acc).lines;
|
||||
for ((file, (text, invalid)), declared) in read.iter().zip(&texts).zip(&declarations) {
|
||||
let lines = analyze::scan(&text, &declared, &braced, None, std::rc::Rc::from([]), &known, &mut acc).lines;
|
||||
for ((file, (text, braced, invalid)), declared) in read.iter().zip(&texts).zip(&declarations) {
|
||||
if *invalid {
|
||||
acc.problem(report::NOT_UTF8, None, Some(&file.path), None);
|
||||
}
|
||||
analyze::scan(text, declared, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut acc);
|
||||
analyze::scan(text, declared, braced, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut acc);
|
||||
}
|
||||
acc.finish(Kind::Script, lines)
|
||||
}
|
||||
@@ -230,6 +236,7 @@ fn settle<'l>(
|
||||
pre: report::Acc<'l>,
|
||||
text: &str,
|
||||
declared: &analyze::Declared<'_>,
|
||||
braced: &[analyze::Braced],
|
||||
mut known: analyze::Known,
|
||||
) -> report::Acc<'l> {
|
||||
if known.len() == 0 {
|
||||
@@ -240,7 +247,7 @@ fn settle<'l>(
|
||||
for _ in 0..3 {
|
||||
let before = known.len();
|
||||
let mut acc = pre.clone();
|
||||
let more = analyze::scan(text, declared, None, std::rc::Rc::from([]), &known, &mut acc).known;
|
||||
let more = analyze::scan(text, declared, braced, None, std::rc::Rc::from([]), &known, &mut acc).known;
|
||||
known.add(more, entries);
|
||||
if known.len() == before {
|
||||
acc.steps = acc.steps.saturating_add(spent);
|
||||
@@ -249,7 +256,7 @@ fn settle<'l>(
|
||||
spent = spent.saturating_add(acc.steps.saturating_sub(pre.steps));
|
||||
}
|
||||
let mut acc = pre;
|
||||
analyze::scan(text, declared, None, std::rc::Rc::from([]), &known, &mut acc);
|
||||
analyze::scan(text, declared, braced, None, std::rc::Rc::from([]), &known, &mut acc);
|
||||
acc.steps = acc.steps.saturating_add(spent);
|
||||
acc
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ fn names_written_with_escapes_are_read_as_the_names_they_spell() {
|
||||
/// `src` with the escapes in its names written as the characters they stand for.
|
||||
fn unescaped(src: &str) -> String {
|
||||
let limits = Limits::default();
|
||||
crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).into_owned()
|
||||
crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).0.into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -364,6 +364,155 @@ fn malformed_escapes_in_bulk_are_read_once_through() {
|
||||
assert_eq!(r.lines, 3);
|
||||
assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[3][..]));
|
||||
assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len());
|
||||
// Braced escapes: long runs of digits, closed and not, and ones that end at once.
|
||||
let zeros = "0".repeat(100_000);
|
||||
let src = format!(
|
||||
"a\\u{{{zeros}62}};\n\\u{{{}\n{}\n{}\nalert(1);\n",
|
||||
"f".repeat(100_000),
|
||||
"\\u{".repeat(20_000),
|
||||
"\\u{0000000061}\\u{110000}\\u{}\\u{D800}#\\u{61}".repeat(10_000)
|
||||
);
|
||||
let r = report(&src);
|
||||
assert_eq!(r.lines, 5);
|
||||
assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[5][..]));
|
||||
assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braced_escapes_in_names_are_read_by_their_value() {
|
||||
for (src, want) in [
|
||||
("\\u{61}pp", "app"),
|
||||
("a\\u{62}c", "abc"),
|
||||
("\\u{0000000061}", "a"),
|
||||
("\\u{2118}\\u{203F}", "\u{2118}\u{203F}"),
|
||||
("a\\u{1D7CE}", "a\u{1D7CE}"),
|
||||
("\\u{1D49C}", "\u{1D49C}"),
|
||||
// Not at the start of a name, in no name, out of range, empty, a surrogate and not closed.
|
||||
("\\u{30}a", "\\u{30}a"),
|
||||
("a\\u{1F600}", "a\\u{1F600}"),
|
||||
("a\\u{110000}", "a\\u{110000}"),
|
||||
("a\\u{FFFFFFFFFFFFFFFFFFFF}b", "a\\u{FFFFFFFFFFFFFFFFFFFF}b"),
|
||||
("a\\u{}b", "a\\u{}b"),
|
||||
("a\\u{D800}", "a\\u{D800}"),
|
||||
("a\\u{61", "a\\u{61"),
|
||||
("a\\u{6 1}", "a\\u{6 1}"),
|
||||
] {
|
||||
assert_eq!(unescaped(src), want, "{src:?}");
|
||||
}
|
||||
assert_eq!(tokens("\\u{61}pp.x"), [(T::Ident, "\\u{61}pp"), (T::Punct, "."), (T::Ident, "x")]);
|
||||
assert_eq!(tokens("a\\u{0000000062}"), [(T::Ident, "a\\u{0000000062}")]);
|
||||
assert_eq!(tokens("a\\u{1F600}").get(..2), Some(&[(T::Ident, "a"), (T::Other, "\\")][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn braced_name_escape_reports_later_syntax_and_member() {
|
||||
let r = report("\\u{61}pp.activeDocument.acmeName;\nvar a\\u{62}c = 1, \\u{0000000061}pp2 = 2;\n\\u{61}pp.x + \\u{61}pp.y;\n");
|
||||
let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(members, ["app.activeDocument.acmeName", "app.x", "app.y"]);
|
||||
assert_eq!(
|
||||
problems(&r),
|
||||
[
|
||||
("newerSyntax", Some(1), Some("\\u{61}")),
|
||||
("newerSyntax", Some(2), Some("\\u{62}")),
|
||||
("newerSyntax", Some(2), Some("\\u{0000000061}")),
|
||||
("newerSyntax", Some(3), Some("\\u{61}")),
|
||||
]
|
||||
);
|
||||
// An escape that isn't a name's character, or isn't closed, makes no name and no report of one.
|
||||
for src in [
|
||||
"\\u{1F600}pp.activeDocument;",
|
||||
"\\u{110000}pp.activeDocument;",
|
||||
"\\u{}pp.activeDocument;",
|
||||
"\\u{D800}pp.activeDocument;",
|
||||
"\\u{61pp.activeDocument;",
|
||||
] {
|
||||
let r = report(src);
|
||||
assert!(r.members.iter().all(|m| !m.name.starts_with("app")), "{src:?}: {:?}", r.members);
|
||||
assert!(r.problems.iter().all(|p| p.detail.as_deref().is_none_or(|d| !d.starts_with("\\u{"))), "{src:?}: {:?}", r.problems);
|
||||
}
|
||||
// In an included file, the report names the file.
|
||||
let mut m = mem(&[("lib.jsxinc", "\\u{61}pp.name;\n")]);
|
||||
let r = check("main.jsx", b"#include 'lib.jsxinc'\n", Some(&mut m), &Limits::default());
|
||||
let found: Vec<_> = r.problems.iter().map(|p| (p.code, p.line, p.file.as_deref(), p.detail.as_deref())).collect();
|
||||
assert_eq!(found, [("newerSyntax", Some(1), Some("lib.jsxinc"), Some("\\u{61}"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_names_with_escaped_starts_are_single_tokens() {
|
||||
assert_eq!(tokens("a #\\u0061b"), [(T::Ident, "a"), (T::Other, "#\\u0061b")]);
|
||||
assert_eq!(tokens("this.#\\u0061pp"), [(T::Ident, "this"), (T::Punct, "."), (T::Other, "#\\u0061pp")]);
|
||||
assert_eq!(tokens("o.#\\u{61}b"), [(T::Ident, "o"), (T::Punct, "."), (T::Other, "#\\u{61}b")]);
|
||||
// An escape for a character no name starts with leaves `#` on its own.
|
||||
assert_eq!(tokens("a #\\u0030b").get(..3), Some(&[(T::Ident, "a"), (T::Other, "#"), (T::Other, "\\")][..]));
|
||||
let r = report("var q = this.#\\u0061pp;\nvar r = o.#\\u{61}b;\n");
|
||||
assert!(r.members.is_empty(), "{:?}", r.members);
|
||||
assert_eq!(
|
||||
problems(&r),
|
||||
[("newerSyntax", Some(1), Some("#app")), ("newerSyntax", Some(2), Some("\\u{61}")), ("newerSyntax", Some(2), Some("#ab"))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_name_reports_decoded_construct() {
|
||||
let r = report("var q = this.#hid\\u0064en;\nvar r = this.#\\u0061\\u0062;\n");
|
||||
assert_eq!(problems(&r), [("newerSyntax", Some(1), Some("#hidden")), ("newerSyntax", Some(2), Some("#ab"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_name_at_line_start_is_not_a_directive() {
|
||||
let r = report(concat!(
|
||||
"class Box {\n",
|
||||
" #size = 1;\n",
|
||||
" #\\u0061rea = 2;\n",
|
||||
" #include 'a.jsxinc'\n",
|
||||
" #\\u0069nclude = 4;\n",
|
||||
" #\\u{69}nclude = 5;\n",
|
||||
"}\n",
|
||||
"#\\u0062ox = 3;\n",
|
||||
"#pragma once\n",
|
||||
"#target acme\n",
|
||||
));
|
||||
let directives: Vec<_> = r.directives.iter().map(|d| (d.name, d.line)).collect();
|
||||
assert_eq!(directives, [("include", 4), ("target", 10)]);
|
||||
// Where the name a private name's escapes spell would start a preprocessor line, the report
|
||||
// gives the name as written.
|
||||
assert_eq!(
|
||||
problems(&r),
|
||||
[
|
||||
("newerSyntax", Some(1), Some("class")),
|
||||
("newerSyntax", Some(2), Some("#size")),
|
||||
("newerSyntax", Some(3), Some("#area")),
|
||||
("newerSyntax", Some(5), Some("#\\u0069nclude")),
|
||||
("newerSyntax", Some(6), Some("\\u{69}")),
|
||||
("newerSyntax", Some(6), Some("#\\u{69}nclude")),
|
||||
("newerSyntax", Some(8), Some("#\\u0062ox")),
|
||||
("unknownDirective", Some(9), Some("#pragma")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_name_that_starts_with_a_directive_name_is_not_a_directive() {
|
||||
let r = report(concat!(
|
||||
"class Box {\n",
|
||||
" #include$size = 1;\n",
|
||||
" #include\u{e9} = 2;\n",
|
||||
" #include\\u0041 = 3;\n",
|
||||
" #include\\u{41} = 4;\n",
|
||||
" #INCLUDE 'a.jsxinc'\n",
|
||||
"}\n",
|
||||
));
|
||||
let directives: Vec<_> = r.directives.iter().map(|d| (d.name, d.line)).collect();
|
||||
assert_eq!(directives, [("include", 6)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_code_uses_escaped_names() {
|
||||
let r = report("var x = <a>{\\u{61}pp.activeDocument}</a>;\nvar y = <b>\\u0061pp.z \\u{61}</b>;\n");
|
||||
let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(members, ["app.activeDocument"]);
|
||||
assert_eq!(problems(&r), [("newerSyntax", Some(1), Some("\\u{61}"))]);
|
||||
assert!(tokens("var x = <a>{this.#\\u0061b}</a>;").contains(&(T::Other, "#\\u0061b")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1590,7 +1739,7 @@ fn include_lines_share_the_folders_of_the_include_path_before_them() {
|
||||
let limits = Limits::default();
|
||||
let mut acc = report::Acc::new(&limits);
|
||||
let declared = analyze::declarations(&src, &mut acc);
|
||||
let scan = analyze::scan(&src, &declared, None, std::rc::Rc::from([]), &analyze::Known::default(), &mut acc);
|
||||
let scan = analyze::scan(&src, &declared, &[], None, std::rc::Rc::from([]), &analyze::Known::default(), &mut acc);
|
||||
let (first, rest) = scan.includes.split_first().unwrap();
|
||||
assert_eq!((first.paths.len(), rest.len()), (64, 999));
|
||||
assert!(rest.iter().all(|i| std::rc::Rc::ptr_eq(&i.paths, &first.paths)), "one list of folders, not a copy for each line");
|
||||
|
||||
@@ -119,6 +119,13 @@ impl PointSnap {
|
||||
Self { targets }
|
||||
}
|
||||
|
||||
/// Snapping for a point placed anywhere, as a drawing tool's ([`DrawSnap`]) is: the art in the
|
||||
/// window as targets ([`Targets::for_drawing`]), live text as Snap to Glyph asks.
|
||||
pub fn drawn(cx: &ToolContext) -> Self {
|
||||
let glyphs = (cx.snap_to_glyph && cx.smart_guides && cx.alignment_guides).then_some(cx.glyph_snap);
|
||||
Self::new(cx, || Targets::for_drawing(cx.doc, &[], visible(cx), glyphs))
|
||||
}
|
||||
|
||||
/// Where `p` goes, and the guides showing why.
|
||||
pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
|
||||
self.snap_from(cx, p, None)
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
//! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards'
|
||||
//! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its
|
||||
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden
|
||||
//! or locked layer, can't be picked.
|
||||
//! or locked layer, can't be picked. The rulers' origin, dragged out of their corner
|
||||
//! ([`OriginDrag`]), snaps as a drawn point does.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Guide, NodeId};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::guides::Targets;
|
||||
use crate::guides::{PointSnap, Targets};
|
||||
use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext};
|
||||
|
||||
/// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection
|
||||
@@ -247,6 +248,39 @@ impl NewGuide {
|
||||
}
|
||||
}
|
||||
|
||||
/// The rulers' origin dragged out of the box where they meet, with any tool: it snaps as a drawn
|
||||
/// point does ([`PointSnap::drawn`]), with Smart Guides onto the anchors, centres, edges and paths
|
||||
/// of the art and the artboards or into line with them, with Snap to Point onto the anchors and
|
||||
/// along the ruler guides, else onto whole pixels or the grid. The targets are gathered when the
|
||||
/// drag begins. It changes nothing: the origin is set where the button is released.
|
||||
pub struct OriginDrag {
|
||||
snap: PointSnap,
|
||||
/// The guides showing what the origin snapped to.
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
impl OriginDrag {
|
||||
pub fn new(cx: &ToolContext) -> Self {
|
||||
Self { snap: PointSnap::drawn(cx), guides: vec![] }
|
||||
}
|
||||
|
||||
/// Where the origin goes with the pointer at `p` (none off the canvas).
|
||||
pub fn pointer(&mut self, cx: &ToolContext, p: Option<Point>) -> Option<Point> {
|
||||
let Some(p) = p else {
|
||||
self.guides.clear();
|
||||
return None;
|
||||
};
|
||||
let (at, guides) = self.snap.snap(cx, p);
|
||||
self.guides = guides;
|
||||
Some(at)
|
||||
}
|
||||
|
||||
/// The guides showing what the origin snapped to.
|
||||
pub fn overlays(&self) -> Vec<Overlay> {
|
||||
self.guides.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection tool's ruler-guide handling: [`Self::press`] on a press, [`Self::pointer`] for
|
||||
/// the rest of the gesture.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -50,7 +50,7 @@ use fill::fill_uncovered;
|
||||
use fill::{fill_view, polygon_area};
|
||||
#[cfg(test)]
|
||||
use grid::ruler_rects;
|
||||
use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, textured_quad, view_bounds};
|
||||
use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, rulers_hidden, textured_quad, view_bounds};
|
||||
pub(crate) use grid::{RulerScale, checker_texture, ruler_label, ruler_origin_dragging};
|
||||
#[cfg(test)]
|
||||
pub(crate) use grid::{TRANSPARENCY_GRID, vertical_label_height};
|
||||
@@ -283,7 +283,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.ctx().request_repaint_after_secs(secs as f32);
|
||||
}
|
||||
let hover_still = wait.is_none();
|
||||
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None };
|
||||
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { rulers_hidden(app, ui) };
|
||||
crate::document_drag::drop_on_canvas(app, &resp, &xf);
|
||||
panel_drop(app, ui, &resp, &xf);
|
||||
// In Presentation Mode a right click or a long touch opens no context menu.
|
||||
|
||||
@@ -53,7 +53,10 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf:
|
||||
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
|
||||
/// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries
|
||||
/// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on
|
||||
/// top of its appearance). A chip follows the pointer meanwhile.
|
||||
/// top of its appearance), and a brush is applied to it (`brush.apply`, `brush.addFromLibrary`
|
||||
/// for a brush library's). A Brushes panel scatter, art or pattern
|
||||
/// brush dropped where there is no art places a copy of its art there (`brush.placeArt`). A chip
|
||||
/// follows the pointer meanwhile.
|
||||
pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) {
|
||||
crate::panels::swatches::drag_preview(app, ui.ctx());
|
||||
let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return };
|
||||
@@ -86,7 +89,19 @@ pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respons
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return };
|
||||
let hit = hit_at(app, xf.to_doc(pos), xf.zoom);
|
||||
// A scatter, art or pattern brush from the Brushes panel dropped where there is no art: a copy
|
||||
// of its art, centered where it is dropped, to edit and Alt-drag back onto the brush.
|
||||
if let (None, widgets::PanelDrag::Brush { name, def }) = (&hit, &*d)
|
||||
&& matches!(def["type"].as_str(), Some("scatter" | "art" | "pattern"))
|
||||
{
|
||||
let at = xf.to_doc(pos);
|
||||
if let Err(e) = app.run("brush.placeArt", json!({"name": name, "x": at.x, "y": at.y})) {
|
||||
app.status(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(hit) = hit else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let (cmd, params) = match &*d {
|
||||
widgets::PanelDrag::Paint { params, .. } => {
|
||||
|
||||
@@ -181,8 +181,9 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
||||
/// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the
|
||||
/// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the
|
||||
/// left; swapped in a view turned 90°, where the rulers read the other document axis, and the
|
||||
/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a
|
||||
/// double-click.
|
||||
/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags
|
||||
/// the rulers' origin, snapped as a drawn point is (`Session::ruler_origin_drag`), or resets it on
|
||||
/// a double-click.
|
||||
pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> {
|
||||
let [top, left, corner] = ruler_rects(full);
|
||||
// Right-click on a ruler or the origin box: the document units, to swap between them as
|
||||
@@ -274,9 +275,17 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
|
||||
let mut preview = None;
|
||||
if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option<u64>, bool)>(drag_id)) {
|
||||
cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape));
|
||||
let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p));
|
||||
let pointer =
|
||||
corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p));
|
||||
// The origin snaps as a drawn point does (Smart Guides, Snap to Point, pixels, the grid).
|
||||
let at = if cancelled {
|
||||
app.session.end_ruler_origin_drag();
|
||||
None
|
||||
} else {
|
||||
app.session.ruler_origin_drag(pointer, app.view_info())
|
||||
};
|
||||
if corner.drag_stopped_by(egui::PointerButton::Primary) {
|
||||
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
|
||||
end_ruler_origin_drag(app, ui);
|
||||
if !cancelled
|
||||
&& let Some(at) = at
|
||||
&& let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)}))
|
||||
@@ -284,7 +293,7 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
|
||||
app.status(e.to_string());
|
||||
}
|
||||
} else if !ui.input(|i| i.pointer.primary_down()) {
|
||||
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
|
||||
end_ruler_origin_drag(app, ui);
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled)));
|
||||
if !cancelled {
|
||||
@@ -295,6 +304,21 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
|
||||
preview
|
||||
}
|
||||
|
||||
/// The rulers are hidden (View › Rulers, Presentation Mode): a drag of their origin they were
|
||||
/// showing for is cancelled, so its snapping guides don't stay on the canvas. Nothing previews.
|
||||
pub(super) fn rulers_hidden(app: &mut VectorcraftApp, ui: &Ui) -> Option<Point> {
|
||||
if ruler_origin_dragging(ui.ctx()) {
|
||||
end_ruler_origin_drag(app, ui);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Ends a drag from the rulers' corner, released or cancelled: its state and its snapping guides go.
|
||||
fn end_ruler_origin_drag(app: &mut VectorcraftApp, ui: &Ui) {
|
||||
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(ruler_origin_drag_id()));
|
||||
app.session.end_ruler_origin_drag();
|
||||
}
|
||||
|
||||
/// The key of the ruler origin drag's state in egui's temporary data.
|
||||
fn ruler_origin_drag_id() -> egui::Id {
|
||||
egui::Id::unique("ruler-origin-drag")
|
||||
|
||||
@@ -6983,6 +6983,49 @@ fn a_brush_dropped_on_a_path_is_applied_to_it() {
|
||||
assert_eq!(brush(&app).as_deref(), Some("Arrow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||
let drop = |app: &mut VectorcraftApp, name: &str, at: Point| {
|
||||
let def = app.session.execute("brush.get", &json!({"name": name})).unwrap();
|
||||
let at = xf.to_screen(at);
|
||||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: name.into(), def });
|
||||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||||
};
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
// A calligraphic brush has no art: nothing.
|
||||
drop(&mut app, "3 pt. Round", Point::new(200.0, 250.0));
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||||
// An art brush: a copy of its art, centered where it lands, selected.
|
||||
drop(&mut app, "Arrow", Point::new(200.0, 250.0));
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Place Brush Art");
|
||||
let [placed] = st.selection.objects.as_slice() else { panic!("one object selected: {:?}", st.selection.objects) };
|
||||
let b = st.doc.node(*placed).unwrap().geometric_bounds().unwrap();
|
||||
assert!((b.center() - Point::new(200.0, 250.0)).hypot() < 0.1, "{b:?}");
|
||||
// A scatter brush and a pattern brush (its tiles, in one group) place their art too.
|
||||
for (i, name) in ["Dots", "Chain"].into_iter().enumerate() {
|
||||
let at = Point::new(80.0 + 200.0 * i as f64, 60.0);
|
||||
drop(&mut app, name, at);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 2 + i, "{name}");
|
||||
let [placed] = st.selection.objects.as_slice() else { panic!("{name}: one object selected: {:?}", st.selection.objects) };
|
||||
let n = st.doc.node(*placed).unwrap();
|
||||
assert!((n.geometric_bounds().unwrap().center() - at).hypot() < 0.1, "{name}");
|
||||
if name == "Chain" {
|
||||
assert_eq!(n.children().unwrap()[0].name.as_deref(), Some("Side Tile"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -248,8 +248,9 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) {
|
||||
/// Most notes the status message's hover and access information list.
|
||||
const MAX_LISTED_NOTES: usize = 30;
|
||||
|
||||
/// The notes behind the status message (the open's, [`crate::state::UiState::status_notes`]) when
|
||||
/// there are several and the status bar still shows the message they came with, else none.
|
||||
/// The notes behind the status message (an open's, a save's or an export's,
|
||||
/// [`crate::state::UiState::status_notes`]) when there are several and the status bar still shows
|
||||
/// the message they came with, else none.
|
||||
fn status_notes(ui: &crate::state::UiState) -> &[String] {
|
||||
let notes = ui.status_notes.as_ref().filter(|(status, notes)| *status == ui.status && notes.len() > 1);
|
||||
notes.map(|(_, notes)| notes.as_slice()).unwrap_or_default()
|
||||
|
||||
@@ -40,6 +40,7 @@ pub fn open(app: &mut VectorcraftApp, para: bool, target: Value) -> Result<Value
|
||||
fields.insert("section".into(), json!("general"));
|
||||
let attrs = target.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
fields.extend(super::para_style_options::form_fields(&attrs, app.session.general_unit()));
|
||||
fields.insert("__vertical".into(), json!(super::para_style_options::vertical_text(app)));
|
||||
} else {
|
||||
fields.insert("section".into(), json!("general"));
|
||||
fields.insert("__smallCapsSize".into(), json!(d.setup.small_caps_size));
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
//! Paragraph Style Options fields shared by editing and creating a paragraph style: General, Basic
|
||||
//! Paragraph Formats (alignment, indents and spacing), Composition (composer and Roman Hanging
|
||||
//! Punctuation), Hyphenation and Justification.
|
||||
//! Paragraph Formats (alignment, indents and spacing), Tabs (the style's tab stops), Composition
|
||||
//! (composer and Roman Hanging Punctuation), Hyphenation and Justification.
|
||||
//!
|
||||
//! Each field is unset (empty, or a checkbox in its unset state) or set, and an unset field leaves
|
||||
//! its attribute out of the style. Distances are shown and typed in the General unit and kept in
|
||||
//! points. The Hyphenation and Justification options are one attribute each (`hyphenation`,
|
||||
//! `justification`): when any of their fields is set, a field left empty takes its default value.
|
||||
//! A field still as it opened keeps the style's stored value as it is.
|
||||
//! The `tabs` field is unset (null) or a list of stops, an empty list included, and is the `tabs`
|
||||
//! attribute. A field still as it opened keeps the style's stored value as it is.
|
||||
//!
|
||||
//! Behavior reference (public documentation):
|
||||
//! <https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html>.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{HyphenationOptions, JustificationOptions, Unit};
|
||||
use vectorcraft_doc::text::DEFAULT_TAB_INTERVAL;
|
||||
use vectorcraft_doc::{HyphenationOptions, JustificationOptions, TabAlign, TabStop, Unit};
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
use crate::VectorcraftApp;
|
||||
@@ -21,7 +23,11 @@ use crate::state::Dialog;
|
||||
pub const KIND: &str = "paraStyleOptions";
|
||||
|
||||
/// The section ids, in the order the section list shows them.
|
||||
pub(crate) const SECTIONS: [&str; 5] = ["general", "basic", "composition", "hyphenation", "justification"];
|
||||
pub(crate) const SECTIONS: [&str; 6] = ["general", "basic", "tabs", "composition", "hyphenation", "justification"];
|
||||
/// The alignments of a tab stop with the names the `tabs` field gives them, in the Tabs panel's
|
||||
/// order.
|
||||
const TAB_ALIGNS: [(TabAlign, &str); 4] =
|
||||
[(TabAlign::Left, "left"), (TabAlign::Center, "center"), (TabAlign::Right, "right"), (TabAlign::Decimal, "decimal")];
|
||||
/// The distance fields of Basic Paragraph Formats, which are also the attributes they set.
|
||||
const DISTANCES: [&str; 5] = ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"];
|
||||
/// The range of the indents, the space before and after, and the Hyphenation Zone (pt), as
|
||||
@@ -98,6 +104,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
"glyph_max",
|
||||
"auto_leading",
|
||||
"single_word",
|
||||
"tabs",
|
||||
"section",
|
||||
]
|
||||
},
|
||||
@@ -112,6 +119,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
|
||||
let doc = &app.session.active().ok_or("no document open")?.doc;
|
||||
let def = doc.para_styles.iter().find(|s| s.name == name).ok_or_else(|| format!("no style `{name}`"))?;
|
||||
let mut fields = form_fields(&def.attrs, unit);
|
||||
fields.insert("__vertical".into(), json!(vertical_text(app)));
|
||||
fields.insert("name".into(), json!(name));
|
||||
fields.insert("__style".into(), json!(name));
|
||||
fields.insert("section".into(), json!("general"));
|
||||
@@ -129,6 +137,76 @@ pub(crate) fn form_fields(attrs: &Map<String, Value>, unit: Unit) -> Map<String,
|
||||
fields
|
||||
}
|
||||
|
||||
/// Whether the first selected text is vertical type, where the Tabs section names a stop's
|
||||
/// position Y and its Left and Right alignments Top and Bottom.
|
||||
pub(crate) fn vertical_text(app: &VectorcraftApp) -> bool {
|
||||
crate::panels::character::first_selected_text(app).is_some_and(|t| t.vertical)
|
||||
}
|
||||
|
||||
/// The `tabs` field for a style's `tabs` attribute: unset (null) when the style leaves it out,
|
||||
/// otherwise one entry per stop ([`stop_field`]).
|
||||
fn tabs_field(value: Option<&Value>, unit: Unit) -> Value {
|
||||
let Some(stops) = value.and_then(Value::as_array) else { return Value::Null };
|
||||
Value::Array(stops.iter().filter_map(|s| serde_json::from_value::<TabStop>(s.clone()).ok()).map(|s| stop_field(&s, unit)).collect())
|
||||
}
|
||||
|
||||
/// A tab stop's entry in the `tabs` field: its position as text in `unit`, its alignment, leader
|
||||
/// and Align On character, and the position in points it shows (`__pt`), which a position still as
|
||||
/// it shows keeps exactly.
|
||||
fn stop_field(s: &TabStop, unit: Unit) -> Value {
|
||||
let align = TAB_ALIGNS.iter().find(|(a, _)| *a == s.align).map_or("left", |(_, key)| *key);
|
||||
json!({"position": unit.format(s.position), "align": align, "leader": s.leader, "alignOn": s.align_on.to_string(), "__pt": s.position})
|
||||
}
|
||||
|
||||
/// The position in points of a `tabs` entry: its `__pt` while its text is as that shows in `unit`,
|
||||
/// else the text read in `unit`; `None` when the text is not a distance.
|
||||
fn stop_position(row: &Value, unit: Unit) -> Option<f64> {
|
||||
let text = row.get("position").and_then(Value::as_str).map_or("", str::trim);
|
||||
row.get("__pt").and_then(Value::as_f64).filter(|pt| pt.is_finite() && unit.format(*pt) == text).or_else(|| unit.parse(text))
|
||||
}
|
||||
|
||||
/// The tab stop a `tabs` entry sets, or why it sets none: its position must be a distance from 0 to
|
||||
/// 100000 pt (`label` names the field), its alignment one of [`TAB_ALIGNS`], its leader text of at
|
||||
/// most eight characters and its Align On character one character. A leader or Align On character
|
||||
/// left out is none or a period.
|
||||
fn read_stop(row: &Value, unit: Unit, label: &str) -> Result<TabStop, String> {
|
||||
let position = stop_position(row, unit).ok_or_else(|| format!("{label} must be a number"))?;
|
||||
if !(0.0..=TabStop::MAX_POSITION).contains(&position) {
|
||||
let (min, max) = (unit.format(0.0), unit.format(TabStop::MAX_POSITION));
|
||||
return Err(format!("{label} must be a number from {min} to {max}"));
|
||||
}
|
||||
let other = match row.get("align") {
|
||||
None | Some(Value::Null) => "left",
|
||||
Some(v) => v.as_str().unwrap_or(""),
|
||||
};
|
||||
let align = TAB_ALIGNS.iter().find(|(_, key)| *key == other).map(|(a, _)| *a).ok_or_else(|| format!("unknown align `{other}`"))?;
|
||||
let leader = match row.get("leader") {
|
||||
None | Some(Value::Null) => "",
|
||||
Some(Value::String(s)) => s.as_str(),
|
||||
Some(_) => {
|
||||
let key = "leader";
|
||||
return Err(format!("`{key}` must be a string"));
|
||||
}
|
||||
};
|
||||
let (label, max) = (caption(tl!("Leader:")), TabStop::MAX_LEADER);
|
||||
if leader.chars().count() > max {
|
||||
return Err(format!("{label} must be at most {max} characters"));
|
||||
}
|
||||
let label = caption(tl!("Align On:"));
|
||||
let align_on = match row.get("alignOn") {
|
||||
None | Some(Value::Null) => '.',
|
||||
Some(Value::String(s)) => {
|
||||
let mut chars = s.chars();
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some(c), None) => c,
|
||||
_ => return Err(format!("{label} must be one character")),
|
||||
}
|
||||
}
|
||||
Some(_) => return Err(format!("{label} must be one character")),
|
||||
};
|
||||
Ok(TabStop { position, align, leader: leader.into(), align_on })
|
||||
}
|
||||
|
||||
/// A distance in points as its field shows it in `unit`.
|
||||
fn distance_field(value: Option<&Value>, unit: Unit) -> Value {
|
||||
value.and_then(Value::as_f64).map_or_else(|| json!(""), |pt| json!(unit.format(pt)))
|
||||
@@ -174,6 +252,7 @@ pub(crate) fn fields_from_attrs(attrs: &Map<String, Value>, unit: Unit) -> Map<S
|
||||
}
|
||||
fields.insert("auto_leading".into(), number_field(justification.and_then(|j| j.get("auto_leading"))));
|
||||
fields.insert("single_word".into(), justification.and_then(|j| j.get("single_word")).filter(|v| v.is_string()).cloned().unwrap_or(Value::Null));
|
||||
fields.insert("tabs".into(), tabs_field(attrs.get("tabs"), unit));
|
||||
fields
|
||||
}
|
||||
|
||||
@@ -292,9 +371,60 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result<Map<String, Value>, String> {
|
||||
let same = JUSTIFICATION_FIELDS.iter().all(|key| unchanged(key, "__resetJustification"));
|
||||
let value = if same { None } else { justification(d, &original, &original_fields)? };
|
||||
keep_or(&mut attrs, "justification", same, value);
|
||||
|
||||
let same = unchanged("tabs", "__resetTabs");
|
||||
let value = if same { None } else { tab_stops(d, &original, unit)? };
|
||||
keep_or(&mut attrs, "tabs", same, value);
|
||||
Ok(attrs)
|
||||
}
|
||||
|
||||
/// The `tabs` attribute the Tabs section sets: none while the list is unset; otherwise its stops
|
||||
/// sorted by position. Of stops within 1e-6 pt of each other, the one edited last (`__tabEdited`)
|
||||
/// is kept, else the first in the list. A stop as the style has it is kept without a check, and any
|
||||
/// other is read by [`read_stop`]. An error when the list has more than 256 stops and more than the
|
||||
/// style has.
|
||||
fn tab_stops(d: &Dialog, original: &Map<String, Value>, unit: Unit) -> Result<Option<Value>, String> {
|
||||
let rows = match d.fields.get("tabs") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::Array(rows)) => rows,
|
||||
Some(_) => {
|
||||
let key = "tabs";
|
||||
return Err(format!("`{key}` must be an array"));
|
||||
}
|
||||
};
|
||||
let kept: Vec<TabStop> =
|
||||
original.get("tabs").and_then(Value::as_array).into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect();
|
||||
let max = TabStop::MAX_STOPS;
|
||||
if rows.len() > max.max(kept.len()) {
|
||||
return Err(format!("at most {max} tab stops"));
|
||||
}
|
||||
let label = caption(if d.bool("__vertical") { tl!("Y:") } else { tl!("X:") });
|
||||
let kept_rows: Vec<Value> = kept.iter().map(|s| stop_field(s, unit)).collect();
|
||||
let mut stops = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
match kept_rows.iter().position(|k| k == row) {
|
||||
Some(i) => stops.push(kept[i].clone()),
|
||||
None => stops.push(read_stop(row, unit, label)?),
|
||||
}
|
||||
}
|
||||
let edited = d.fields.get("__tabEdited").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok());
|
||||
let mut order: Vec<usize> = (0..stops.len()).collect();
|
||||
order.sort_by(|&a, &b| stops[a].position.total_cmp(&stops[b].position).then(a.cmp(&b)));
|
||||
let mut keep: Vec<usize> = Vec::with_capacity(order.len());
|
||||
for i in order {
|
||||
match keep.last_mut() {
|
||||
Some(last) if (stops[*last].position - stops[i].position).abs() < 1e-6 => {
|
||||
if Some(i) == edited {
|
||||
*last = i;
|
||||
}
|
||||
}
|
||||
_ => keep.push(i),
|
||||
}
|
||||
}
|
||||
let stops: Vec<TabStop> = keep.into_iter().map(|i| stops[i].clone()).collect();
|
||||
Ok(Some(serde_json::to_value(stops).map_err(|e| e.to_string())?))
|
||||
}
|
||||
|
||||
/// The `hyphenation` attribute the Hyphenation fields set: none when every field is empty or
|
||||
/// unset; otherwise the style's options (or the defaults) with each changed field written over
|
||||
/// them, an empty one at its default.
|
||||
@@ -416,15 +546,24 @@ fn reset_justification(d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset Panel in Tabs: the tab stops become unset, which leaves `tabs` out of the style.
|
||||
fn reset_tabs(d: &mut Dialog) {
|
||||
d.fields.insert("__resetTabs".into(), json!(true));
|
||||
d.fields.insert("tabs".into(), Value::Null);
|
||||
d.fields.remove("__tabEdited");
|
||||
d.fields.insert("__tabSel".into(), json!(0));
|
||||
}
|
||||
|
||||
/// Whether the section `d` shows has a Reset Panel: every section but General.
|
||||
pub(crate) fn can_reset(d: &Dialog) -> bool {
|
||||
["basic", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str())
|
||||
["basic", "tabs", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str())
|
||||
}
|
||||
|
||||
/// Reset Panel: clear the settings of the section `d` shows.
|
||||
pub(crate) fn reset_section(d: &mut Dialog) {
|
||||
match d.str("section").as_str() {
|
||||
"basic" => reset_basic(d),
|
||||
"tabs" => reset_tabs(d),
|
||||
"composition" => reset_composition(d),
|
||||
"hyphenation" => reset_hyphenation(d),
|
||||
"justification" => reset_justification(d),
|
||||
@@ -449,9 +588,8 @@ pub fn reset_panel(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
/// The section list of Paragraph Style Options and New Paragraph Style.
|
||||
pub(crate) fn section_list(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.vertical(|ui| {
|
||||
for (id, label) in
|
||||
SECTIONS.into_iter().zip([tl!("General"), tl!("Basic Paragraph Formats"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")])
|
||||
{
|
||||
let labels = [tl!("General"), tl!("Basic Paragraph Formats"), tl!("Tabs"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")];
|
||||
for (id, label) in SECTIONS.into_iter().zip(labels) {
|
||||
if ui.selectable_label(d.str("section") == id, label).clicked() {
|
||||
d.fields.insert("section".into(), json!(id));
|
||||
}
|
||||
@@ -529,6 +667,156 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The editing fields of the selected tab stop, with the `tabs` entry key each one shows.
|
||||
const TAB_EDITORS: [(&str, &str); 3] = [("__tabPosition", "position"), ("__tabLeader", "leader"), ("__tabAlignOn", "alignOn")];
|
||||
|
||||
/// Tabs: the selected stop's Alignment, X (Y in vertical type), Leader and Align On, which only a
|
||||
/// decimal stop uses; Add ([`add_tab`]), Delete Tab ([`delete_tab`]) and Clear All Tabs
|
||||
/// ([`clear_tabs`]); and the list of stops, one row each, a click on which selects its stop, which
|
||||
/// scrolls when it is taller than 160 points. A change to a field makes its stop the one edited last.
|
||||
pub(crate) fn tabs_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let vertical = d.bool("__vertical");
|
||||
let (mut rows, mut sel, _) = tab_rows(d);
|
||||
let mut changed = false;
|
||||
let has = sel < rows.len();
|
||||
let shown = TAB_EDITORS.map(|(_, key)| rows.get(sel).and_then(|r| r.get(key)).and_then(Value::as_str).unwrap_or("").to_string());
|
||||
for ((field, _), text) in TAB_EDITORS.iter().zip(&shown) {
|
||||
d.fields.insert((*field).into(), json!(text));
|
||||
}
|
||||
let align = rows.get(sel).and_then(|r| r.get("align")).and_then(Value::as_str).unwrap_or("left").to_string();
|
||||
egui::Grid::new("paragraph-style-tabs").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| {
|
||||
let row = ui.label(tl!("Alignment:"));
|
||||
let names: Vec<&str> = TAB_ALIGNS.iter().map(|(a, _)| crate::panels::tabs::align_name(*a, vertical)).collect();
|
||||
let shown = TAB_ALIGNS.iter().position(|(_, key)| *key == align).and_then(|i| names.get(i).copied()).unwrap_or("");
|
||||
ui.add_enabled_ui(has, |ui| {
|
||||
ui.label_inputs_by(row.id, |ui| {
|
||||
if let Some(i) = crate::widgets::dropdown(ui, (KIND, "tab_align"), shown, &names, 190.0)
|
||||
&& let (Some(r), Some((_, key))) = (rows.get_mut(sel).and_then(Value::as_object_mut), TAB_ALIGNS.get(i))
|
||||
{
|
||||
r.insert("align".into(), json!(key));
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
field_row(ui, d, "__tabPosition", if vertical { tl!("Y:") } else { tl!("X:") }, "", has);
|
||||
field_row(ui, d, "__tabLeader", tl!("Leader:"), "", has);
|
||||
field_row(ui, d, "__tabAlignOn", tl!("Align On:"), "", has && align == "decimal");
|
||||
});
|
||||
for ((field, key), was) in TAB_EDITORS.iter().zip(&shown) {
|
||||
let typed = d.str(field);
|
||||
if let Some(r) = rows.get_mut(sel).and_then(Value::as_object_mut)
|
||||
&& typed != *was
|
||||
{
|
||||
r.insert((*key).into(), json!(typed));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
d.fields.insert("tabs".into(), Value::Array(rows.clone()));
|
||||
d.fields.insert("__tabEdited".into(), json!(sel));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let (mut add, mut delete, mut clear) = (false, false, false);
|
||||
let can_add = new_tab_position(&rows, unit_of(d)).is_some();
|
||||
ui.horizontal(|ui| {
|
||||
add = ui.add_enabled(can_add, egui::Button::new(tl!("Add"))).clicked();
|
||||
delete = ui.add_enabled(has, egui::Button::new(tl!("Delete Tab"))).clicked();
|
||||
clear = ui.add_enabled(!rows.is_empty(), egui::Button::new(tl!("Clear All Tabs"))).clicked();
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let reveal = d.fields.remove("__tabScroll").is_some();
|
||||
egui::ScrollArea::vertical().id_salt("paragraph-style-tab-list").max_height(160.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
let a = r.get("align").and_then(Value::as_str).and_then(|key| TAB_ALIGNS.iter().find(|(_, k)| *k == key)).map(|(a, _)| *a);
|
||||
let name = a.map_or("", |a| crate::panels::tabs::align_name(a, vertical));
|
||||
let mut text = format!("{} {name}", r.get("position").and_then(Value::as_str).unwrap_or(""));
|
||||
if let Some(leader) = r.get("leader").and_then(Value::as_str).filter(|l| !l.is_empty()) {
|
||||
text += &format!(" {leader}");
|
||||
}
|
||||
let row = ui.selectable_label(i == sel, text);
|
||||
if reveal && i == sel {
|
||||
ui.scroll_to_rect_animation(row.rect, None, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
if row.clicked() {
|
||||
sel = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
d.fields.insert("__tabSel".into(), json!(sel));
|
||||
if add {
|
||||
add_tab(d);
|
||||
} else if delete {
|
||||
delete_tab(d);
|
||||
} else if clear {
|
||||
clear_tabs(d);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `tabs` entries of `d` (none while unset), the selected one's index (`__tabSel`, at most the
|
||||
/// last) and the index of the one edited last (`__tabEdited`).
|
||||
fn tab_rows(d: &Dialog) -> (Vec<Value>, usize, Option<usize>) {
|
||||
let rows: Vec<Value> = d.fields.get("tabs").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let index = |key: &str| d.fields.get(key).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok());
|
||||
let sel = index("__tabSel").unwrap_or(0).min(rows.len().saturating_sub(1));
|
||||
(rows, sel, index("__tabEdited"))
|
||||
}
|
||||
|
||||
/// The position (pt) Add gives a new stop among `rows`: half an inch past the furthest stop that
|
||||
/// reads, half an inch when none does, and at most 100000 pt. `None` while the list holds 256 stops
|
||||
/// or when a stop already sits at that position.
|
||||
fn new_tab_position(rows: &[Value], unit: Unit) -> Option<f64> {
|
||||
if rows.len() >= TabStop::MAX_STOPS {
|
||||
return None;
|
||||
}
|
||||
let positions: Vec<f64> = rows.iter().filter_map(|r| stop_position(r, unit)).collect();
|
||||
let furthest = positions.iter().copied().filter(|pt| (0.0..=TabStop::MAX_POSITION).contains(pt)).reduce(f64::max);
|
||||
let position = furthest.map_or(DEFAULT_TAB_INTERVAL, |pt| pt + DEFAULT_TAB_INTERVAL).min(TabStop::MAX_POSITION);
|
||||
(!positions.iter().any(|pt| (pt - position).abs() < 1e-6)).then_some(position)
|
||||
}
|
||||
|
||||
/// Add in Tabs: a left stop at [`new_tab_position`], which becomes the selected and last edited
|
||||
/// stop and is scrolled into view in the list. Nothing when that gives no position.
|
||||
pub(crate) fn add_tab(d: &mut Dialog) {
|
||||
let unit = unit_of(d);
|
||||
let (mut rows, _, _) = tab_rows(d);
|
||||
let Some(position) = new_tab_position(&rows, unit) else { return };
|
||||
rows.push(stop_field(&TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: '.' }, unit));
|
||||
let at = rows.len() - 1;
|
||||
d.fields.insert("tabs".into(), Value::Array(rows));
|
||||
d.fields.insert("__tabSel".into(), json!(at));
|
||||
d.fields.insert("__tabEdited".into(), json!(at));
|
||||
d.fields.insert("__tabScroll".into(), json!(true));
|
||||
}
|
||||
|
||||
/// Delete Tab in Tabs: remove the selected stop; the next one (or the last) becomes selected.
|
||||
pub(crate) fn delete_tab(d: &mut Dialog) {
|
||||
let (mut rows, sel, edited) = tab_rows(d);
|
||||
if sel >= rows.len() {
|
||||
return;
|
||||
}
|
||||
rows.remove(sel);
|
||||
match edited {
|
||||
Some(e) if e == sel => {
|
||||
d.fields.remove("__tabEdited");
|
||||
}
|
||||
Some(e) if e > sel => {
|
||||
d.fields.insert("__tabEdited".into(), json!(e - 1));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
d.fields.insert("__tabSel".into(), json!(sel.min(rows.len().saturating_sub(1))));
|
||||
d.fields.insert("tabs".into(), Value::Array(rows));
|
||||
d.fields.insert("__tabScroll".into(), json!(true));
|
||||
}
|
||||
|
||||
/// Clear All Tabs in Tabs: an empty list of stops, which the style then sets.
|
||||
pub(crate) fn clear_tabs(d: &mut Dialog) {
|
||||
d.fields.insert("tabs".into(), json!([]));
|
||||
d.fields.insert("__tabSel".into(), json!(0));
|
||||
d.fields.remove("__tabEdited");
|
||||
}
|
||||
|
||||
/// Composition: the Single-line or Every-line Composer, and Roman Hanging Punctuation.
|
||||
pub(crate) fn composition_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
egui::Grid::new("paragraph-style-composition").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| {
|
||||
@@ -624,6 +912,7 @@ pub(crate) fn general_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
pub(crate) fn section_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
match d.str("section").as_str() {
|
||||
"basic" => basic_body(ui, d),
|
||||
"tabs" => tabs_body(ui, d),
|
||||
"composition" => composition_body(ui, d),
|
||||
"hyphenation" => hyphenation_body(ui, d),
|
||||
"justification" => justification_body(ui, d),
|
||||
|
||||
@@ -162,6 +162,7 @@ fn reset_panel_clears_only_its_section() {
|
||||
let stored = def(&app, "Body");
|
||||
for (section, cleared) in [
|
||||
("basic", &["justify", "space_after"][..]),
|
||||
("tabs", &["tabs"]),
|
||||
("composition", &["composer", "roman_hanging"]),
|
||||
("hyphenation", &["hyphenate", "hyphenation"]),
|
||||
("justification", &["justification"]),
|
||||
@@ -239,7 +240,15 @@ fn new_paragraph_style_offers_the_sections() {
|
||||
set(&mut app, "section", json!(section));
|
||||
texts += &crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx()));
|
||||
}
|
||||
for label in ["Basic Paragraph Formats", "Composition", "First-line Left Indent", "Hyphenate Capitalized Words", "Single Word Justification:"] {
|
||||
for label in [
|
||||
"Basic Paragraph Formats",
|
||||
"Tabs",
|
||||
"Composition",
|
||||
"First-line Left Indent",
|
||||
"Align On:",
|
||||
"Hyphenate Capitalized Words",
|
||||
"Single Word Justification:",
|
||||
] {
|
||||
assert!(texts.contains(label), "{label} is drawn: {texts}");
|
||||
}
|
||||
set(&mut app, "section", json!("basic"));
|
||||
@@ -257,6 +266,237 @@ fn new_paragraph_style_offers_the_sections() {
|
||||
assert_eq!(paras(&app, id)[0].justify, Justify::Center);
|
||||
}
|
||||
|
||||
/// The rows of the `tabs` field of the open dialog.
|
||||
fn tab_rows(app: &VectorcraftApp) -> Vec<Value> {
|
||||
app.ui.dialog.as_ref().unwrap().fields["tabs"].as_array().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Tabs shows a style's stops in the General unit with their alignment, leader and Align On
|
||||
/// character; a stop left as it was keeps its position exactly, a changed one is read in the unit,
|
||||
/// and the stops are stored sorted by position. Add puts a left stop half an inch past the furthest
|
||||
/// stop, Delete Tab removes the selected stop and Clear All Tabs leaves the style an empty list.
|
||||
#[test]
|
||||
fn tabs_section_edits_the_style_stops() {
|
||||
let mut app = app();
|
||||
let stops = json!([{"position": 36.0, "align": "right"}, {"position": 72.123456, "align": "decimal", "leader": ".", "alignOn": ","}]);
|
||||
app.run("paraStyle.new", json!({"name": "Body", "attrs": {"left_indent": 3.0, "tabs": stops}})).unwrap();
|
||||
let stored = def(&app, "Body");
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
let rows = tab_rows(&app);
|
||||
let shown: Vec<(&str, &str, &str, &str)> = rows
|
||||
.iter()
|
||||
.map(|r| (r["position"].as_str().unwrap(), r["align"].as_str().unwrap(), r["leader"].as_str().unwrap(), r["alignOn"].as_str().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(shown, [("36 pt", "right", "", "."), ("72.123 pt", "decimal", ".", ",")]);
|
||||
assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(), stored, "nothing changed");
|
||||
|
||||
// The first stop moves past the second and turns centered; the second keeps its exact position.
|
||||
let mut rows = rows;
|
||||
rows[0]["position"] = json!("1.5 in");
|
||||
rows[0]["align"] = json!("center");
|
||||
set(&mut app, "tabs", Value::from(rows));
|
||||
let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
a["tabs"],
|
||||
json!([{"position": 72.123456, "align": "decimal", "leader": ".", "alignOn": ","}, {"position": 108.0, "align": "center", "alignOn": "."}])
|
||||
);
|
||||
assert_eq!(a["left_indent"], json!(3.0), "other attributes stay");
|
||||
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
add_tab(d);
|
||||
assert_eq!((tab_rows(&app).len(), app.ui.dialog.as_ref().unwrap().fields["__tabSel"].clone()), (3, json!(2)));
|
||||
let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(a["tabs"][2], json!({"position": 144.0, "align": "left", "alignOn": "."}), "half an inch past the furthest");
|
||||
set(&mut app, "__tabSel", json!(0));
|
||||
delete_tab(app.ui.dialog.as_mut().unwrap());
|
||||
let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(a["tabs"].as_array().unwrap().iter().map(|s| s["position"].clone()).collect::<Vec<_>>(), [json!(72.123456), json!(144.0)]);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(def(&app, "Body")["tabs"].as_array().map(Vec::len), Some(2));
|
||||
|
||||
// Clear All Tabs leaves an empty list set, which Reset Panel removes; a new stop on a style
|
||||
// without stops goes at half an inch.
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
clear_tabs(app.ui.dialog.as_mut().unwrap());
|
||||
assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"], json!([]));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(def(&app, "Body")["tabs"], json!([]), "an empty list is stored");
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
set(&mut app, "section", json!("tabs"));
|
||||
app.run("ui.paraStyleOptions.resetPanel", json!({})).unwrap();
|
||||
let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!(!a.contains_key("tabs") && a["left_indent"] == json!(3.0), "{a:?}");
|
||||
add_tab(app.ui.dialog.as_mut().unwrap());
|
||||
assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"], json!([{"position": 36.0, "align": "left", "alignOn": "."}]));
|
||||
}
|
||||
|
||||
/// Of stops typed at one position, the one edited last is kept; with none edited, the first in the
|
||||
/// list is.
|
||||
#[test]
|
||||
fn a_repeated_tab_position_keeps_the_stop_edited_last() {
|
||||
let mut app = app();
|
||||
app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap();
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
let rows = json!([{"position": "36", "align": "right"}, {"position": "0.5 in", "align": "center"}, {"position": "10", "align": "left"}]);
|
||||
set(&mut app, "tabs", rows);
|
||||
let kept = |app: &VectorcraftApp| edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"].clone();
|
||||
assert_eq!(kept(&app), json!([{"position": 10.0, "align": "left", "alignOn": "."}, {"position": 36.0, "align": "right", "alignOn": "."}]));
|
||||
set(&mut app, "__tabEdited", json!(1));
|
||||
assert_eq!(kept(&app)[1]["align"], json!("center"));
|
||||
}
|
||||
|
||||
/// The list of stops scrolls, so a style with 256 stops keeps the Tabs section short enough for the
|
||||
/// dialog's buttons to stay on screen.
|
||||
#[test]
|
||||
fn a_long_tab_list_scrolls() {
|
||||
let mut app = app();
|
||||
let stops: Vec<Value> = (0..256).map(|i| json!({"position": 36.0 * f64::from(i + 1), "align": "left"})).collect();
|
||||
app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap();
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut height = f32::INFINITY;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let top = ui.cursor().top();
|
||||
tabs_body(ui, d);
|
||||
height = ui.cursor().top() - top;
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(height < 400.0, "the Tabs section is {height} points tall");
|
||||
}
|
||||
|
||||
/// The stop Add puts at the end of a long list is scrolled into view: its row in the list is drawn
|
||||
/// inside the list's visible area.
|
||||
#[test]
|
||||
fn an_added_tab_stop_is_scrolled_into_view() {
|
||||
let mut app = app();
|
||||
let stops: Vec<Value> = (0..100).map(|i| json!({"position": 36.0 * f64::from(i + 1), "align": "left"})).collect();
|
||||
app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap();
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let draw = |d: &mut Dialog| {
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| tabs_body(ui, d));
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().any(|c| match &c.shape {
|
||||
egui::Shape::Text(t) => t.galley.text().starts_with("3636 pt ") && c.clip_rect.intersects(t.visual_bounding_rect()),
|
||||
_ => false,
|
||||
})
|
||||
};
|
||||
draw(d);
|
||||
add_tab(d);
|
||||
let shown: Vec<bool> = (0..3).map(|_| draw(d)).collect();
|
||||
assert_eq!(shown.last(), Some(&true), "{shown:?}");
|
||||
}
|
||||
|
||||
/// Add does not put a stop where one already is: with a stop at 100000 pt, the largest position,
|
||||
/// it adds nothing and the Add button is disabled, so the existing stop keeps its settings.
|
||||
#[test]
|
||||
fn add_leaves_a_stop_at_the_largest_position_alone() {
|
||||
let mut app = app();
|
||||
let stops = json!([{"position": 100000.0, "align": "right", "leader": "-"}]);
|
||||
app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap();
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
let before = tab_rows(&app);
|
||||
add_tab(app.ui.dialog.as_mut().unwrap());
|
||||
assert_eq!(tab_rows(&app), before, "no stop is added");
|
||||
assert!(new_tab_position(&before, Unit::Points).is_none(), "Add is disabled");
|
||||
assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(), def(&app, "Body"), "the stop is kept");
|
||||
}
|
||||
|
||||
/// A tab position out of range or not a distance, an unknown alignment, a leader of more than eight
|
||||
/// characters, an Align On entry that is not one character, more than 256 stops, a `tabs` value that
|
||||
/// is not a list and a stop that is not an object are errors that keep the dialog open and the style
|
||||
/// as it was. Vertical type names the position Y.
|
||||
#[test]
|
||||
fn invalid_tab_stops_keep_the_dialog_open() {
|
||||
let mut app = app();
|
||||
app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap();
|
||||
let stop = |key: &str, value: Value| {
|
||||
let mut stop = json!({"position": "36", "align": "decimal", "leader": "", "alignOn": "."});
|
||||
stop[key] = value;
|
||||
json!([stop])
|
||||
};
|
||||
let many = Value::from((0..257).map(|i| json!({"position": format!("{i}"), "align": "left"})).collect::<Vec<_>>());
|
||||
for (tabs, why) in [
|
||||
(stop("position", json!("-1")), "X must be a number from 0 pt to 100000 pt"),
|
||||
(stop("position", json!("100001 pt")), "X must be a number from 0 pt to 100000 pt"),
|
||||
(stop("position", json!("far")), "X must be a number"),
|
||||
(stop("align", json!("diagonal")), "unknown align `diagonal`"),
|
||||
(stop("leader", json!("123456789")), "Leader must be at most 8 characters"),
|
||||
(stop("alignOn", json!("")), "Align On must be one character"),
|
||||
(stop("alignOn", json!(",.")), "Align On must be one character"),
|
||||
(many, "at most 256 tab stops"),
|
||||
(json!("36"), "`tabs` must be an array"),
|
||||
(json!({"position": "36"}), "`tabs` must be an array"),
|
||||
(json!([5]), "X must be a number"),
|
||||
] {
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
set(&mut app, "tabs", tabs.clone());
|
||||
assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap_err(), why, "{tabs}");
|
||||
assert!(super::super::confirm(&mut app).is_err());
|
||||
assert!(app.ui.dialog.is_some());
|
||||
super::super::cancel(&mut app);
|
||||
assert_eq!(def(&app, "Body"), Map::new());
|
||||
}
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
set(&mut app, "__vertical", json!(true));
|
||||
set(&mut app, "tabs", stop("position", json!("far")));
|
||||
assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap_err(), "Y must be a number");
|
||||
// Stops a style read from a file holds out of range stay while another stop is added.
|
||||
super::super::cancel(&mut app);
|
||||
let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc);
|
||||
let held = json!([{"position": 200000.0, "align": "left", "leader": "123456789", "alignOn": "."}]);
|
||||
doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), held.clone());
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
add_tab(app.ui.dialog.as_mut().unwrap());
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(def(&app, "Body")["tabs"][1], held[0]);
|
||||
}
|
||||
|
||||
/// Preview shows a style's new stops on the paragraphs that use it, and Cancel puts the document
|
||||
/// and its history back. OK sets them in one undo step, a paragraph whose stops the Tabs panel set
|
||||
/// keeps its own, and Undo and Redo restore both. Saving and opening the document keeps the stops.
|
||||
#[test]
|
||||
fn tabs_preview_cancel_and_ok_update_the_paragraphs_in_one_undo_step() {
|
||||
let mut app = app();
|
||||
let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": [{"position": 36.0}]}})).unwrap();
|
||||
app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap();
|
||||
app.run("text.tabs.set", json!({"ids": [id], "start": 4, "end": 4, "stops": [{"position": 50.0}]})).unwrap();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
let new_stops = json!([{"position": "20", "align": "right", "leader": "."}, {"position": "80", "align": "decimal", "alignOn": ","}]);
|
||||
let positions = |app: &VectorcraftApp| paras(app, id).iter().map(|p| p.tabs.iter().map(|t| t.position).collect::<Vec<_>>()).collect::<Vec<_>>();
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
set(&mut app, "section", json!("tabs"));
|
||||
set(&mut app, "tabs", new_stops.clone());
|
||||
frame(&mut app);
|
||||
assert_eq!(positions(&app), [vec![20.0, 80.0], vec![50.0]], "Preview shows the change");
|
||||
super::super::cancel(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
|
||||
|
||||
app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap();
|
||||
set(&mut app, "tabs", new_stops);
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(positions(&app), [vec![20.0, 80.0], vec![50.0]]);
|
||||
let p = paras(&app, id);
|
||||
assert_eq!((p[0].tabs[0].leader.as_str(), p[0].tabs[1].align_on), (".", ','));
|
||||
let changed = app.session.doc().unwrap().doc.clone();
|
||||
let saved = vectorcraft_format::load(&vectorcraft_format::save(&changed, false)).unwrap();
|
||||
let def_of = |d: &vectorcraft_doc::Document| d.para_styles.iter().find(|s| s.name == "Body").map(|s| s.attrs.clone());
|
||||
assert_eq!(def_of(&saved), def_of(&changed));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, changed);
|
||||
}
|
||||
|
||||
/// The section list, Reset Panel and both buttons are drawn in every section, and the Hyphenation
|
||||
/// options are drawn whether or not Hyphenation is on.
|
||||
#[test]
|
||||
@@ -269,6 +509,7 @@ fn every_section_draws_its_fields() {
|
||||
for (section, label) in [
|
||||
("general", "Style Name:"),
|
||||
("basic", "Space After Paragraph"),
|
||||
("tabs", "Clear All Tabs"),
|
||||
("composition", "Roman Hanging Punctuation"),
|
||||
("hyphenation", "Hyphenation Zone:"),
|
||||
("justification", "Glyph Scaling:"),
|
||||
@@ -282,7 +523,7 @@ fn every_section_draws_its_fields() {
|
||||
out.textures_delta.clear();
|
||||
texts = crate::tests_labels::shapes_text(&out);
|
||||
}
|
||||
for shown in [label, "General", "Basic Paragraph Formats", "Justification", "Reset Panel", "OK", "Cancel"] {
|
||||
for shown in [label, "General", "Basic Paragraph Formats", "Tabs", "Justification", "Reset Panel", "OK", "Cancel"] {
|
||||
assert!(texts.contains(shown), "{shown} is drawn in {section}: {texts}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +133,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
}
|
||||
|
||||
/// `file.saveForWeb` with settings: write the files (`path`, else a picked file: the page with HTML
|
||||
/// output, else the image); the web downloads them. The status bar shows the export's first
|
||||
/// warning.
|
||||
/// output, else the image); the web downloads them. The status bar shows the export's warnings as
|
||||
/// [`io::report_saved`] shows a save's notes.
|
||||
pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> {
|
||||
// Bad settings never open a save panel.
|
||||
let s = app.session.web_settings(&p)?;
|
||||
@@ -170,7 +170,7 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, Stri
|
||||
}
|
||||
|
||||
/// Whether an export's reply `r` has warnings (an image that couldn't be decoded):
|
||||
/// [`io::report_saved`] puts the first in the status bar as a save's note.
|
||||
/// [`io::report_saved`] shows them in the status bar as a save's notes.
|
||||
fn has_warnings(r: &Value) -> bool {
|
||||
r["warnings"].as_array().is_some_and(|w| !w.is_empty())
|
||||
}
|
||||
@@ -183,8 +183,8 @@ pub(super) fn preview_name(stem: &str) -> &str {
|
||||
}
|
||||
|
||||
/// `file.saveForWeb.browser`: the HTML page and its images in a new temporary folder, opened in the
|
||||
/// default browser (desktop). When the export has warnings, the status bar shows the first in
|
||||
/// place of "Opened …".
|
||||
/// default browser (desktop). When the export has warnings, the status bar shows them as
|
||||
/// [`io::report_saved`] shows a save's notes.
|
||||
pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if io::is_web(app) {
|
||||
return Err("Preview in Browser needs the desktop app (the web downloads files instead)".into());
|
||||
|
||||
@@ -508,6 +508,35 @@ fn the_status_bar_says_when_an_image_could_not_be_decoded() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// An export into a folder with several notes, from Export for Screens and from the Asset Export
|
||||
/// panel: the status bar names the first, counts them, and keeps every one of them for its hover.
|
||||
#[test]
|
||||
fn an_export_into_a_folder_keeps_every_note_for_the_hover() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let dir = temp_folder("notes");
|
||||
let folder = dir.to_string_lossy().replace('\\', "/");
|
||||
let rows = json!([{"format": "png", "scale": "1x", "suffix": ""}]);
|
||||
let (mut app, _) = app(1, false);
|
||||
vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({"at": [10, 10]}));
|
||||
app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
let r = app.run("file.exportForScreens", json!({"folder": folder, "formats": rows})).unwrap();
|
||||
let notes = crate::io::notes_of(&r);
|
||||
assert_eq!(notes.len(), 2, "{r}");
|
||||
assert!(app.ui.status.starts_with(&format!("Saved {folder} with 2 notes: {}", notes[0])), "{}", app.ui.status);
|
||||
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
|
||||
app.ui.status_notes = None;
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("assets.add", json!({"multiple": false})).unwrap();
|
||||
let r = crate::io::export_files(&mut app, "assets.export", json!({"folder": folder, "formats": rows})).unwrap();
|
||||
let notes = crate::io::notes_of(&r);
|
||||
assert_eq!(notes.len(), 2, "{r}");
|
||||
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)), "assets.export");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_export_reopens_on_its_rows() {
|
||||
let (mut app, _) = app(1, true);
|
||||
|
||||
@@ -1399,6 +1399,14 @@ font-technology Variable Променливи
|
||||
Hair Space Тънък интервал
|
||||
Hand Tool Инструмент „Ръка“
|
||||
Hand-Drawn Ръчно рисувани
|
||||
Painted and Sketched Рисувани и скицирани
|
||||
Washed Blue Размито синьо
|
||||
Bristle Ochre Охра с четка
|
||||
Pastel Heather Пастелен пирен
|
||||
Charcoal Shade Сянка с въглен
|
||||
Ink Contour Мастилен контур
|
||||
Wet Ink Мокро мастило
|
||||
Fine Pen Фино перо
|
||||
Handles Дръжки
|
||||
Hard Light Твърда светлина
|
||||
Hard Mix Твърдо смесване
|
||||
@@ -5844,6 +5852,7 @@ theme Appearance Външен вид
|
||||
Ruby Руби
|
||||
Geometric Guides Геометрични водачи
|
||||
Geometric Patterns Геометрични мостри
|
||||
Grit Textures Зърнести текстури
|
||||
Ease In Плавно начало
|
||||
Ease Out Плавен край
|
||||
Ease In Out Плавно начало и край
|
||||
@@ -6387,6 +6396,8 @@ breakText Glyphs Глифи
|
||||
Delete the selected brushes? Да се изтрият избраните четки?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Щрихи използват една или повече от тези четки. Да се премахнат ли четките от тези щрихи и да се изтрият четките?
|
||||
Remove Strokes Премахване на щрихи
|
||||
Place Brush Art Поставяне на графика на четка
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` няма графика (имат я само разпръснатите и артистичните четки и четките с мостра)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Инструменти за подреждане
|
||||
Arrange in Grid… Подреждане в мрежа…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -812,6 +812,14 @@
|
||||
Damp Paper Vlažni papir
|
||||
Outlines and Rules Obrisi i linije
|
||||
Hand-Drawn Ručno crtano
|
||||
Painted and Sketched Slikano i skicirano
|
||||
Washed Blue Isprano plavo
|
||||
Bristle Ochre Oker kistom
|
||||
Pastel Heather Pastelni vrijesak
|
||||
Charcoal Shade Sjena ugljenom
|
||||
Ink Contour Kontura tušem
|
||||
Wet Ink Mokri tuš
|
||||
Fine Pen Fino pero
|
||||
Gradient Finishes Gradijentne završnice
|
||||
Shape Effects Efekti oblika
|
||||
Blends and Transparency Stapanja i prozirnost
|
||||
@@ -5861,6 +5869,7 @@ mark-style Japanese Japanski
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometrijske vodilice
|
||||
Geometric Patterns Geometrijski uzorci
|
||||
Grit Textures Zrnaste teksture
|
||||
Ease In Ublaži ulaz
|
||||
Ease Out Ublaži izlaz
|
||||
Ease In Out Ublaži ulaz i izlaz
|
||||
@@ -6438,6 +6447,8 @@ shape Open Otvoren
|
||||
Delete the selected brushes? Izbrisati odabrane četkice?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Potezi koriste jednu ili više ovih četkica. Ukloniti četkice s tih poteza i izbrisati četkice?
|
||||
Remove Strokes Ukloni poteze
|
||||
Place Brush Art Postavi crtež četkice
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nema crtež (imaju ga samo raspršene i umjetničke četkice i četkice uzorka)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Alati za raspoređivanje
|
||||
Arrange in Grid… Rasporedi u mrežu…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1882,6 +1882,7 @@ digits Malayalam Malaiàlam
|
||||
Gridline every Línia de quadrícula cada
|
||||
Gridline every: Línia de quadrícula cada:
|
||||
Grids In Back Quadrícules al darrere
|
||||
Grit Textures Textures granulades
|
||||
Group Agrupa
|
||||
Group Selection Selecció de grup
|
||||
Group Selection Tool Eina de selecció de grup
|
||||
@@ -1906,6 +1907,14 @@ digits Malayalam Malaiàlam
|
||||
Hand Mà
|
||||
Hand Tool Eina de mà
|
||||
Hand-Drawn Dibuixat a mà
|
||||
Painted and Sketched Pintat i esbossat
|
||||
Washed Blue Blau rentat
|
||||
Bristle Ochre Ocre de cerres
|
||||
Pastel Heather Bruc pastel
|
||||
Charcoal Shade Ombra de carbó
|
||||
Ink Contour Contorn de tinta
|
||||
Wet Ink Tinta humida
|
||||
Fine Pen Ploma fina
|
||||
Handles Nanses
|
||||
Hard Dur
|
||||
Hard Light Llum dura
|
||||
@@ -5081,6 +5090,7 @@ digits Malayalam Malaiàlam
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` ha de ser 1, 3 o 5
|
||||
@msg `select` must be true or false `select` ha de ser true o false
|
||||
@msg `stops` must be an array `stops` ha de ser una matriu
|
||||
@msg `{key}` must be an array `{key}` ha de ser una matriu
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` ha de ser un objecte d’opcions SVG (vegeu document.formats)
|
||||
@msg `to` must be "corner" or "smooth" `to` ha de ser "corner" o "smooth"
|
||||
@msg `{_1}` has no brushes to apply `{_1}` no té pinzells per aplicar
|
||||
@@ -6482,6 +6492,10 @@ anchor Points: Punts:
|
||||
@msg features lists at most {max} tags features admet com a màxim {max} etiquetes
|
||||
@msg unknown OpenType feature `{t}` funció OpenType desconeguda `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} ha de ser un nombre superior a 0 i com a màxim {max}
|
||||
@msg {label} must be at most {max} characters {label} ha de tenir com a màxim {max} caràcters
|
||||
@msg {label} must be one character {label} ha de ser un sol caràcter
|
||||
@msg at most {max} tab stops com a màxim {max} tabulacions
|
||||
@msg unknown align `{other}` alineació desconeguda `{other}`
|
||||
@msg no character style `{name}` No s'ha trobat l'estil de caràcter `{name}`
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6652,6 +6666,8 @@ breakText Glyphs Glifs
|
||||
Delete the selected brushes? Voleu suprimir els pinzells seleccionats?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Hi ha traços que fan servir un o més d’aquests pinzells. Voleu treure els pinzells d’aquests traços i suprimir els pinzells?
|
||||
Remove Strokes Elimina els traços
|
||||
Place Brush Art Col·loca l’art del pinzell
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no té art (només en tenen els pinzells de dispersió, artístics i de motiu)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Eines d’organització
|
||||
Arrange in Grid… Organitza en quadrícula…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -821,6 +821,14 @@
|
||||
Damp Paper Vlhký papír
|
||||
Outlines and Rules Obrysy a linky
|
||||
Hand-Drawn Ručně kreslené
|
||||
Painted and Sketched Malované a skicované
|
||||
Washed Blue Rozmytá modrá
|
||||
Bristle Ochre Okr štětcem
|
||||
Pastel Heather Pastelový vřes
|
||||
Charcoal Shade Stín uhlem
|
||||
Ink Contour Obrys tuší
|
||||
Wet Ink Mokrá tuš
|
||||
Fine Pen Jemné pero
|
||||
Gradient Finishes Přechodové povrchy
|
||||
Shape Effects Efekty tvarů
|
||||
Blends and Transparency Prolnutí a průhlednost
|
||||
@@ -4560,6 +4568,7 @@ imageTrace Outline Obrys
|
||||
@msg `globalAdjust` must be an object `globalAdjust` musí být objekt
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` musí být 1, 3 nebo 5
|
||||
@msg `stops` must be an array `stops` musí být pole
|
||||
@msg `{key}` must be an array `{key}` musí být pole
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` musí být objekt s možnostmi SVG (viz document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` není příkaz pro uložení
|
||||
@msg `{area}` is no recovery area name `{area}` není název oblasti obnovení
|
||||
@@ -6025,6 +6034,7 @@ mark-style Japanese Japonské
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometrická vodítka
|
||||
Geometric Patterns Geometrické vzorky
|
||||
Grit Textures Zrnité textury
|
||||
Ease In Pozvolný začátek
|
||||
Ease Out Pozvolný konec
|
||||
Ease In Out Pozvolný začátek a konec
|
||||
@@ -6510,6 +6520,10 @@ anchor Points: Body:
|
||||
@msg features lists at most {max} tags features obsahuje nejvýše {max} značek
|
||||
@msg unknown OpenType feature `{t}` neznámá funkce OpenType `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} musí být číslo větší než 0 a nejvýše {max}
|
||||
@msg {label} must be at most {max} characters {label} smí mít nejvýše {max} znaků
|
||||
@msg {label} must be one character {label} musí být jeden znak
|
||||
@msg at most {max} tab stops nejvýše {max} zarážek tabulátoru
|
||||
@msg unknown align `{other}` neznámé zarovnání `{other}`
|
||||
@msg no character style `{name}` styl znaků `{name}` neexistuje
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6671,6 +6685,8 @@ breakText Glyphs Glyfy
|
||||
Delete the selected brushes? Odstranit vybrané štětce?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Tahy používají jeden nebo více z těchto štětců. Odebrat štětce z těchto tahů a odstranit štětce?
|
||||
Remove Strokes Odebrat tahy
|
||||
Place Brush Art Umístit kresbu štětce
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nemá kresbu (mají ji jen rozptylové, obrazové a vzorkové štětce)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Nástroje uspořádání
|
||||
Arrange in Grid… Uspořádat do mřížky…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -758,6 +758,14 @@
|
||||
Damp Paper Fugtigt papir
|
||||
Outlines and Rules Konturer og linjer
|
||||
Hand-Drawn Håndtegnet
|
||||
Painted and Sketched Malet og skitseret
|
||||
Washed Blue Udvasket blå
|
||||
Bristle Ochre Børsteokker
|
||||
Pastel Heather Pastellyng
|
||||
Charcoal Shade Kulskygge
|
||||
Ink Contour Blækkontur
|
||||
Wet Ink Vådt blæk
|
||||
Fine Pen Fin pen
|
||||
Gradient Finishes Gradientfinish
|
||||
Shape Effects Formeffekter
|
||||
Blends and Transparency Blandinger og gennemsigtighed
|
||||
@@ -5859,6 +5867,7 @@ swatch Bright Violet Kraftig violet
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometriske hjælpelinjer
|
||||
Geometric Patterns Geometriske mønstre
|
||||
Grit Textures Kornede teksturer
|
||||
Ease In Langsom start
|
||||
Ease Out Langsom slutning
|
||||
Ease In Out Langsom start og slutning
|
||||
@@ -6401,6 +6410,8 @@ breakText Glyphs Glyffer
|
||||
Delete the selected brushes? Slet de valgte pensler?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Streger bruger en eller flere af disse pensler. Fjern penslerne fra stregerne, og slet penslerne?
|
||||
Remove Strokes Fjern streger
|
||||
Place Brush Art Placér penselgrafik
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (det har kun sprednings-, grafik- og mønsterpensler)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Arrangeringsværktøjer
|
||||
Arrange in Grid… Arrangér i gitter…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1444,6 +1444,14 @@ file-filter Templates Vorlagen
|
||||
Hair Space Haarspatium
|
||||
Hand Tool Hand-Werkzeug
|
||||
Hand-Drawn Handgezeichnet
|
||||
Painted and Sketched Gemalt und skizziert
|
||||
Washed Blue Verwaschenes Blau
|
||||
Bristle Ochre Borstenocker
|
||||
Pastel Heather Pastellheide
|
||||
Charcoal Shade Kohleschatten
|
||||
Ink Contour Tuschekontur
|
||||
Wet Ink Nasse Tusche
|
||||
Fine Pen Feine Feder
|
||||
Handles Griffe
|
||||
Hard Light Hartes Licht
|
||||
Hard Mix Hart mischen
|
||||
@@ -4175,6 +4183,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg `globalAdjust` must be an object `globalAdjust` muss ein Objekt sein
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` muss 1, 3 oder 5 sein
|
||||
@msg `stops` must be an array `stops` muss ein Array sein
|
||||
@msg `{key}` must be an array `{key}` muss ein Array sein
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` muss ein Objekt mit SVG-Optionen sein (siehe document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` ist kein Speicherbefehl
|
||||
@msg `{area}` is no recovery area name `{area}` ist kein Name eines Wiederherstellungsbereichs
|
||||
@@ -5614,6 +5623,7 @@ effect Soft Weich
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometrische Hilfslinien
|
||||
Geometric Patterns Geometrische Muster
|
||||
Grit Textures Körnige Texturen
|
||||
Ease In Langsam starten
|
||||
Ease Out Langsam enden
|
||||
Ease In Out Langsam starten und enden
|
||||
@@ -6104,6 +6114,10 @@ anchor Points: Punkte:
|
||||
@msg features lists at most {max} tags features enthält höchstens {max} Tags
|
||||
@msg unknown OpenType feature `{t}` unbekannte OpenType-Funktion `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} muss eine Zahl über 0 und höchstens {max} sein
|
||||
@msg {label} must be at most {max} characters {label} darf höchstens {max} Zeichen lang sein
|
||||
@msg {label} must be one character {label} muss genau ein Zeichen sein
|
||||
@msg at most {max} tab stops höchstens {max} Tabstopps
|
||||
@msg unknown align `{other}` unbekannte Ausrichtung `{other}`
|
||||
@msg no character style `{name}` Zeichenformat `{name}` nicht gefunden
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6266,6 +6280,8 @@ breakText Glyphs Glyphen
|
||||
Delete the selected brushes? Ausgewählte Pinsel löschen?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturen verwenden einen oder mehrere dieser Pinsel. Pinsel von diesen Konturen entfernen und die Pinsel löschen?
|
||||
Remove Strokes Konturen entfernen
|
||||
Place Brush Art Pinselgrafik platzieren
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` hat keine Grafik (nur Spezial-, Bild- und Musterpinsel haben eine)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Anordnungswerkzeuge
|
||||
Arrange in Grid… In Raster anordnen…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1374,6 +1374,14 @@ font-technology Variable Μεταβλητές
|
||||
Hair Space Πολύ λεπτό διάστημα
|
||||
Hand Tool Χέρι (Hand Tool)
|
||||
Hand-Drawn Σχεδιασμένο στο χέρι
|
||||
Painted and Sketched Ζωγραφισμένο και σκιτσαρισμένο
|
||||
Washed Blue Ξεπλυμένο μπλε
|
||||
Bristle Ochre Ώχρα με πινέλο
|
||||
Pastel Heather Παστέλ ρείκι
|
||||
Charcoal Shade Σκιά με κάρβουνο
|
||||
Ink Contour Περίγραμμα με μελάνι
|
||||
Wet Ink Υγρό μελάνι
|
||||
Fine Pen Λεπτή πένα
|
||||
Handles Λαβές
|
||||
Hard Light Έντονο φως
|
||||
Hard Mix Έντονη ανάμειξη
|
||||
@@ -2374,6 +2382,7 @@ axis Both Και τα δύο
|
||||
@msg `globalAdjust` must be an object Το `globalAdjust` πρέπει να είναι αντικείμενο
|
||||
@msg `sampleSize` must be 1, 3 or 5 Το `sampleSize` πρέπει να είναι 1, 3 ή 5
|
||||
@msg `stops` must be an array Το `stops` πρέπει να είναι πίνακας
|
||||
@msg `{key}` must be an array Το `{key}` πρέπει να είναι πίνακας
|
||||
@msg `svg` must be an object of SVG options (see document.formats) Το `svg` πρέπει να είναι αντικείμενο επιλογών SVG (δείτε document.formats)
|
||||
@msg `{_1}` is no save command Το `{_1}` δεν είναι εντολή αποθήκευσης
|
||||
@msg `{area}` is no recovery area name Το `{area}` δεν είναι όνομα περιοχής ανάκτησης
|
||||
@@ -5976,6 +5985,7 @@ effect Points Σημεία
|
||||
Ruby Ruby
|
||||
Geometric Guides Γεωμετρικοί οδηγοί
|
||||
Geometric Patterns Γεωμετρικά μοτίβα
|
||||
Grit Textures Κοκκώδεις υφές
|
||||
Ease In Ομαλή αρχή
|
||||
Ease Out Ομαλό τέλος
|
||||
Ease In Out Ομαλή αρχή και τέλος
|
||||
@@ -6461,6 +6471,10 @@ anchor Points: Σημεία:
|
||||
@msg features lists at most {max} tags Το features περιέχει έως {max} ετικέτες
|
||||
@msg unknown OpenType feature `{t}` άγνωστο χαρακτηριστικό OpenType `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} Το {label} πρέπει να είναι αριθμός μεγαλύτερος από 0 και έως {max}
|
||||
@msg {label} must be at most {max} characters Το {label} πρέπει να έχει έως {max} χαρακτήρες
|
||||
@msg {label} must be one character Το {label} πρέπει να είναι ένας χαρακτήρας
|
||||
@msg at most {max} tab stops έως {max} στάσεις στηλοθέτη
|
||||
@msg unknown align `{other}` άγνωστη στοίχιση `{other}`
|
||||
@msg no character style `{name}` Δεν βρέθηκε το στυλ χαρακτήρων `{name}`
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6623,6 +6637,8 @@ breakText Glyphs Γλύφοι
|
||||
Delete the selected brushes? Να διαγραφούν τα επιλεγμένα πινέλα;
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Περιγράμματα χρησιμοποιούν ένα ή περισσότερα από αυτά τα πινέλα. Να αφαιρεθούν τα πινέλα από αυτά τα περιγράμματα και να διαγραφούν τα πινέλα;
|
||||
Remove Strokes Αφαίρεση περιγραμμάτων
|
||||
Place Brush Art Τοποθέτηση σχεδίου πινέλου
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` δεν έχει σχέδιο (έχουν μόνο τα πινέλα διασποράς, σχεδίου και μοτίβου)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Εργαλεία διάταξης
|
||||
Arrange in Grid… Διάταξη σε πλέγμα…
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1453,6 +1453,14 @@ font-technology Variable Variables
|
||||
Hair Space Espacio fino
|
||||
Hand Tool Herramienta Mano
|
||||
Hand-Drawn Dibujado a mano
|
||||
Painted and Sketched Pintado y bocetado
|
||||
Washed Blue Azul lavado
|
||||
Bristle Ochre Ocre de cerdas
|
||||
Pastel Heather Brezo pastel
|
||||
Charcoal Shade Sombra al carboncillo
|
||||
Ink Contour Contorno de tinta
|
||||
Wet Ink Tinta húmeda
|
||||
Fine Pen Plumilla fina
|
||||
Handles Manejadores
|
||||
Hard Light Luz fuerte
|
||||
Hard Mix Mezcla definida
|
||||
@@ -4251,6 +4259,7 @@ axis Both Ambos
|
||||
@msg `globalAdjust` must be an object `globalAdjust` debe ser un objeto
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` debe ser 1, 3 o 5
|
||||
@msg `stops` must be an array `stops` debe ser una lista
|
||||
@msg `{key}` must be an array `{key}` debe ser una lista
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` debe ser un objeto con opciones de SVG (consulta document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` no es un comando de guardado
|
||||
@msg `{area}` is no recovery area name `{area}` no es el nombre de un área de recuperación
|
||||
@@ -5967,6 +5976,7 @@ mark-style Japanese Japonesas
|
||||
Ruby Ruby
|
||||
Geometric Guides Guías geométricas
|
||||
Geometric Patterns Motivos geométricos
|
||||
Grit Textures Texturas granuladas
|
||||
Ease In Entrada suave
|
||||
Ease Out Salida suave
|
||||
Ease In Out Entrada y salida suaves
|
||||
@@ -6453,6 +6463,10 @@ anchor Points: Puntos:
|
||||
@msg features lists at most {max} tags features admite como máximo {max} etiquetas
|
||||
@msg unknown OpenType feature `{t}` función OpenType desconocida `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} debe ser un número mayor que 0 y como máximo {max}
|
||||
@msg {label} must be at most {max} characters {label} debe tener como máximo {max} caracteres
|
||||
@msg {label} must be one character {label} debe ser un solo carácter
|
||||
@msg at most {max} tab stops como máximo {max} tabulaciones
|
||||
@msg unknown align `{other}` alineación desconocida `{other}`
|
||||
@msg no character style `{name}` No existe el estilo de carácter `{name}`
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6614,6 +6628,8 @@ breakText Glyphs Glifos
|
||||
Delete the selected brushes? ¿Eliminar los pinceles seleccionados?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Algunos trazos usan uno o más de estos pinceles. ¿Quitar los pinceles de esos trazos y eliminar los pinceles?
|
||||
Remove Strokes Quitar trazos
|
||||
Place Brush Art Colocar ilustración del pincel
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no tiene ilustración (solo la tienen los pinceles de dispersión, de arte y de motivo)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Herramientas de organización
|
||||
Arrange in Grid… Organizar en cuadrícula…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -812,6 +812,14 @@
|
||||
Damp Paper Kostea paperi
|
||||
Outlines and Rules Ääriviivat ja viivat
|
||||
Hand-Drawn Käsin piirretyt
|
||||
Painted and Sketched Maalatut ja luonnostellut
|
||||
Washed Blue Haalea sininen
|
||||
Bristle Ochre Harjasokra
|
||||
Pastel Heather Pastellikanerva
|
||||
Charcoal Shade Hiilivarjo
|
||||
Ink Contour Mustekontuuri
|
||||
Wet Ink Märkä muste
|
||||
Fine Pen Hieno kynä
|
||||
Gradient Finishes Liukuväriviimeistelyt
|
||||
Shape Effects Muototehosteet
|
||||
Blends and Transparency Sulautukset ja läpinäkyvyys
|
||||
@@ -4474,6 +4482,7 @@ imageTrace Outline Ääriviiva
|
||||
@msg `globalAdjust` must be an object `globalAdjust` on oltava objekti
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` on oltava 1, 3 tai 5
|
||||
@msg `stops` must be an array `stops` on oltava taulukko
|
||||
@msg `{key}` must be an array `{key}` on oltava taulukko
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` on oltava SVG-asetusten objekti (ks. document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` ei ole tallennuskomento
|
||||
@msg `{area}` is no recovery area name `{area}` ei ole palautusalueen nimi
|
||||
@@ -6055,6 +6064,7 @@ swatch Bright Violet Kirkas violetti
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometriset apuviivat
|
||||
Geometric Patterns Geometriset kuviot
|
||||
Grit Textures Rakeiset tekstuurit
|
||||
Ease In Hidas alku
|
||||
Ease Out Hidas loppu
|
||||
Ease In Out Hidas alku ja loppu
|
||||
@@ -6540,6 +6550,10 @@ anchor Points: Pisteet:
|
||||
@msg features lists at most {max} tags features sisältää enintään {max} tunnistetta
|
||||
@msg unknown OpenType feature `{t}` tuntematon OpenType-ominaisuus `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} on oltava luku, joka on yli 0 ja enintään {max}
|
||||
@msg {label} must be at most {max} characters {label} saa olla enintään {max} merkkiä
|
||||
@msg {label} must be one character {label} on oltava yksi merkki
|
||||
@msg at most {max} tab stops enintään {max} sarkainkohtaa
|
||||
@msg unknown align `{other}` tuntematon tasaus `{other}`
|
||||
@msg no character style `{name}` Merkkityyliä `{name}` ei löytynyt
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6701,6 +6715,8 @@ breakText Glyphs Merkit
|
||||
Delete the selected brushes? Poistetaanko valitut siveltimet?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Viivat käyttävät yhtä tai useampaa näistä siveltimistä. Irrotetaanko siveltimet näistä viivoista ja poistetaanko siveltimet?
|
||||
Remove Strokes Poista viivat
|
||||
Place Brush Art Sijoita siveltimen kuvitus
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ei sisällä kuvitusta (kuvitus on vain sirote-, taide- ja kuviosiveltimissä)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Järjestelytyökalut
|
||||
Arrange in Grid… Järjestä ruudukkoon…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1447,6 +1447,14 @@ font-technology Variable Variables
|
||||
Hair Space Espace ultrafine
|
||||
Hand Tool Outil Main
|
||||
Hand-Drawn Dessiné à la main
|
||||
Painted and Sketched Peint et esquissé
|
||||
Washed Blue Bleu lavé
|
||||
Bristle Ochre Ocre à la brosse
|
||||
Pastel Heather Bruyère pastel
|
||||
Charcoal Shade Ombre au fusain
|
||||
Ink Contour Contour encré
|
||||
Wet Ink Encre humide
|
||||
Fine Pen Plume fine
|
||||
Handles Poignées
|
||||
Hard Light Lumière crue
|
||||
Hard Mix Mélange maximal
|
||||
@@ -4240,6 +4248,7 @@ axis Both Les deux
|
||||
@msg `globalAdjust` must be an object `globalAdjust` doit être un objet
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` doit valoir 1, 3 ou 5
|
||||
@msg `stops` must be an array `stops` doit être un tableau
|
||||
@msg `{key}` must be an array `{key}` doit être un tableau
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` doit être un objet d’options SVG (voir document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` n’est pas une commande d’enregistrement
|
||||
@msg `{area}` is no recovery area name `{area}` n’est pas un nom de zone de récupération
|
||||
@@ -6020,6 +6029,7 @@ theme Appearance Apparence
|
||||
Ruby Ruby
|
||||
Geometric Guides Repères géométriques
|
||||
Geometric Patterns Motifs géométriques
|
||||
Grit Textures Textures granuleuses
|
||||
Ease In Accélération progressive
|
||||
Ease Out Décélération progressive
|
||||
Ease In Out Accélération et décélération
|
||||
@@ -6505,6 +6515,10 @@ anchor Points: Points :
|
||||
@msg features lists at most {max} tags features contient au plus {max} balises
|
||||
@msg unknown OpenType feature `{t}` fonction OpenType inconnue `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} doit être un nombre supérieur à 0 et au plus égal à {max}
|
||||
@msg {label} must be at most {max} characters {label} doit compter au plus {max} caractères
|
||||
@msg {label} must be one character {label} doit être un seul caractère
|
||||
@msg at most {max} tab stops au plus {max} taquets de tabulation
|
||||
@msg unknown align `{other}` alignement inconnu `{other}`
|
||||
@msg no character style `{name}` Style de caractère `{name}` introuvable
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6666,6 +6680,8 @@ breakText Glyphs Glyphes
|
||||
Delete the selected brushes? Supprimer les formes sélectionnées ?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Des contours utilisent une ou plusieurs de ces formes. Retirer les formes de ces contours et supprimer les formes ?
|
||||
Remove Strokes Supprimer les contours
|
||||
Place Brush Art Placer l’illustration de la forme
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` n’a pas d’illustration (seules les formes diffuses, artistiques et de motif en ont)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Outils de disposition
|
||||
Arrange in Grid… Disposer en grille…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2073,6 +2073,7 @@ antiAlias Sharp शार्प
|
||||
Gridline every ग्रिडलाइन हर
|
||||
Gridline every: ग्रिडलाइन हर:
|
||||
Grids In Back ग्रिड में वापस
|
||||
Grit Textures दानेदार टेक्सचर
|
||||
Group समूह
|
||||
Group Overlapping Objects समूह ओवरलैपिंग ऑब्जेक्ट
|
||||
Group Selection समूह सिलेक्शन
|
||||
@@ -2100,6 +2101,14 @@ antiAlias Sharp शार्प
|
||||
Hand हैंड
|
||||
Hand Tool हैंड टूल
|
||||
Hand-Drawn हाथ-ड्रन
|
||||
Painted and Sketched पेंट और स्केच
|
||||
Washed Blue धुला नीला
|
||||
Bristle Ochre ब्रश गेरू
|
||||
Pastel Heather पेस्टल बैंगनी
|
||||
Charcoal Shade चारकोल छाया
|
||||
Ink Contour स्याही रूपरेखा
|
||||
Wet Ink गीली स्याही
|
||||
Fine Pen बारीक पेन
|
||||
Handles हैंडल्स
|
||||
Hard कठोर
|
||||
Hard Light हार्ड लाइट
|
||||
@@ -3770,6 +3779,8 @@ antiAlias Sharp शार्प
|
||||
Remove Selected Assets सिलेक्टेड परिसंपत्तियों को हटा दें
|
||||
Remove Selected Entry सिलेक्टेड प्रविष्टि निकालें
|
||||
Remove Strokes स्ट्रोक हटाएँ
|
||||
Place Brush Art ब्रश आर्टवर्क रखें
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` में कोई आर्टवर्क नहीं है (आर्टवर्क केवल स्कैटर, आर्ट और पैटर्न ब्रश में होता है)
|
||||
Remove the shortcut शॉर्टकट निकालें
|
||||
Remove Threading थ्रेडिंग निकालें
|
||||
Remove White Overprint व्हाइट ओवरप्रिंट निकालें
|
||||
@@ -5545,6 +5556,9 @@ antiAlias Sharp शार्प
|
||||
@msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें।
|
||||
@msg {label} must be a number {label} एक नंबर होना चाहिए
|
||||
@msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए
|
||||
@msg {label} must be at most {max} characters {label} में अधिकतम {max} वर्ण होने चाहिए
|
||||
@msg {label} must be one character {label} एक वर्ण होना चाहिए
|
||||
@msg at most {max} tab stops अधिकतम {max} टैब स्टॉप
|
||||
@msg {label} must be a number at most {max} {label} सबसे {max} पर एक नंबर होना चाहिए
|
||||
@msg {label} must be a number from -1296 to 1296 {label} को -1296 से 1296 के बीच की संख्या होना चाहिए
|
||||
@msg {label} must be a number from {min} to {max} {label} को {min} से {max} तक एक नंबर होना चाहिए
|
||||
@@ -5900,6 +5914,7 @@ antiAlias Sharp शार्प
|
||||
@msg `status` must be linked, embedded, missing or modified, or a list of them `status` को लिंक, एम्बेडेड, लापता या संशोधित किया जाना चाहिए, या उनमें से एक सूची
|
||||
@msg `stop` must be a stop index `stop` एक स्टॉप इंडेक्स होना चाहिए
|
||||
@msg `stops` must be an array `stops` एक सारणी होना चाहिए
|
||||
@msg `{key}` must be an array `{key}` एक सारणी होना चाहिए
|
||||
@msg `story` styles the whole story: give no `start` or `end` `story` पूरी स्टोरी को स्टाइल करता है: `start` या `end` न दें
|
||||
@msg `story` takes the whole story: give no `start` or `end` `story` पूरी स्टोरी लेता है: `start` या `end` न दें
|
||||
@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` स्ट्रोक पर लागू होता है (stroke: true)
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1847,6 +1847,14 @@ file-filter Templates Sablonok
|
||||
Hand Kéz
|
||||
Hand Tool Kéz
|
||||
Hand-Drawn Kézzel rajzolt
|
||||
Painted and Sketched Festett és vázlatos
|
||||
Washed Blue Kimosott kék
|
||||
Bristle Ochre Sörteokker
|
||||
Pastel Heather Pasztell hanga
|
||||
Charcoal Shade Szénárnyék
|
||||
Ink Contour Tuskontúr
|
||||
Wet Ink Nedves tus
|
||||
Fine Pen Finom toll
|
||||
Handles Fogantyúk
|
||||
Hard Szigorú
|
||||
Hard Light Kemény fény
|
||||
@@ -4759,6 +4767,7 @@ imageTrace Outline Körvonal
|
||||
@msg `globalAdjust` must be an object `globalAdjust`: objektum kell
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize`: 1, 3 vagy 5 kell
|
||||
@msg `stops` must be an array `stops`: tömb kell
|
||||
@msg `{key}` must be an array `{key}`: tömb kell
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg`: SVG-beállításokat tartalmazó objektum kell (lásd: document.formats)
|
||||
@msg `to` must be "corner" or "smooth" `to`: "corner" vagy "smooth" kell
|
||||
@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference`: "emBox", "capHeight", "xHeight" vagy "icfBox" kell
|
||||
@@ -6008,6 +6017,7 @@ imageTrace Outline Körvonal
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometriai segédvonalak
|
||||
Geometric Patterns Geometriai mintázatok
|
||||
Grit Textures Szemcsés textúrák
|
||||
Ease In Lassú kezdés
|
||||
Ease Out Lassú befejezés
|
||||
Ease In Out Lassú kezdés és befejezés
|
||||
@@ -6494,6 +6504,10 @@ anchor Points: Pontok:
|
||||
@msg features lists at most {max} tags a features legfeljebb {max} címkét tartalmazhat
|
||||
@msg unknown OpenType feature `{t}` ismeretlen OpenType-funkció: `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} 0-nál nagyobb és legfeljebb {max} értékű szám kell legyen
|
||||
@msg {label} must be at most {max} characters {label} legfeljebb {max} karakter lehet
|
||||
@msg {label} must be one character {label} pontosan egy karakter kell legyen
|
||||
@msg at most {max} tab stops legfeljebb {max} tabulátor
|
||||
@msg unknown align `{other}` ismeretlen igazítás: `{other}`
|
||||
@msg no character style `{name}` A(z) `{name}` karakterstílus nem található
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6655,6 +6669,8 @@ breakText Glyphs Glifák
|
||||
Delete the selected brushes? Törlöd a kijelölt ecseteket?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Körvonalak használnak egyet vagy többet ezek közül az ecsetek közül. Leveszed az ecseteket ezekről a körvonalakról, és törlöd az ecseteket?
|
||||
Remove Strokes Körvonalak eltávolítása
|
||||
Place Brush Art Ecsetgrafika elhelyezése
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ecsetnek nincs grafikája (csak a szóró-, a grafikus és a mintázatecseteknek van)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Elrendezőeszközök
|
||||
Arrange in Grid… Elrendezés rácsba…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -758,6 +758,14 @@
|
||||
Damp Paper Kertas Basah
|
||||
Outlines and Rules Garis luar dan garis
|
||||
Hand-Drawn Gambar tangan
|
||||
Painted and Sketched Lukisan dan sketsa
|
||||
Washed Blue Biru luntur
|
||||
Bristle Ochre Oker kuas kering
|
||||
Pastel Heather Ungu pastel
|
||||
Charcoal Shade Bayangan arang
|
||||
Ink Contour Kontur tinta
|
||||
Wet Ink Tinta basah
|
||||
Fine Pen Pena halus
|
||||
Gradient Finishes Finishing gradien
|
||||
Shape Effects Efek bentuk
|
||||
Blends and Transparency Blend dan transparansi
|
||||
@@ -5859,6 +5867,7 @@ swatch Bright Violet Violet cerah
|
||||
Ruby Ruby
|
||||
Geometric Guides Panduan Geometris
|
||||
Geometric Patterns Pola Geometris
|
||||
Grit Textures Tekstur Butiran
|
||||
Ease In Awal Lambat
|
||||
Ease Out Akhir Lambat
|
||||
Ease In Out Awal dan Akhir Lambat
|
||||
@@ -6401,6 +6410,8 @@ breakText Glyphs Glif
|
||||
Delete the selected brushes? Hapus kuas yang dipilih?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Stroke menggunakan satu atau beberapa kuas ini. Lepaskan kuas dari stroke tersebut dan hapus kuasnya?
|
||||
Remove Strokes Hapus Stroke
|
||||
Place Brush Art Tempatkan karya seni kuas
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` tidak memiliki karya seni (hanya kuas sebar, kuas seni, dan kuas pola yang memilikinya)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Alat penyusunan
|
||||
Arrange in Grid… Susun dalam grid…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2380 and column 5.
|
@@ -1474,6 +1474,14 @@ file-filter Templates Modelli
|
||||
Hair Space Spazio sottilissimo
|
||||
Hand Tool Strumento Mano
|
||||
Hand-Drawn Disegnato a mano
|
||||
Painted and Sketched Dipinto e schizzato
|
||||
Washed Blue Blu slavato
|
||||
Bristle Ochre Ocra a setole
|
||||
Pastel Heather Erica pastello
|
||||
Charcoal Shade Ombra a carboncino
|
||||
Ink Contour Contorno a china
|
||||
Wet Ink China bagnata
|
||||
Fine Pen Pennino fine
|
||||
Handles Maniglie
|
||||
Hard Light Luce intensa
|
||||
Hard Mix Miscela intensa
|
||||
@@ -4302,6 +4310,7 @@ axis Both Entrambi
|
||||
@msg `globalAdjust` must be an object `globalAdjust` deve essere un oggetto
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve essere 1, 3 o 5
|
||||
@msg `stops` must be an array `stops` deve essere un array
|
||||
@msg `{key}` must be an array `{key}` deve essere un array
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` deve essere un oggetto di opzioni SVG (vedi document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` non è un comando di salvataggio
|
||||
@msg `{area}` is no recovery area name `{area}` non è il nome di un'area di recupero
|
||||
@@ -5648,6 +5657,7 @@ lighting Light Luce
|
||||
Ruby Ruby
|
||||
Geometric Guides Guide geometriche
|
||||
Geometric Patterns Motivi geometrici
|
||||
Grit Textures Texture granulose
|
||||
Ease In Inizio graduale
|
||||
Ease Out Fine graduale
|
||||
Ease In Out Inizio e fine graduali
|
||||
@@ -6138,6 +6148,10 @@ anchor Points: Punti:
|
||||
@msg features lists at most {max} tags features contiene al massimo {max} tag
|
||||
@msg unknown OpenType feature `{t}` funzione OpenType sconosciuta `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} deve essere un numero maggiore di 0 e al massimo {max}
|
||||
@msg {label} must be at most {max} characters {label} deve avere al massimo {max} caratteri
|
||||
@msg {label} must be one character {label} deve essere un solo carattere
|
||||
@msg at most {max} tab stops al massimo {max} tabulazioni
|
||||
@msg unknown align `{other}` allineamento sconosciuto `{other}`
|
||||
@msg no character style `{name}` Stile di carattere `{name}` non trovato
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6300,6 +6314,8 @@ breakText Glyphs Glifi
|
||||
Delete the selected brushes? Eliminare i pennelli selezionati?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Alcune tracce usano uno o più di questi pennelli. Rimuovere i pennelli da queste tracce ed eliminare i pennelli?
|
||||
Remove Strokes Rimuovi tracce
|
||||
Place Brush Art Inserisci grafica del pennello
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` non ha una grafica (ce l’hanno solo i pennelli a dispersione, artistici e motivo)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Strumenti di disposizione
|
||||
Arrange in Grid… Disponi in griglia…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -844,6 +844,14 @@ digits Malayalam マラヤーラム数字
|
||||
Damp Paper 湿った紙
|
||||
Outlines and Rules アウトラインと罫線
|
||||
Hand-Drawn 手描き風
|
||||
Painted and Sketched 絵画とスケッチ
|
||||
Washed Blue にじみブルー
|
||||
Bristle Ochre 筆跡オーカー
|
||||
Pastel Heather パステルヘザー
|
||||
Charcoal Shade 木炭シェード
|
||||
Ink Contour インク輪郭
|
||||
Wet Ink 濡れ墨
|
||||
Fine Pen 細いペン
|
||||
Gradient Finishes グラデーション仕上げ
|
||||
Shape Effects シェイプ効果
|
||||
Blends and Transparency ブレンドと透明
|
||||
@@ -4400,6 +4408,7 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg `preserve` must be an object `preserve` にはオブジェクトを指定してください
|
||||
@msg `globalAdjust` must be an object `globalAdjust` にはオブジェクトを指定してください
|
||||
@msg `stops` must be an array `stops` には配列を指定してください
|
||||
@msg `{key}` must be an array `{key}` には配列を指定してください
|
||||
@msg `{k}` must be [x, y] `{k}` は [x, y] の形式で指定してください
|
||||
@msg a gradient needs at least two stops グラデーションには 2 つ以上の分岐点が必要です
|
||||
@msg give both `start` and `end` [x, y] (or neither) `start` と `end` は両方とも [x, y] で指定するか、どちらも省略してください
|
||||
@@ -5698,6 +5707,7 @@ lighting Light ライト
|
||||
Ruby ルビ
|
||||
Geometric Guides 幾何ガイド
|
||||
Geometric Patterns 幾何パターン
|
||||
Grit Textures 粒状テクスチャ
|
||||
Ease In イーズイン
|
||||
Ease Out イーズアウト
|
||||
Ease In Out イーズインアウト
|
||||
@@ -6203,6 +6213,10 @@ anchor Points: ポイント:
|
||||
@msg features lists at most {max} tags features に指定できるタグは {max} 個までです
|
||||
@msg unknown OpenType feature `{t}` 不明な OpenType 機能 `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} には 0 より大きく {max} 以下の数値を指定してください
|
||||
@msg {label} must be at most {max} characters {label} には {max} 文字以内で指定してください
|
||||
@msg {label} must be one character {label} には 1 文字を指定してください
|
||||
@msg at most {max} tab stops タブストップは {max} 個までです
|
||||
@msg unknown align `{other}` 不明な揃え `{other}`
|
||||
@msg no character style `{name}` 文字スタイル `{name}` が見つかりません
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6365,6 +6379,8 @@ breakText Glyphs 字形
|
||||
Delete the selected brushes? 選択したブラシを削除しますか?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? これらのブラシの 1 つ以上が線で使われています。それらの線からブラシを外して、ブラシを削除しますか?
|
||||
Remove Strokes 線を削除
|
||||
Place Brush Art ブラシのアートを配置
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` にはアートがありません (アートを持つのは散布ブラシ、アートブラシ、パターンブラシだけです)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools 配置ツール
|
||||
Arrange in Grid… グリッドに配置…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -718,6 +718,14 @@
|
||||
Damp Paper ნესტიანი ქაღალდი
|
||||
Outlines and Rules კონტურები და ხაზები
|
||||
Hand-Drawn ხელით დახატული
|
||||
Painted and Sketched დახატული და ესკიზური
|
||||
Washed Blue გარეცხილი ლურჯი
|
||||
Bristle Ochre ფუნჯის ოხრა
|
||||
Pastel Heather პასტელის იისფერი
|
||||
Charcoal Shade ნახშირის ჩრდილი
|
||||
Ink Contour მელნის კონტური
|
||||
Wet Ink სველი მელანი
|
||||
Fine Pen წვრილი კალამი
|
||||
Gradient Finishes გრადიენტული ზედაპირები
|
||||
Shape Effects ფიგურის ეფექტები
|
||||
Blends and Transparency გადასვლები და გამჭვირვალობა
|
||||
@@ -4033,6 +4041,7 @@ font-technology Variable ცვლადი
|
||||
@msg `globalAdjust` must be an object `globalAdjust` უნდა იყოს ობიექტი
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` უნდა იყოს 1, 3 ან 5
|
||||
@msg `stops` must be an array `stops` უნდა იყოს მასივი
|
||||
@msg `{key}` must be an array `{key}` უნდა იყოს მასივი
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` უნდა იყოს SVG-ის პარამეტრების ობიექტი (იხ. document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` შენახვის ბრძანება არ არის
|
||||
@msg `{area}` is no recovery area name `{area}` აღდგენის არის სახელი არ არის
|
||||
@@ -5965,6 +5974,7 @@ imageTrace Outline კონტური
|
||||
Ruby რუბი
|
||||
Geometric Guides გეომეტრიული მიმმართველები
|
||||
Geometric Patterns გეომეტრიული პატერნები
|
||||
Grit Textures მარცვლოვანი ტექსტურები
|
||||
Ease In ნელი დასაწყისი
|
||||
Ease Out ნელი დასასრული
|
||||
Ease In Out ნელი დასაწყისი და დასასრული
|
||||
@@ -6536,6 +6546,10 @@ anchor Points: წერტილები:
|
||||
@msg features lists at most {max} tags features შეიძლება შეიცავდეს არაუმეტეს {max} ტეგს
|
||||
@msg unknown OpenType feature `{t}` უცნობი OpenType შესაძლებლობა `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} უნდა იყოს 0-ზე მეტი და არაუმეტეს {max}-ისა რიცხვი
|
||||
@msg {label} must be at most {max} characters {label} უნდა შეიცავდეს არაუმეტეს {max} სიმბოლოს
|
||||
@msg {label} must be one character {label} უნდა იყოს ერთი სიმბოლო
|
||||
@msg at most {max} tab stops არაუმეტეს {max} ტაბულაციის გაჩერება
|
||||
@msg unknown align `{other}` უცნობი სწორება `{other}`
|
||||
@msg no character style `{name}` სიმბოლოს სტილი `{name}` ვერ მოიძებნა
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6697,6 +6711,8 @@ breakText Glyphs გლიფები
|
||||
Delete the selected brushes? წაიშალოს არჩეული ფუნჯები?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? კონტურები იყენებს ამ ფუნჯებიდან ერთს ან მეტს. მოიხსნას ფუნჯები ამ კონტურებიდან და წაიშალოს ფუნჯები?
|
||||
Remove Strokes კონტურების მოხსნა
|
||||
Place Brush Art ფუნჯის ნახატის განთავსება
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ნახატი არ აქვს (ნახატი მხოლოდ გაფანტვის, მხატვრულ და პატერნის ფუნჯებს აქვს)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools განლაგების ხელსაწყოები
|
||||
Arrange in Grid… ბადეში განლაგება…
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -837,6 +837,14 @@ digits Malayalam 말라얄람어
|
||||
Damp Paper 젖은 종이
|
||||
Outlines and Rules 윤곽선과 괘선
|
||||
Hand-Drawn 손그림
|
||||
Painted and Sketched 페인팅과 스케치
|
||||
Washed Blue 번진 파랑
|
||||
Bristle Ochre 붓결 황토
|
||||
Pastel Heather 파스텔 헤더
|
||||
Charcoal Shade 목탄 음영
|
||||
Ink Contour 잉크 윤곽
|
||||
Wet Ink 젖은 먹
|
||||
Fine Pen 가는 펜
|
||||
Gradient Finishes 그레이디언트 마감
|
||||
Shape Effects 모양 효과
|
||||
Blends and Transparency 블렌드와 투명도
|
||||
@@ -4327,6 +4335,7 @@ theme Appearance 외관
|
||||
@msg `preserve` must be an object `preserve`는 개체여야 합니다
|
||||
@msg `globalAdjust` must be an object `globalAdjust`는 개체여야 합니다
|
||||
@msg `stops` must be an array `stops`는 배열이어야 합니다
|
||||
@msg `{key}` must be an array `{key}`은(는) 배열이어야 합니다
|
||||
@msg `{k}` must be [x, y] `{k}`은(는) [x, y]여야 합니다
|
||||
@msg a gradient needs at least two stops 그레이디언트에는 정지점이 두 개 이상 필요합니다
|
||||
@msg give both `start` and `end` [x, y] (or neither) `start`와 `end` [x, y]를 모두 지정하세요(또는 둘 다 생략)
|
||||
@@ -5609,6 +5618,7 @@ lighting Light 광원
|
||||
Ruby 루비
|
||||
Geometric Guides 기하학적 안내선
|
||||
Geometric Patterns 기하학적 패턴
|
||||
Grit Textures 입자 텍스처
|
||||
Ease In 서서히 시작
|
||||
Ease Out 서서히 종료
|
||||
Ease In Out 서서히 시작 및 종료
|
||||
@@ -6099,6 +6109,10 @@ anchor Points: 포인트:
|
||||
@msg features lists at most {max} tags features에는 태그를 최대 {max}개까지 지정할 수 있습니다
|
||||
@msg unknown OpenType feature `{t}` 알 수 없는 OpenType 기능 `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label}은(는) 0 초과 {max} 이하의 숫자여야 합니다
|
||||
@msg {label} must be at most {max} characters {label}은(는) 최대 {max}자여야 합니다
|
||||
@msg {label} must be one character {label}은(는) 한 글자여야 합니다
|
||||
@msg at most {max} tab stops 탭 정지는 최대 {max}개까지 지정할 수 있습니다
|
||||
@msg unknown align `{other}` 알 수 없는 정렬 `{other}`
|
||||
@msg no character style `{name}` 문자 스타일 `{name}`을 찾을 수 없습니다
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6261,6 +6275,8 @@ breakText Glyphs 글리프
|
||||
Delete the selected brushes? 선택한 브러시를 삭제하시겠습니까?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 선에서 이 브러시 중 하나 이상을 사용합니다. 해당 선에서 브러시를 제거하고 브러시를 삭제하시겠습니까?
|
||||
Remove Strokes 선 제거
|
||||
Place Brush Art 브러시 아트 배치
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}`에는 아트가 없습니다 (흩뿌리기, 아트, 패턴 브러시에만 아트가 있습니다)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools 배치 도구
|
||||
Arrange in Grid… 격자로 배치…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -772,6 +772,14 @@
|
||||
Damp Paper Vochtig papier
|
||||
Outlines and Rules Omtrekken en lijnen
|
||||
Hand-Drawn Handgetekend
|
||||
Painted and Sketched Geschilderd en geschetst
|
||||
Washed Blue Verwassen blauw
|
||||
Bristle Ochre Borsteloker
|
||||
Pastel Heather Pastelheide
|
||||
Charcoal Shade Houtskoolschaduw
|
||||
Ink Contour Inktcontour
|
||||
Wet Ink Natte inkt
|
||||
Fine Pen Fijne pen
|
||||
Gradient Finishes Verloopafwerkingen
|
||||
Shape Effects Vormeffecten
|
||||
Blends and Transparency Vermengingen en transparantie
|
||||
@@ -5910,6 +5918,7 @@ swatch Bright Violet Felviolet
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometrische hulplijnen
|
||||
Geometric Patterns Geometrische patronen
|
||||
Grit Textures Korrelige texturen
|
||||
Ease In Langzaam beginnen
|
||||
Ease Out Langzaam eindigen
|
||||
Ease In Out Langzaam beginnen en eindigen
|
||||
@@ -6452,6 +6461,8 @@ breakText Glyphs Glyphs
|
||||
Delete the selected brushes? Geselecteerde kwasten verwijderen?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Contouren gebruiken een of meer van deze kwasten. De kwasten van deze contouren halen en de kwasten verwijderen?
|
||||
Remove Strokes Contouren verwijderen
|
||||
Place Brush Art Penseelillustratie plaatsen
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` heeft geen illustratie (alleen verspreidings-, art- en patroonpenselen hebben er een)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Schikgereedschappen
|
||||
Arrange in Grid… In raster schikken…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -768,6 +768,14 @@
|
||||
Damp Paper Wilgotny papier
|
||||
Outlines and Rules Kontury i linie
|
||||
Hand-Drawn Rysowane odręcznie
|
||||
Painted and Sketched Malowane i szkicowane
|
||||
Washed Blue Rozmyty błękit
|
||||
Bristle Ochre Ochra pędzlem
|
||||
Pastel Heather Pastelowy wrzos
|
||||
Charcoal Shade Cień węglem
|
||||
Ink Contour Kontur tuszem
|
||||
Wet Ink Mokry tusz
|
||||
Fine Pen Cienkie pióro
|
||||
Gradient Finishes Wykończenia gradientowe
|
||||
Shape Effects Efekty kształtów
|
||||
Blends and Transparency Przejścia i przezroczystość
|
||||
@@ -4383,6 +4391,7 @@ imageTrace Outline Kontur
|
||||
@msg `globalAdjust` must be an object `globalAdjust` musi być obiektem
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` musi wynosić 1, 3 lub 5
|
||||
@msg `stops` must be an array `stops` musi być tablicą
|
||||
@msg `{key}` must be an array `{key}` musi być tablicą
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` musi być obiektem opcji SVG (patrz document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` nie jest poleceniem zapisu
|
||||
@msg `{area}` is no recovery area name `{area}` nie jest nazwą obszaru odzyskiwania
|
||||
@@ -5879,6 +5888,7 @@ mark-style Japanese Japońskie
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometryczne linie pomocnicze
|
||||
Geometric Patterns Wzorki geometryczne
|
||||
Grit Textures Tekstury ziarniste
|
||||
Ease In Łagodny początek
|
||||
Ease Out Łagodny koniec
|
||||
Ease In Out Łagodny początek i koniec
|
||||
@@ -6365,6 +6375,10 @@ anchor Points: Punkty:
|
||||
@msg features lists at most {max} tags features może zawierać najwyżej {max} znaczników
|
||||
@msg unknown OpenType feature `{t}` nieznana funkcja OpenType `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} musi być liczbą większą od 0 i nie większą niż {max}
|
||||
@msg {label} must be at most {max} characters {label} może mieć najwyżej {max} znaków
|
||||
@msg {label} must be one character {label} musi być jednym znakiem
|
||||
@msg at most {max} tab stops najwyżej {max} tabulatorów
|
||||
@msg unknown align `{other}` nieznane wyrównanie `{other}`
|
||||
@msg no character style `{name}` Nie znaleziono stylu znakowego `{name}`
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6527,6 +6541,8 @@ breakText Glyphs Glify
|
||||
Delete the selected brushes? Usunąć zaznaczone pędzle?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Obrysy używają co najmniej jednego z tych pędzli. Zdjąć pędzle z tych obrysów i usunąć pędzle?
|
||||
Remove Strokes Usuń obrysy
|
||||
Place Brush Art Umieść kompozycję pędzla
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nie ma kompozycji (mają ją tylko pędzle rozproszone, artystyczne i wzorka)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Narzędzia układania
|
||||
Arrange in Grid… Ułóż w siatce…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1278,6 +1278,14 @@ antiAlias Sharp Nítido
|
||||
Hair Space Espaço Fino
|
||||
Hand Tool Ferramenta Mão
|
||||
Hand-Drawn Desenhado à Mão
|
||||
Painted and Sketched Pintado e Esboçado
|
||||
Washed Blue Azul Lavado
|
||||
Bristle Ochre Ocre de Cerdas
|
||||
Pastel Heather Urze Pastel
|
||||
Charcoal Shade Sombra a Carvão
|
||||
Ink Contour Contorno a Nanquim
|
||||
Wet Ink Nanquim Úmido
|
||||
Fine Pen Pena Fina
|
||||
Handles Alças
|
||||
Hard Light Luz Forte
|
||||
Hard Mix Mistura definida
|
||||
@@ -3798,6 +3806,7 @@ axis Both Ambos
|
||||
@msg `globalAdjust` must be an object `globalAdjust` deve ser um objeto
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve ser 1, 3 ou 5
|
||||
@msg `stops` must be an array `stops` deve ser uma matriz
|
||||
@msg `{key}` must be an array `{key}` deve ser uma matriz
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` deve ser um objeto com opções de SVG (veja document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` não é um comando de salvar
|
||||
@msg `{area}` is no recovery area name `{area}` não é o nome de uma área de recuperação
|
||||
@@ -5940,6 +5949,7 @@ mark-style Japanese Japonesas
|
||||
Ruby Ruby
|
||||
Geometric Guides Guias geométricas
|
||||
Geometric Patterns Padrões geométricos
|
||||
Grit Textures Texturas granuladas
|
||||
Ease In Entrada suave
|
||||
Ease Out Saída suave
|
||||
Ease In Out Entrada e saída suaves
|
||||
@@ -6426,6 +6436,10 @@ anchor Points: Pontos:
|
||||
@msg features lists at most {max} tags features aceita no máximo {max} marcas
|
||||
@msg unknown OpenType feature `{t}` recurso OpenType desconhecido `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} deve ser um número maior que 0 e no máximo {max}
|
||||
@msg {label} must be at most {max} characters {label} deve ter no máximo {max} caracteres
|
||||
@msg {label} must be one character {label} deve ser um único caractere
|
||||
@msg at most {max} tab stops no máximo {max} paradas de tabulação
|
||||
@msg unknown align `{other}` alinhamento desconhecido `{other}`
|
||||
@msg no character style `{name}` Estilo de caractere `{name}` não encontrado
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6589,6 +6603,8 @@ breakText Glyphs Glifos
|
||||
Delete the selected brushes? Excluir os pincéis selecionados?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Traçados usam um ou mais destes pincéis. Remover os pincéis desses traçados e excluir os pincéis?
|
||||
Remove Strokes Remover Traçados
|
||||
Place Brush Art Colocar Arte do Pincel
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` não tem arte (só os pincéis de dispersão, de arte e de padrão têm)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Ferramentas de Organização
|
||||
Arrange in Grid… Organizar em Grade…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1463,6 +1463,14 @@ file-filter Templates Шаблоны
|
||||
Hair Space Волосяная шпация
|
||||
Hand Tool Инструмент «Рука»
|
||||
Hand-Drawn Нарисованный от руки
|
||||
Painted and Sketched Живопись и эскизы
|
||||
Washed Blue Размытый синий
|
||||
Bristle Ochre Охра кистью
|
||||
Pastel Heather Пастельный вереск
|
||||
Charcoal Shade Тень углём
|
||||
Ink Contour Контур тушью
|
||||
Wet Ink Влажная тушь
|
||||
Fine Pen Тонкое перо
|
||||
Handles Маркеры
|
||||
Hard Light Жёсткий свет
|
||||
Hard Mix Жёсткое наложение
|
||||
@@ -4288,6 +4296,7 @@ axis Both Оба
|
||||
@msg `globalAdjust` must be an object `globalAdjust` должно быть объектом
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` должно быть 1, 3 или 5
|
||||
@msg `stops` must be an array `stops` должно быть массивом
|
||||
@msg `{key}` must be an array `{key}` должно быть массивом
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` должно быть объектом параметров SVG (см. document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` не является командой сохранения
|
||||
@msg `{area}` is no recovery area name `{area}` не является именем области восстановления
|
||||
@@ -5634,6 +5643,7 @@ lighting Light Свет
|
||||
Ruby Руби
|
||||
Geometric Guides Геометрические направляющие
|
||||
Geometric Patterns Геометрические узоры
|
||||
Grit Textures Зернистые текстуры
|
||||
Ease In Плавное начало
|
||||
Ease Out Плавное окончание
|
||||
Ease In Out Плавное начало и окончание
|
||||
@@ -6487,6 +6497,10 @@ anchor Points: Точки:
|
||||
@msg features lists at most {max} tags features содержит не более {max} тегов
|
||||
@msg unknown OpenType feature `{t}` неизвестная функция OpenType `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} должно быть числом больше 0 и не более {max}
|
||||
@msg {label} must be at most {max} characters {label} должно содержать не более {max} символов
|
||||
@msg {label} must be one character {label} должно быть одним символом
|
||||
@msg at most {max} tab stops не более {max} позиций табуляции
|
||||
@msg unknown align `{other}` неизвестное выравнивание `{other}`
|
||||
@msg no character style `{name}` Стиль символов `{name}` не найден
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6648,6 +6662,8 @@ breakText Glyphs Глифы
|
||||
Delete the selected brushes? Удалить выбранные кисти?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обводки используют одну или несколько из этих кистей. Убрать кисти с этих обводок и удалить кисти?
|
||||
Remove Strokes Убрать обводки
|
||||
Place Brush Art Поместить рисунок кисти
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` нет рисунка (он есть только у кистей-распылений, художественных и узорных кистей)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Инструменты размещения
|
||||
Arrange in Grid… Разместить по сетке…
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1412,6 +1412,14 @@ font-technology Variable Variabla
|
||||
Hair Space Hårfint blanksteg
|
||||
Hand Tool Handverktyg
|
||||
Hand-Drawn Handritad
|
||||
Painted and Sketched Målat och skissat
|
||||
Washed Blue Urtvättad blå
|
||||
Bristle Ochre Borstockra
|
||||
Pastel Heather Pastelljung
|
||||
Charcoal Shade Kolskugga
|
||||
Ink Contour Tuschkontur
|
||||
Wet Ink Våt tusch
|
||||
Fine Pen Fin penna
|
||||
Handles Handtag
|
||||
Hard Light Hårt ljus
|
||||
Hard Mix Hård blandning
|
||||
@@ -4161,6 +4169,7 @@ axis Both Båda
|
||||
@msg `globalAdjust` must be an object `globalAdjust` måste vara ett objekt
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` måste vara 1, 3 eller 5
|
||||
@msg `stops` must be an array `stops` måste vara en array
|
||||
@msg `{key}` must be an array `{key}` måste vara en array
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` måste vara ett objekt med SVG-alternativ (se document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` är inget sparkommando
|
||||
@msg `{area}` is no recovery area name `{area}` är inget namn på ett återställningsområde
|
||||
@@ -6047,6 +6056,7 @@ font-script Japanese Japanska
|
||||
Ruby Ruby
|
||||
Geometric Guides Geometriska stödlinjer
|
||||
Geometric Patterns Geometriska mönster
|
||||
Grit Textures Korniga texturer
|
||||
Ease In Långsam start
|
||||
Ease Out Långsamt slut
|
||||
Ease In Out Långsam start och slut
|
||||
@@ -6556,6 +6566,9 @@ shape Open Öppen
|
||||
@msg features lists at most {max} tags features får innehålla högst {max} taggar
|
||||
@msg unknown OpenType feature `{t}` okänd OpenType-funktion `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} måste vara ett tal över 0 och högst {max}
|
||||
@msg {label} must be at most {max} characters {label} får innehålla högst {max} tecken
|
||||
@msg {label} must be one character {label} måste vara ett tecken
|
||||
@msg at most {max} tab stops högst {max} tabbstopp
|
||||
@msg no character style `{name}` Teckenformatet `{name}` hittades inte
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6717,6 +6730,8 @@ breakText Glyphs Glyfer
|
||||
Delete the selected brushes? Radera de valda penslarna?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturer använder en eller flera av de här penslarna. Ta bort penslarna från konturerna och radera penslarna?
|
||||
Remove Strokes Ta bort konturer
|
||||
Place Brush Art Placera penselgrafik
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (bara spridnings-, konst- och mönsterpenslar har grafik)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Ordningsverktyg
|
||||
Arrange in Grid… Ordna i rutnät…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -238,6 +238,20 @@ fn anti_aliasing_method_labels_are_in_complete_catalogs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Painted and Sketched graphic style library's name and its styles' names have a row in every
|
||||
/// bundled catalog.
|
||||
#[test]
|
||||
fn painted_and_sketched_style_names_are_in_every_catalog() {
|
||||
let lib = vectorcraft_doc::style_libs::builtin_style_library("painted-sketched").expect("built in");
|
||||
let labels: Vec<&str> = std::iter::once(lib.name.as_str()).chain(lib.styles.iter().map(|g| g.name.as_str())).collect();
|
||||
assert_eq!(labels.len(), 8);
|
||||
for lang in LANGUAGES.iter().filter(|lang| !lang.source.is_empty()) {
|
||||
for label in &labels {
|
||||
assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The names the EPS options' menus show (preview, overprints, flattener preset and PostScript
|
||||
/// level), and Save As's Preview label.
|
||||
#[test]
|
||||
|
||||
@@ -1376,6 +1376,14 @@ font-technology Variable Değişken
|
||||
Hair Space Saç Alanı
|
||||
Hand Tool El Aracı
|
||||
Hand-Drawn elle çizilmiş
|
||||
Painted and Sketched Boyanmış ve Eskiz
|
||||
Washed Blue Soluk Mavi
|
||||
Bristle Ochre Fırça Darbeli Toprak Sarısı
|
||||
Pastel Heather Pastel Funda
|
||||
Charcoal Shade Kömür Gölge
|
||||
Ink Contour Mürekkep Kontur
|
||||
Wet Ink Islak Mürekkep
|
||||
Fine Pen İnce Kalem
|
||||
Handles Kollar
|
||||
Hard Light Sert Işık
|
||||
Harmony Rules: Uyum Kuralları:
|
||||
@@ -4041,6 +4049,7 @@ axis Both Her İkisi
|
||||
@msg `globalAdjust` must be an object `globalAdjust` bir nesne olmalıdır
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` 1, 3 veya 5 olmalıdır
|
||||
@msg `stops` must be an array `stops` bir dizi olmalıdır
|
||||
@msg `{key}` must be an array `{key}` bir dizi olmalıdır
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg`, SVG seçeneklerinin bir nesnesi olmalıdır (bkz. document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` kaydetme komutu değil
|
||||
@msg `{area}` is no recovery area name `{area}`, kurtarma alanı adı değildir
|
||||
@@ -6020,6 +6029,7 @@ axis Y Axis Y ekseni
|
||||
Snap to Perpendicular Dikeye Yasla
|
||||
Geometric Guides Geometrik Kılavuzlar
|
||||
Geometric Patterns Geometrik Desenler
|
||||
Grit Textures Grenli Dokular
|
||||
@msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır
|
||||
# Heavy plug-in filters (plugin.inspect, plug-in runs)
|
||||
Install “{name}”, which asks for a heavy budget? Büyük bir hesaplama bütçesi isteyen “{name}” yüklensin mi?
|
||||
@@ -6466,6 +6476,10 @@ anchor Points: Noktalar:
|
||||
@msg features lists at most {max} tags features en fazla {max} etiket içerebilir
|
||||
@msg unknown OpenType feature `{t}` bilinmeyen OpenType özelliği `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} 0'ın üzerinde ve en fazla {max} olan bir sayı olmalıdır
|
||||
@msg {label} must be at most {max} characters {label} en fazla {max} karakter olmalıdır
|
||||
@msg {label} must be one character {label} tek bir karakter olmalıdır
|
||||
@msg at most {max} tab stops en fazla {max} sekme durağı
|
||||
@msg unknown align `{other}` bilinmeyen hizalama `{other}`
|
||||
@msg no character style `{name}` `{name}` karakter stili bulunamadı
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6627,6 +6641,8 @@ breakText Glyphs Glifler
|
||||
Delete the selected brushes? Seçili fırçalar silinsin mi?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturlar bu fırçalardan birini veya daha fazlasını kullanıyor. Fırçalar bu konturlardan kaldırılsın ve fırçalar silinsin mi?
|
||||
Remove Strokes Konturları Kaldır
|
||||
Place Brush Art Fırça Çizimini Yerleştir
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` fırçasının çizimi yok (yalnızca serpme, resim ve desen fırçalarının çizimi vardır)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Düzenleme Araçları
|
||||
Arrange in Grid… Izgarada Düzenle…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1468,6 +1468,14 @@ file-filter Templates Шаблони
|
||||
Hair Space Волосяна шпація
|
||||
Hand Tool Інструмент «Рука»
|
||||
Hand-Drawn Намальований від руки
|
||||
Painted and Sketched Мальовані та ескізні
|
||||
Washed Blue Розмитий синій
|
||||
Bristle Ochre Охра пензлем
|
||||
Pastel Heather Пастельний верес
|
||||
Charcoal Shade Тінь вугіллям
|
||||
Ink Contour Контур тушшю
|
||||
Wet Ink Мокра туш
|
||||
Fine Pen Тонке перо
|
||||
Handles Маркери
|
||||
Hard Light Жорстке світло
|
||||
Hard Mix Жорстке накладання
|
||||
@@ -4286,6 +4294,7 @@ axis Both Обидві
|
||||
@msg `globalAdjust` must be an object `globalAdjust` має бути об’єктом
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` має бути 1, 3 або 5
|
||||
@msg `stops` must be an array `stops` має бути масивом
|
||||
@msg `{key}` must be an array `{key}` має бути масивом
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` має бути об’єктом параметрів SVG (див. document.formats)
|
||||
@msg `{_1}` is no save command `{_1}` не є командою збереження
|
||||
@msg `{area}` is no recovery area name `{area}` не є ім’ям області відновлення
|
||||
@@ -5628,6 +5637,7 @@ lighting Light Світло
|
||||
Ruby Рубі
|
||||
Geometric Guides Геометричні напрямні
|
||||
Geometric Patterns Геометричні візерунки
|
||||
Grit Textures Зернисті текстури
|
||||
Ease In Плавний початок
|
||||
Ease Out Плавне завершення
|
||||
Ease In Out Плавний початок і завершення
|
||||
@@ -6118,6 +6128,10 @@ anchor Points: Точки:
|
||||
@msg features lists at most {max} tags features містить не більше {max} тегів
|
||||
@msg unknown OpenType feature `{t}` невідома функція OpenType `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} має бути числом більше 0 і не більше {max}
|
||||
@msg {label} must be at most {max} characters {label} має містити не більше {max} символів
|
||||
@msg {label} must be one character {label} має бути одним символом
|
||||
@msg at most {max} tab stops не більше {max} позицій табуляції
|
||||
@msg unknown align `{other}` невідоме вирівнювання `{other}`
|
||||
@msg no character style `{name}` Стиль символів `{name}` не знайдено
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6280,6 +6294,8 @@ breakText Glyphs Гліфи
|
||||
Delete the selected brushes? Видалити вибрані пензлі?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обведення використовують один або кілька з цих пензлів. Забрати пензлі з цих обведень і видалити пензлі?
|
||||
Remove Strokes Забрати обведення
|
||||
Place Brush Art Розмістити малюнок пензля
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` немає малюнка (він є лише в пензлів-розпорошень, художніх і візерункових пензлів)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Інструменти розміщення
|
||||
Arrange in Grid… Розмістити сіткою…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -1695,6 +1695,14 @@ file-filter Templates Mẫu
|
||||
Halo Vầng hào quang
|
||||
Hand Tool Công cụ Bàn tay
|
||||
Hand-Drawn Vẽ tay
|
||||
Painted and Sketched Vẽ màu và phác thảo
|
||||
Washed Blue Xanh nhạt nhòa
|
||||
Bristle Ochre Vàng đất cọ khô
|
||||
Pastel Heather Tím phấn màu
|
||||
Charcoal Shade Bóng than
|
||||
Ink Contour Đường viền mực
|
||||
Wet Ink Mực ướt
|
||||
Fine Pen Bút mảnh
|
||||
Handles Tay nắm
|
||||
Hard Cứng
|
||||
Hard Light Ánh sáng mạnh
|
||||
@@ -4420,6 +4428,7 @@ theme Appearance Giao diện
|
||||
@msg `globalAdjust` must be an object `globalAdjust` phải là một đối tượng
|
||||
@msg `sampleSize` must be 1, 3 or 5 `sampleSize` phải là 1, 3 hoặc 5
|
||||
@msg `stops` must be an array `stops` phải là một mảng
|
||||
@msg `{key}` must be an array `{key}` phải là một mảng
|
||||
@msg `svg` must be an object of SVG options (see document.formats) `svg` phải là đối tượng tùy chọn SVG (xem document.formats)
|
||||
@msg `to` must be "corner" or "smooth" `to` phải là "corner" hoặc "smooth"
|
||||
@msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` phải là "emBox", "capHeight", "xHeight" hoặc "icfBox"
|
||||
@@ -5629,6 +5638,7 @@ theme Appearance Giao diện
|
||||
Ruby Ruby
|
||||
Geometric Guides Đường dẫn hình học
|
||||
Geometric Patterns Họa tiết hình học
|
||||
Grit Textures Kết cấu hạt
|
||||
Ease In Chậm lúc đầu
|
||||
Ease Out Chậm lúc cuối
|
||||
Ease In Out Chậm lúc đầu và cuối
|
||||
@@ -6119,6 +6129,10 @@ anchor Points: Điểm:
|
||||
@msg features lists at most {max} tags features chứa tối đa {max} thẻ
|
||||
@msg unknown OpenType feature `{t}` tính năng OpenType không xác định `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} phải là một số lớn hơn 0 và tối đa {max}
|
||||
@msg {label} must be at most {max} characters {label} chỉ được có tối đa {max} ký tự
|
||||
@msg {label} must be one character {label} phải là một ký tự
|
||||
@msg at most {max} tab stops tối đa {max} điểm dừng tab
|
||||
@msg unknown align `{other}` căn chỉnh không xác định `{other}`
|
||||
@msg no character style `{name}` Không tìm thấy kiểu ký tự `{name}`
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6281,6 +6295,8 @@ breakText Glyphs Glyph
|
||||
Delete the selected brushes? Xóa các cọ đã chọn?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Có nét đang dùng một hoặc nhiều cọ này. Gỡ cọ khỏi các nét đó và xóa cọ?
|
||||
Remove Strokes Gỡ nét
|
||||
Place Brush Art Đặt đối tượng của cọ
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` không có đối tượng (chỉ cọ phân tán, cọ bản vẽ và cọ họa tiết mới có)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools Công cụ sắp xếp
|
||||
Arrange in Grid… Sắp xếp theo lưới…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -1403,6 +1403,14 @@ file-filter Templates 模板
|
||||
Hair Space 极细空格
|
||||
Hand Tool 抓手工具
|
||||
Hand-Drawn 手绘
|
||||
Painted and Sketched 绘画与素描
|
||||
Washed Blue 水洗蓝
|
||||
Bristle Ochre 干刷土黄
|
||||
Pastel Heather 粉彩淡紫
|
||||
Charcoal Shade 炭笔阴影
|
||||
Ink Contour 墨线轮廓
|
||||
Wet Ink 湿墨
|
||||
Fine Pen 细笔
|
||||
Handles 手柄
|
||||
Hard Light 强光
|
||||
Hard Mix 实色混合
|
||||
@@ -4108,6 +4116,7 @@ imageTrace Outline 轮廓
|
||||
@msg `preserve` must be an object `preserve` 必须是对象
|
||||
@msg `globalAdjust` must be an object `globalAdjust` 必须是对象
|
||||
@msg `stops` must be an array `stops` 必须是数组
|
||||
@msg `{key}` must be an array `{key}` 必须是数组
|
||||
@msg `{k}` must be [x, y] `{k}` 必须是 [x, y]
|
||||
@msg a gradient needs at least two stops 渐变至少需要两个色标
|
||||
@msg give both `start` and `end` [x, y] (or neither) 请同时给出 `start` 和 `end` [x, y] (或都不给出)
|
||||
@@ -5862,6 +5871,7 @@ lighting Light 光源
|
||||
Ruby 注音
|
||||
Geometric Guides 几何参考线
|
||||
Geometric Patterns 几何图案
|
||||
Grit Textures 颗粒纹理
|
||||
Ease In 缓入
|
||||
Ease Out 缓出
|
||||
Ease In Out 缓入缓出
|
||||
@@ -6351,6 +6361,10 @@ anchor Points: 点:
|
||||
@msg features lists at most {max} tags features 最多包含 {max} 个标签
|
||||
@msg unknown OpenType feature `{t}` 未知的 OpenType 特性 `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} 必须是大于 0 且不超过 {max} 的数字
|
||||
@msg {label} must be at most {max} characters {label} 最多 {max} 个字符
|
||||
@msg {label} must be one character {label} 必须是一个字符
|
||||
@msg at most {max} tab stops 最多 {max} 个制表位
|
||||
@msg unknown align `{other}` 未知的对齐方式 `{other}`
|
||||
@msg no character style `{name}` 找不到字符样式 `{name}`
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6512,6 +6526,8 @@ breakText Glyphs 字形
|
||||
Delete the selected brushes? 删除选中的画笔吗?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有描边使用了其中一个或多个画笔。要从这些描边移除画笔并删除画笔吗?
|
||||
Remove Strokes 移除描边
|
||||
Place Brush Art 置入画笔图稿
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 没有图稿 (只有散布画笔、艺术画笔和图案画笔有图稿)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools 排列工具
|
||||
Arrange in Grid… 按网格排列…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1439,6 +1439,14 @@ file-filter Templates 範本
|
||||
Hair Space 極細空格
|
||||
Hand Tool 手形工具
|
||||
Hand-Drawn 手繪
|
||||
Painted and Sketched 繪畫與素描
|
||||
Washed Blue 水洗藍
|
||||
Bristle Ochre 乾刷土黃
|
||||
Pastel Heather 粉彩淡紫
|
||||
Charcoal Shade 炭筆陰影
|
||||
Ink Contour 墨線輪廓
|
||||
Wet Ink 濕墨
|
||||
Fine Pen 細筆
|
||||
Handles 控制把手
|
||||
Hard Light 實光
|
||||
Hard Mix 實色混合
|
||||
@@ -1902,6 +1910,7 @@ file-filter Templates 範本
|
||||
@msg `preserve` must be an object `preserve` 必須是物件
|
||||
@msg `globalAdjust` must be an object `globalAdjust` 必須是物件
|
||||
@msg `stops` must be an array `stops` 必須是陣列
|
||||
@msg `{key}` must be an array `{key}` 必須是陣列
|
||||
@msg `{k}` must be [x, y] `{k}` 必須是 [x, y]
|
||||
@msg a gradient needs at least two stops 漸層至少需要兩個色標
|
||||
@msg give both `start` and `end` [x, y] (or neither) 請同時提供 `start` 和 `end` [x, y] (或兩者都不提供)
|
||||
@@ -5666,6 +5675,7 @@ lighting Light 光源
|
||||
Ruby 注音
|
||||
Geometric Guides 幾何參考線
|
||||
Geometric Patterns 幾何圖樣
|
||||
Grit Textures 顆粒紋理
|
||||
Ease In 緩入
|
||||
Ease Out 緩出
|
||||
Ease In Out 緩入緩出
|
||||
@@ -6156,6 +6166,10 @@ anchor Points: 點:
|
||||
@msg features lists at most {max} tags features 最多包含 {max} 個標籤
|
||||
@msg unknown OpenType feature `{t}` 未知的 OpenType 功能 `{t}`
|
||||
@msg {label} must be a number above 0 and at most {max} {label} 必須是大於 0 且不超過 {max} 的數字
|
||||
@msg {label} must be at most {max} characters {label} 最多 {max} 個字元
|
||||
@msg {label} must be one character {label} 必須是一個字元
|
||||
@msg at most {max} tab stops 最多 {max} 個定位點
|
||||
@msg unknown align `{other}` 未知的對齊方式 `{other}`
|
||||
@msg no character style `{name}` 找不到字元樣式 `{name}`
|
||||
|
||||
# Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects).
|
||||
@@ -6318,6 +6332,8 @@ breakText Glyphs 字符
|
||||
Delete the selected brushes? 要刪除選取的筆刷嗎?
|
||||
Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有筆畫使用了其中一個或多個筆刷。要從這些筆畫移除筆刷並刪除筆刷嗎?
|
||||
Remove Strokes 移除筆畫
|
||||
Place Brush Art 置入筆刷圖稿
|
||||
@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 沒有圖稿 (只有散佈筆刷、藝術筆刷和圖樣筆刷有圖稿)
|
||||
# Arrange Tools add-on (Object › Plug-ins › Arrange Tools).
|
||||
Arrange Tools 排列工具
|
||||
Arrange in Grid… 依格狀排列…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -30,7 +30,7 @@ pub use open::{
|
||||
#[cfg(test)]
|
||||
pub(crate) use open::{open_file, opened_with_notes};
|
||||
pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf};
|
||||
pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to};
|
||||
pub(crate) use save::{export_path, is_web, notes_of, report_saved, show_with_notes, target_path, write_named, write_reporting, write_to};
|
||||
#[cfg(test)]
|
||||
pub(crate) use save::{report_write_error, saved_status};
|
||||
|
||||
@@ -90,7 +90,7 @@ mod tests {
|
||||
use vectorcraft_testkit::ai;
|
||||
let (mut app, _) = app();
|
||||
let damaged = b"%AI12_CompressedData not zlib";
|
||||
let notes = |r: &Value| -> Vec<String> { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() };
|
||||
let notes = notes_of;
|
||||
let pdf = ai::pdf(damaged, "0 g 150 20 100 60 re f");
|
||||
let r = open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap();
|
||||
let [page, layers] = ¬es(&r)[..] else { panic!("two notes: {r}") };
|
||||
@@ -125,6 +125,36 @@ mod tests {
|
||||
assert_eq!((app.ui.status.clone(), app.ui.status_notes.clone()), shown);
|
||||
}
|
||||
|
||||
/// A save and an export with several notes: the status bar names the first, counts them, and
|
||||
/// keeps every one of them, in the reply's order, for its hover. A save without notes keeps
|
||||
/// none.
|
||||
#[test]
|
||||
fn a_save_and_an_export_keep_every_note_for_the_hover() {
|
||||
let (mut app, _) = app();
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let r = app.run("file.save", json!({"path": "/out/a.svg", "compress": true})).unwrap();
|
||||
let notes = notes_of(&r);
|
||||
assert_eq!(notes.len(), 2, "{r}");
|
||||
assert!(notes[0].starts_with("SVG keeps the artwork but not everything"), "{r}");
|
||||
assert_eq!(app.ui.status, saved_status("/out/a.svg", &r));
|
||||
assert!(app.ui.status.contains("with 2 notes"), "{}", app.ui.status);
|
||||
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
|
||||
|
||||
app.run("file.save", json!({"path": "/out/b.vectorcraft"})).unwrap();
|
||||
assert_eq!((app.ui.status.as_str(), &app.ui.status_notes), ("Saved /out/b.vectorcraft", &None));
|
||||
|
||||
// A PDF export of a knockout group and of type in a font that isn't installed.
|
||||
app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
|
||||
app.run("object.group", json!({})).unwrap();
|
||||
app.run("transparency.set", json!({"knockout": "on"})).unwrap();
|
||||
let r = export(&mut app, Some("pdf"), Some("/out/c.pdf".into()), &json!({})).unwrap();
|
||||
let notes = notes_of(&r);
|
||||
assert_eq!(notes.len(), 2, "{r}");
|
||||
assert_eq!(app.ui.status, exported_with_notes("/out/c.pdf", notes.len(), ¬es[0]));
|
||||
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
|
||||
}
|
||||
|
||||
/// #1685: a save or an export that can't write its file says so in a dialog that names the
|
||||
/// file and the reason and stays until it is closed; the document stays modified and keeps
|
||||
/// the file it had. An agent's request only gets the error, and a message that arrives while
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::save::{
|
||||
ask_before_losing, ask_to_replace_files, checks_replaced, export_path, is_web, remember_active_artboard, replaced_files, report_saved,
|
||||
report_write_error, saved_status, target_path, target_path_keeping, view_file, with_path, write_encoded, write_named, write_reporting,
|
||||
ask_before_losing, ask_to_replace_files, checks_replaced, export_path, is_web, notes_of, remember_active_artboard, replaced_files, report_saved,
|
||||
report_write_error, saved_status, show_with_notes, target_path, target_path_keeping, view_file, with_path, write_encoded, write_named,
|
||||
write_reporting,
|
||||
};
|
||||
use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress};
|
||||
|
||||
@@ -107,11 +108,12 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
|
||||
Some(files) => format!("{} files", files.len()),
|
||||
None => r["path"].as_str().unwrap_or_default().to_string(),
|
||||
};
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
app.status(match warnings.first() {
|
||||
let warnings = notes_of(&r);
|
||||
let status = match warnings.first() {
|
||||
Some(first) => exported_with_notes(&what, warnings.len(), first),
|
||||
None => format!("Exported {what}"),
|
||||
});
|
||||
};
|
||||
show_with_notes(app, status, warnings);
|
||||
Ok(r)
|
||||
})?;
|
||||
if r.get("path").is_none() {
|
||||
@@ -128,7 +130,7 @@ pub(crate) fn exported_with_notes(what: &str, n: usize, first: &str) -> String {
|
||||
|
||||
/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path
|
||||
/// (Export Selection, Save for Office Documents, Text Export Options and the presets dialogs'
|
||||
/// Export…), reported with the first of the command's warnings ([`report_saved`]).
|
||||
/// Export…), reported with the command's warnings ([`report_saved`]).
|
||||
pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result<String, String> {
|
||||
let (path, v) = run_to_file(app, id, ext, params)?;
|
||||
report_saved(app, &path, &v);
|
||||
@@ -254,8 +256,9 @@ pub(crate) fn open_original(app: &mut VectorcraftApp, path: &str) -> Result<(),
|
||||
/// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`;
|
||||
/// without one the web downloads them (the ZIP, or each file) and the other frontends get them
|
||||
/// back as base64. With `openLocation`, the file manager shows the first file written. When an
|
||||
/// export into a folder or a download has warnings, the status bar shows the first as
|
||||
/// [`report_saved`] shows a save's notes: "Saved <folder, ZIP or first download> with n notes: …".
|
||||
/// export into a folder or a download has warnings, the status bar shows them as [`report_saved`]
|
||||
/// shows a save's notes: "Saved <folder, ZIP or first download> with n notes: …", and hovering the
|
||||
/// message lists up to 30 of them, fewer when the window is small, with how many were left out.
|
||||
pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result<Value, String> {
|
||||
export_files(app, "document.exportForScreens", params)
|
||||
}
|
||||
@@ -325,7 +328,7 @@ fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_locati
|
||||
{
|
||||
status = format!("{status} ({e})");
|
||||
}
|
||||
app.status(status);
|
||||
show_with_notes(app, status, notes_of(&r));
|
||||
} else if let Some(download) = app.services.download.as_mut() {
|
||||
// A browser download has no folders: a file from a sub-folder (`2x/Logo@2x.png`) is named alone.
|
||||
let returned: Vec<(&str, &str)> = returned.iter().map(|(name, data)| (name.rsplit('/').next().unwrap_or(name), *data)).collect();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use vectorcraft_engine::cmd::plugin::InstallApproval;
|
||||
use vectorcraft_plugins::permissions;
|
||||
|
||||
use super::save::suggested;
|
||||
use super::save::{notes_of, show_with_notes, suggested};
|
||||
use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json};
|
||||
|
||||
/// Template extensions New from Template's open dialog lists first.
|
||||
@@ -144,8 +144,8 @@ pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) {
|
||||
app.sync_views();
|
||||
let notes = open_notes(app, r);
|
||||
if let Some(first) = notes.first() {
|
||||
app.status(opened_with_notes(notes.len(), first));
|
||||
app.ui.status_notes = Some((app.ui.status.clone(), notes));
|
||||
let status = opened_with_notes(notes.len(), first);
|
||||
show_with_notes(app, status, notes);
|
||||
}
|
||||
crate::dialogs::missing_links::after_open(app, r);
|
||||
}
|
||||
@@ -153,7 +153,7 @@ pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) {
|
||||
/// The notes of the open `r`, those of what reading the file left out (its document's
|
||||
/// `import_losses`) first, each group in the order the import gave them.
|
||||
fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec<String> {
|
||||
let notes: Vec<String> = r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
|
||||
let notes = notes_of(r);
|
||||
let index = r["index"].as_u64().and_then(|i| usize::try_from(i).ok());
|
||||
let losses = index.and_then(|i| app.session.documents().get(i)).map(|st| st.import_losses.as_slice()).unwrap_or_default();
|
||||
let (mut first, rest): (Vec<String>, Vec<String>) = notes.into_iter().partition(|n| losses.contains(n));
|
||||
|
||||
@@ -429,20 +429,35 @@ pub(super) fn view_file(app: &mut VectorcraftApp, path: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Saved <path>", with the first of the result's warnings.
|
||||
/// "Saved <path>", with the first of the result's warnings and how many there are; hovering the
|
||||
/// message lists up to 30 of them, fewer when the window is small, with how many were left out
|
||||
/// ([`show_with_notes`]).
|
||||
pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) {
|
||||
app.status(saved_status(path, r));
|
||||
show_with_notes(app, saved_status(path, r), notes_of(r));
|
||||
}
|
||||
|
||||
/// The warnings of a reply `r`, in the reply's order (a save's notes start with what the format
|
||||
/// loses).
|
||||
pub(crate) fn notes_of(r: &Value) -> Vec<String> {
|
||||
r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// Show `status`, which names the first of `notes`, in the status bar and keep `notes` for its
|
||||
/// hover ([`crate::state::UiState::status_notes`]). Without notes nothing is kept.
|
||||
pub(crate) fn show_with_notes(app: &mut VectorcraftApp, status: String, notes: Vec<String>) {
|
||||
app.status(status);
|
||||
app.ui.status_notes = (!notes.is_empty()).then(|| (app.ui.status.clone(), notes));
|
||||
}
|
||||
|
||||
/// [`report_saved`]'s message.
|
||||
pub(crate) fn saved_status(path: &str, r: &Value) -> String {
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
let warnings = notes_of(r);
|
||||
match warnings.first() {
|
||||
Some(first) => crate::i18n::msg_n(
|
||||
warnings.len() as u64,
|
||||
"Saved {path} with {n} note: {first}",
|
||||
"Saved {path} with {n} notes: {first}",
|
||||
&[("path", path), ("first", first)],
|
||||
&[("path", path), ("first", first.as_str())],
|
||||
),
|
||||
None => format!("Saved {path}"),
|
||||
}
|
||||
|
||||
@@ -155,10 +155,20 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
"file.openWith" => io::open_with(app, p),
|
||||
// Saves write through the app (save panel, download); with no path known the panel and the
|
||||
// format's options dialog ask first.
|
||||
"file.save" => io::save(app, SaveMode::Save, p, true),
|
||||
"file.saveAs" => io::save(app, SaveMode::SaveAs, p, true),
|
||||
"file.saveCopy" => io::save(app, SaveMode::Copy, p, true),
|
||||
"file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true),
|
||||
// A confined request sees the document's own file, and the folder a save suggests, only
|
||||
// inside its read roots.
|
||||
"file.save" | "file.saveAs" | "file.saveCopy" | "file.saveAsTemplate" => {
|
||||
let mode = match id {
|
||||
"file.save" => SaveMode::Save,
|
||||
"file.saveAs" => SaveMode::SaveAs,
|
||||
"file.saveCopy" => SaveMode::Copy,
|
||||
_ => SaveMode::Template,
|
||||
};
|
||||
io::save(app, mode, p, true).map(|mut r| {
|
||||
vectorcraft_engine::cmd::fileio::leave_out_unreadable_save_paths(&mut r, p.get("path").and_then(Value::as_str));
|
||||
r
|
||||
})
|
||||
}
|
||||
"document.exportSelection" => {
|
||||
io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p}))
|
||||
}
|
||||
|
||||
@@ -1493,7 +1493,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"css.exportFile",
|
||||
"Export CSS…",
|
||||
"",
|
||||
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}",
|
||||
"{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}",
|
||||
),
|
||||
(
|
||||
"file.print",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! applies it to the selected paths (and makes it the Paintbrush's current brush); double-clicking
|
||||
//! one opens its Brush Options. Shift-click selects a range of brushes, Cmd/Ctrl-click adds or
|
||||
//! removes one, and the panel menu's Select All Unused selects the brushes no stroke paints with,
|
||||
//! for Delete Brush and Duplicate Brush. Brush libraries open in library panels
|
||||
//! for Delete Brush and Duplicate Brush. A scatter, art or pattern brush dragged onto an empty part
|
||||
//! of the canvas places a copy of its art there. Brush libraries open in library panels
|
||||
//! ([`BrushLibraries`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
@@ -86,7 +86,7 @@ fn shown(app: &mut VectorcraftApp, ui: &Ui, opts: &CssOptions, all: bool) -> Opt
|
||||
let (id, scope) = if all { ("css.generate", "all") } else { ("css.selection", "selection") };
|
||||
let out = app.session.execute(id, ¶ms(opts, scope)).unwrap_or_default();
|
||||
let rules: Vec<&str> = out["rules"].as_array().into_iter().flatten().filter_map(|r| r["css"].as_str()).collect();
|
||||
let warnings = out["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
|
||||
let warnings = io::notes_of(&out);
|
||||
let job = (!rules.is_empty())
|
||||
.then(|| Arc::new(highlight(rules.get(..MAX_SHOWN).unwrap_or(&rules), &t, egui::TextStyle::Monospace.resolve(ui.style()))));
|
||||
let skipped = out["skipped"].as_u64().unwrap_or(0);
|
||||
@@ -204,13 +204,14 @@ pub(crate) fn export_file(app: &mut VectorcraftApp, p: &Value) -> Result<Value,
|
||||
io::write_to(&mut app.services, &at, &bytes)?;
|
||||
images.push(at);
|
||||
}
|
||||
// The status bar shows the first warning in Export As's message.
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
||||
// The status bar shows the warnings as Export As shows them.
|
||||
let warnings = io::notes_of(&r);
|
||||
let what = &path;
|
||||
app.status(match warnings.first() {
|
||||
let status = match warnings.first() {
|
||||
Some(first) => io::exported_with_notes(what, warnings.len(), first),
|
||||
None => format!("Exported {path}"),
|
||||
});
|
||||
};
|
||||
io::show_with_notes(app, status, warnings);
|
||||
Ok(json!({ "path": path, "rules": r["rules"], "images": images, "warnings": r["warnings"] }))
|
||||
}
|
||||
|
||||
|
||||
@@ -155,8 +155,9 @@ fn find_filters_list_view_and_arrows_step_through_the_libraries() {
|
||||
assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none());
|
||||
let first = SWATCH_IDS[0];
|
||||
let last = SwatchLibraries::list(&app).pop().unwrap().id;
|
||||
assert_eq!(last, "geometric-patterns", "gradient and then pattern libraries come after the swatch libraries");
|
||||
assert_eq!(last, "grit-textures", "gradient and then pattern libraries come after the swatch libraries");
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, "radial-glows", 1).as_deref(), Some("geometric-patterns"));
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, "geometric-patterns", 1).as_deref(), Some("grit-textures"));
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, first, -1), Some(last), "wraps around");
|
||||
assert_eq!(neighbour::<SwatchLibraries>(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1]));
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const ALIGNS: [(TabAlign, &str); 4] = [(TabAlign::Left, "⇥"), (TabAlign::Cente
|
||||
/// The name of alignment `a`'s button: its tool tip and what screen readers call it. A line of
|
||||
/// vertical type runs from top to bottom, so in vertical type a Left stop is named Top-Justified
|
||||
/// and a Right stop is named Bottom-Justified.
|
||||
fn align_name(a: TabAlign, vertical: bool) -> &'static str {
|
||||
pub(crate) fn align_name(a: TabAlign, vertical: bool) -> &'static str {
|
||||
match (a, vertical) {
|
||||
(TabAlign::Left, false) => tl!("Left-Justified Tab"),
|
||||
(TabAlign::Left, true) => tl!("Top-Justified Tab"),
|
||||
|
||||
@@ -146,3 +146,23 @@ fn export_says_in_the_status_bar_when_an_image_could_not_be_decoded() {
|
||||
assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 1 note: {WARNING}"));
|
||||
assert_eq!(log.borrow().len(), 3, "the sheet and the image's picture are still written");
|
||||
}
|
||||
|
||||
/// A CSS export with several notes: the status bar names the first, counts them, and keeps every
|
||||
/// one of them for its hover.
|
||||
#[test]
|
||||
fn export_keeps_every_note_for_the_hover() {
|
||||
let (mut app, _) = app();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({}));
|
||||
// Type painted with a gradient is rasterized, and its picture is drawn in the fallback font.
|
||||
app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
app.run("paint.setFill", json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})).unwrap();
|
||||
let r = app.run("css.exportFile", json!({"path": "/tmp/site/site.css", "scope": "all", "rasterize": true})).unwrap();
|
||||
let notes = crate::io::notes_of(&r);
|
||||
assert_eq!(notes.len(), 2, "{r}");
|
||||
assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 2 notes: {}", notes[0]));
|
||||
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
|
||||
}
|
||||
|
||||
@@ -101,6 +101,30 @@ fn clicking_a_library_style_adds_and_applies_it_in_one_step() {
|
||||
assert!(last_open(&app.ui).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painted_and_sketched_opens_from_the_window_menu_and_a_click_applies_its_style() {
|
||||
let mut app = app();
|
||||
let entry = crate::menus::menu_entries(&app)
|
||||
.into_iter()
|
||||
.find(|e| e.path == ["Window", "Graphic Style Libraries"] && e.label == "Painted and Sketched")
|
||||
.expect("a Window menu item");
|
||||
assert_eq!((entry.command.as_deref(), &entry.params["library"]), (Some("window.graphicStyleLibrary"), &json!("painted-sketched")));
|
||||
let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run(entry.command.as_deref().unwrap(), entry.params.clone()).unwrap();
|
||||
assert_eq!(last_open(&app.ui).map(|p| (p.kind.as_str(), p.id.as_str())), Some(("graphicStyles", "painted-sketched")));
|
||||
let ctx = context();
|
||||
let undo = undo_len(&app);
|
||||
click(&mut app, &ctx, "Charcoal Shade", Modifiers::NONE, 0.0);
|
||||
let (g, linked) = app.session.selection_graphic_style().expect("applied to the selection");
|
||||
assert_eq!((g.name.as_str(), linked), ("Charcoal Shade", true));
|
||||
assert_eq!(undo_len(&app), undo + 1, "added and applied as one undo step");
|
||||
let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().appearance.effects.clone();
|
||||
assert_eq!(node.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["sketch.charcoal"]);
|
||||
let lib = GraphicStyleLibraries::get(&app, "painted-sketched").unwrap().1;
|
||||
let rows = GraphicStyleLibraries::rows(&lib, "ink");
|
||||
assert_eq!(rows.iter().map(|r| r.name).collect::<Vec<_>>(), ["Ink Contour", "Wet Ink"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_menu_and_the_panel_open_every_library() {
|
||||
let mut app = app();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user