mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 09:20:56 -06:00
Merge main into fix/fitted-gradient-turns-with-transform
This commit is contained in:
@@ -1004,7 +1004,6 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace);
|
||||
drop(replace);
|
||||
Ok(runs)
|
||||
})?;
|
||||
Ok(json!({ "runs": runs, "font": family }))
|
||||
|
||||
@@ -930,7 +930,10 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> i
|
||||
/// The direct-selection flag and resolved root nodes for Copy and Cut operations.
|
||||
fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
|
||||
let st = s.doc()?;
|
||||
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature");
|
||||
// The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes
|
||||
// the whole path then (#2122), unless a selection tool Cmd lent them picked the anchors (#6123).
|
||||
let picked = s.lent_selection.as_ref().is_some_and(|(uid, sel)| *uid == st.uid && *sel == st.selection);
|
||||
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked;
|
||||
let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty());
|
||||
let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })));
|
||||
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| {
|
||||
|
||||
@@ -129,7 +129,9 @@ pub(crate) use access::{drive_share_path, share_path, touched};
|
||||
pub(crate) use access::{may_read, may_write};
|
||||
pub use commands::specs;
|
||||
pub use export_names::artboard_file_names;
|
||||
pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return};
|
||||
pub(crate) use export_names::{
|
||||
artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return,
|
||||
};
|
||||
use export_names::{distinct_names_by, write_encoded};
|
||||
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
|
||||
pub use formats::{
|
||||
|
||||
@@ -5,19 +5,46 @@ use super::{Encoded, EngineError, Result, Session, Value, json};
|
||||
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
|
||||
/// case: `Icon` and `icon` are one file on most desktop file systems).
|
||||
pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
|
||||
unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))))
|
||||
unique_file_names(artboard_names(doc, boards))
|
||||
}
|
||||
|
||||
/// [`artboard_file_names`] with the dots of a name kept (`example.artboard`), for Export for Screens,
|
||||
/// which puts every stem through `safe_file_name` after this (trailing periods, device names).
|
||||
pub(crate) fn artboard_file_names_keeping_dots(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
|
||||
unique_file_names_keeping_dots(artboard_names(doc, boards))
|
||||
}
|
||||
|
||||
/// Each of `boards`' name, with the file name to use when it has none.
|
||||
fn artboard_names<'a>(doc: &'a vectorcraft_doc::Document, boards: &'a [usize]) -> impl Iterator<Item = (&'a str, String)> {
|
||||
boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))
|
||||
}
|
||||
|
||||
/// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe
|
||||
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
|
||||
/// (compared without case).
|
||||
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
distinct_names(names.into_iter().map(|(name, fallback)| {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() { fallback } else { base }
|
||||
}))
|
||||
}
|
||||
|
||||
/// [`unique_file_names`] keeping `.` in a name (an artboard called `example.artboard` exports as
|
||||
/// `example.artboard.png`, #6143). A name of only dots (`.` and `..` name other folders) takes the
|
||||
/// fallback, and leading dots are dashes (`.draft` would be a hidden file); the caller still runs
|
||||
/// each stem through `safe_file_name`, which drops trailing periods and guards Windows device names.
|
||||
pub(crate) fn unique_file_names_keeping_dots<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
distinct_names(names.into_iter().map(|(name, fallback)| {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }).collect();
|
||||
if base.trim_matches('.').is_empty() {
|
||||
return fallback;
|
||||
}
|
||||
// A leading dot would hide the file or folder on Unix-like systems: it is a dash, as before.
|
||||
let rest = base.trim_start_matches('.');
|
||||
format!("{}{rest}", "-".repeat(base.len() - rest.len()))
|
||||
}))
|
||||
}
|
||||
|
||||
/// `names` with `-2`, `-3`… after a name already used (compared without case).
|
||||
pub(super) fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
distinct_names_by(names, str::to_lowercase)
|
||||
|
||||
@@ -21,8 +21,8 @@ use super::encode::single_artboard;
|
||||
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names_by, encode_with_warnings, merge, unique_file_names,
|
||||
with_black_output, writable, write_file,
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge,
|
||||
unique_file_names_keeping_dots, with_black_output, writable, write_file,
|
||||
};
|
||||
|
||||
const C: &str = "document.exportForScreens";
|
||||
@@ -1184,7 +1184,7 @@ fn items(
|
||||
// the item folders are (`fold`).
|
||||
let safe = |stems: Vec<String>| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold);
|
||||
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
|
||||
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
|
||||
let stems = safe(unique_file_names_keeping_dots(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
|
||||
pieces
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
@@ -1261,7 +1261,7 @@ fn items(
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let stems = safe(artboard_file_names(doc, &boards));
|
||||
let stems = safe(artboard_file_names_keeping_dots(doc, &boards));
|
||||
Ok(boards
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
|
||||
@@ -148,6 +148,19 @@ fn device_names_and_trailing_periods_follow_windows_naming_rules() {
|
||||
assert_eq!(r["name"], "nul-.zip");
|
||||
}
|
||||
|
||||
/// A dot in an artboard's name stays a dot (`example.artboard.png`, #6143); a name of only dots, or
|
||||
/// ending in one, still can't name another folder or end in a period, a leading dot is a dash (no
|
||||
/// hidden `.draft.png`), and `/` and `:` are dashes.
|
||||
#[test]
|
||||
fn an_artboard_name_keeps_its_dots() {
|
||||
let mut s = session(7);
|
||||
for (index, name) in ["example.artboard", "v1.2.final", "..", "a/b:c", "trail.", ".draft", "..x.y"].iter().enumerate() {
|
||||
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
|
||||
}
|
||||
let r = export(&mut s, json!({"formats": [{"format": "png"}]}));
|
||||
assert_eq!(names(&r), ["example.artboard.png", "v1.2.final.png", "Artboard-3.png", "a-b-c.png", "trail.png", "-draft.png", "--x.y.png"]);
|
||||
}
|
||||
|
||||
/// `first_file` names the file the export writes first, for the same params: the item's file name,
|
||||
/// a prefix and a suffix with characters Windows does not allow, a range, a Windows device name,
|
||||
/// every kind of folder, presets, size suffixes, a name pattern with its counter and date, an
|
||||
|
||||
@@ -10,8 +10,9 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::select::on_artboard;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, str_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -38,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Text by Font",
|
||||
[],
|
||||
None,
|
||||
"{family, style?} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard → {count}",
|
||||
"{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}",
|
||||
has_doc,
|
||||
select_font
|
||||
),
|
||||
@@ -689,7 +690,6 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && changes(t), &mut replace_in);
|
||||
drop(replace_in);
|
||||
Ok(n)
|
||||
})?
|
||||
};
|
||||
@@ -714,6 +714,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let style = str_param(p, "style").map(str::to_string);
|
||||
let ids = scope(s, false, None)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let within = FontScope::read(d, p)?;
|
||||
// Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group
|
||||
// or layer, or on a locked artboard.
|
||||
let editable = d.editable();
|
||||
@@ -759,11 +760,65 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
drop(editable);
|
||||
let symbols = d.symbol_boxes();
|
||||
hits.retain(|id| within.holds(d, &symbols, *id));
|
||||
drop(symbols);
|
||||
let n = hits.len();
|
||||
s.select(|_, sel| sel.set(hits))?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
/// Where Select Text by Font looks: its `scope`.
|
||||
enum FontScope {
|
||||
Document,
|
||||
/// The artboards' rectangles.
|
||||
Artboards(Vec<Rect>),
|
||||
/// The layers and sublayers, with what they hold.
|
||||
Layers(BTreeSet<NodeId>),
|
||||
}
|
||||
|
||||
impl FontScope {
|
||||
fn read(d: &Document, p: &Value) -> Result<Self> {
|
||||
const C: &str = "select.font";
|
||||
let expected = || bad(C, "`scope` must be \"document\", {artboards: [index, …]} or {layers: [id, …]}");
|
||||
let scope = match p.get("scope") {
|
||||
None | Some(Value::Null) => return Ok(Self::Document),
|
||||
Some(Value::String(v)) if v == "document" => return Ok(Self::Document),
|
||||
Some(Value::Object(m)) if m.len() == 1 => m,
|
||||
Some(_) => return Err(expected()),
|
||||
};
|
||||
let list = |key: &str| -> Result<Option<&Vec<Value>>> {
|
||||
match scope.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Array(a)) if !a.is_empty() => Ok(Some(a)),
|
||||
Some(_) => Err(bad(C, format!("`scope.{key}` must be a non-empty array"))),
|
||||
}
|
||||
};
|
||||
if let Some(a) = list("artboards")? {
|
||||
let rect = |v: &Value| v.as_u64().and_then(|i| d.artboards.get(index(i))).map(|b| b.rect);
|
||||
return a.iter().map(|v| rect(v).ok_or_else(|| bad(C, format!("no such artboard {v}")))).collect::<Result<_>>().map(Self::Artboards);
|
||||
}
|
||||
if let Some(a) = list("layers")? {
|
||||
let layer = |v: &Value| v.as_u64().map(NodeId).filter(|id| d.node(*id).is_some_and(Node::is_layer));
|
||||
return a
|
||||
.iter()
|
||||
.map(|v| layer(v).ok_or_else(|| bad(C, format!("{v} names no layer or sublayer"))))
|
||||
.collect::<Result<_>>()
|
||||
.map(Self::Layers);
|
||||
}
|
||||
Err(expected())
|
||||
}
|
||||
|
||||
/// Is object `id` in the scope.
|
||||
fn holds(&self, d: &Document, symbols: &vectorcraft_doc::SymbolBoxes, id: NodeId) -> bool {
|
||||
match self {
|
||||
Self::Document => true,
|
||||
Self::Artboards(rects) => d.node(id).is_some_and(|n| rects.iter().any(|r| on_artboard(n, symbols, *r))),
|
||||
Self::Layers(layers) => d.ancestry(id).is_some_and(|a| a.iter().any(|i| layers.contains(i))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
match str_param(p, "family") {
|
||||
@@ -912,6 +967,95 @@ mod tests {
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty());
|
||||
}
|
||||
|
||||
/// Select Text by Font with an artboard scope takes the text on the artboards, as All on
|
||||
/// Active Artboard does: overlapping one, or centered on it, a straddling one included.
|
||||
#[test]
|
||||
fn select_font_in_artboards() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 600, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
let text = |s: &mut Session, x: i32| {
|
||||
NodeId(s.execute("text.create", &json!({"x": x, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let first = text(&mut s, 10);
|
||||
let straddling = text(&mut s, 180);
|
||||
let second = text(&mut s, 350);
|
||||
let third = text(&mut s, 650);
|
||||
let _off = text(&mut s, 1000);
|
||||
let select = |s: &mut Session, scope: Value| {
|
||||
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": scope})).unwrap();
|
||||
s.doc().unwrap().selection.objects.clone()
|
||||
};
|
||||
assert_eq!(select(&mut s, json!({"artboards": [0]})), [first, straddling]);
|
||||
assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second, third]);
|
||||
assert_eq!(select(&mut s, json!("document")).len(), 5);
|
||||
// The same objects All on Active Artboard takes.
|
||||
s.execute("select.allOnArtboard", &json!({"artboard": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [first, straddling]);
|
||||
// Hidden text stays out.
|
||||
s.execute("select.set", &json!({"ids": [third.0]})).unwrap();
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second]);
|
||||
for bad in [json!({"artboards": [3]}), json!({"artboards": []}), json!({"artboards": ["x"]}), json!({"pages": [0]}), json!("all"), json!(7)] {
|
||||
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": bad})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Select Text by Font with a layer scope takes the text in the layers, their sublayers
|
||||
/// included, and still leaves out hidden and locked text.
|
||||
#[test]
|
||||
fn select_font_in_layers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let text = |s: &mut Session, y: i32| {
|
||||
NodeId(s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let first_layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let a = text(&mut s, 20);
|
||||
let second_layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = text(&mut s, 40);
|
||||
let sub = s.execute("layer.newSublayer", &json!({"parent": second_layer})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
|
||||
let in_sub = text(&mut s, 60);
|
||||
let locked = text(&mut s, 80);
|
||||
s.execute("select.set", &json!({"ids": [locked.0]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
let select = |s: &mut Session, layers: Value| {
|
||||
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": layers}})).unwrap();
|
||||
let mut v = s.doc().unwrap().selection.objects.clone();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.ancestry(in_sub).unwrap()[1], NodeId(sub), "the text is in the sublayer");
|
||||
assert_eq!(select(&mut s, json!([first_layer.0])), [a]);
|
||||
assert_eq!(select(&mut s, json!([second_layer])), [b, in_sub]);
|
||||
assert_eq!(select(&mut s, json!([sub])), [in_sub]);
|
||||
assert_eq!(select(&mut s, json!([first_layer.0, sub])), [a, in_sub]);
|
||||
// Not a layer: an error, the selection as it was.
|
||||
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [a.0]}})).is_err());
|
||||
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [u64::MAX]}})).is_err());
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
|
||||
}
|
||||
|
||||
/// A symbol instance whose art uses the font is selected when the instance is in scope.
|
||||
#[test]
|
||||
fn select_font_scope_takes_symbol_instances_in_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].clone();
|
||||
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id]})).unwrap();
|
||||
let instance = s.doc().unwrap().selection.objects[0];
|
||||
let select = |s: &mut Session, i: u64| {
|
||||
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": [i]}})).unwrap()["count"].clone()
|
||||
};
|
||||
assert_eq!(select(&mut s, 0), 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [instance]);
|
||||
assert_eq!(select(&mut s, 1), 0);
|
||||
}
|
||||
|
||||
/// Replace Font leaves hidden and locked text in its font, and text inside a hidden or locked
|
||||
/// group or layer, and says so. Text on a visible, unlocked template layer is changed.
|
||||
#[test]
|
||||
|
||||
@@ -96,7 +96,9 @@ pub(crate) fn import(d: &mut Document, lib: &Document, name: &str) -> String {
|
||||
pub(crate) fn existing_pattern(d: &Document, lib: &Document, name: &str) -> Option<String> {
|
||||
let def = lib.pattern(name)?;
|
||||
let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() };
|
||||
let copy = |n: &str| n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| r.parse::<u32>().is_ok());
|
||||
let copy = |n: &str| {
|
||||
n == name || n.strip_prefix(name).and_then(|r| r.strip_prefix(' ')).is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()))
|
||||
};
|
||||
let mut found: Vec<&PatternDef> = d.patterns.iter().filter(|p| copy(&p.name) && bare(p) == bare(def) && p.art == def.art).collect();
|
||||
found.sort_by_key(|p| p.name != name);
|
||||
found.first().map(|p| p.name.clone())
|
||||
@@ -150,3 +152,19 @@ fn geometric_patterns(d: &mut Document) {
|
||||
add(d, "Crosses", 10.0, 10.0, TileType::Grid, vec![rect(4.0, 1.5, 6.0, 8.5), rect(1.5, 4.0, 8.5, 6.0)]);
|
||||
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)])]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plus_signed_suffix_is_not_a_copy_number() {
|
||||
let mut lib = Document::new(100.0, 100.0);
|
||||
lib.patterns.push(PatternDef::new("Dots", vec![]));
|
||||
for (other, expected) in [("Dots +2", None), ("Dots -2", None), ("Dots 2a", None), ("Dots ", None), ("Dots 2", Some("Dots 2"))] {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.patterns.push(PatternDef::new(other, vec![]));
|
||||
assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +611,9 @@ pub(crate) fn alt_code_char(code: u64) -> Option<char> {
|
||||
fn unicode_char(u: &str) -> Option<char> {
|
||||
let u = u.trim();
|
||||
let hex = u.strip_prefix("U+").or_else(|| u.strip_prefix("u+")).or_else(|| u.strip_prefix("0x")).unwrap_or(u);
|
||||
if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
char::from_u32(u32::from_str_radix(hex, 16).ok()?).filter(|c| !c.is_control() || *c == '\t')
|
||||
}
|
||||
|
||||
@@ -843,3 +846,18 @@ fn paste_plain(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::unicode_char;
|
||||
|
||||
#[test]
|
||||
fn unicode_char_rejects_sign_in_hex_digits() {
|
||||
assert_eq!(unicode_char("+B0"), None);
|
||||
assert_eq!(unicode_char("U++B0"), None);
|
||||
assert_eq!(unicode_char("U+"), None);
|
||||
assert_eq!(unicode_char("00B0"), Some('°'));
|
||||
assert_eq!(unicode_char("U+00B0"), Some('°'));
|
||||
assert_eq!(unicode_char("0x00B0"), Some('°'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,14 @@ pub struct Session {
|
||||
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
|
||||
/// back as it was at the release ([`Session::pointer`]).
|
||||
pub(crate) lender: Option<Box<dyn Tool>>,
|
||||
/// The selection of the active document (its uid) as Cmd lent `tool`: what the lent tool's
|
||||
/// gesture found, so that one that leaves it as it was picks nothing.
|
||||
pub(crate) selection_at_lend: Option<(u64, vectorcraft_doc::Selection)>,
|
||||
/// The selection a lent tool's gesture changed and left as it gave `lender` back, in the
|
||||
/// document (its uid) it was made in: while it is still that document's selection, the anchors
|
||||
/// in it are ones picked, not the Pen's last anchor, and Copy and Cut take them with the Pen or
|
||||
/// Curvature tool back too (#6123).
|
||||
pub(crate) lent_selection: Option<(u64, vectorcraft_doc::Selection)>,
|
||||
/// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd
|
||||
/// lends the other tools. None until one is chosen.
|
||||
pub(crate) last_selection_tool: Option<&'static str>,
|
||||
@@ -249,6 +257,9 @@ pub struct Session {
|
||||
/// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not
|
||||
/// saved.
|
||||
pub global_edit_options: cmd::globaledit::Options,
|
||||
/// Set while [`Session::tool_typed_text`] passes keyboard input to the active tool
|
||||
/// ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]).
|
||||
keyboard_text: bool,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
||||
@@ -42,6 +42,8 @@ impl Session {
|
||||
journal: Journal::default(),
|
||||
tool: vectorcraft_tools::create("selection"),
|
||||
lender: None,
|
||||
selection_at_lend: None,
|
||||
lent_selection: None,
|
||||
last_selection_tool: None,
|
||||
last_view: ViewInfo::default(),
|
||||
preview_memo: Default::default(),
|
||||
@@ -110,6 +112,7 @@ impl Session {
|
||||
link_stamps: Default::default(),
|
||||
link_scan: None,
|
||||
global_edit_options: Default::default(),
|
||||
keyboard_text: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,16 +28,28 @@ fn create_text(s: &mut Session, t: &str) -> NodeId {
|
||||
NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// Click with the Type tool at (x, y) and type `keys` one at a time; returns the new text object.
|
||||
/// Click with the Type tool at (x, y) and type `keys` one at a time on the keyboard; returns the
|
||||
/// new text object.
|
||||
fn type_at(s: &mut Session, x: f64, y: f64, keys: &[&str]) -> NodeId {
|
||||
enter_at(s, x, y, keys, true)
|
||||
}
|
||||
|
||||
/// Click with the Type tool at (x, y) and enter `texts` one at a time, typed on the keyboard
|
||||
/// ([`Session::tool_typed_text`]) or inserted as the Glyphs panel and pastes do
|
||||
/// ([`Session::tool_text`]); returns the new text object.
|
||||
fn enter_at(s: &mut Session, x: f64, y: f64, texts: &[&str], keyboard: bool) -> NodeId {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
for k in keys {
|
||||
s.tool_text(k, v).unwrap();
|
||||
for t in texts {
|
||||
if keyboard {
|
||||
s.tool_typed_text(t, v).unwrap();
|
||||
} else {
|
||||
s.tool_text(t, v).unwrap();
|
||||
}
|
||||
}
|
||||
s.tool_key(ToolKey::Escape, Default::default(), v).unwrap();
|
||||
id
|
||||
@@ -177,6 +189,53 @@ fn typographers_quotes_setting_changes_typed_quotes() {
|
||||
assert_eq!(text(&s, id).plain_text(), "\"b'");
|
||||
}
|
||||
|
||||
/// A lone typed `”` is set like a straight quote where a quote opens: the macOS Serbian Cyrillic
|
||||
/// layout has no straight quote and types `”` on Shift+2 (#5247), so Serbian text still gets
|
||||
/// „…”. Where a quote closes it stays `”`. `“ „` and the single quotes stay as typed, so `’90s`
|
||||
/// keeps its apostrophe. Pasted text and the Glyphs panel keep their quotes, and with the option
|
||||
/// off `”` stays as typed.
|
||||
#[test]
|
||||
fn a_typed_closing_double_quote_opens_where_a_quote_opens() {
|
||||
let mut s = session();
|
||||
s.execute("document.setup", &json!({"language": "Serbian: Cyrillic"})).unwrap();
|
||||
let id = type_at(&mut s, 50.0, 50.0, &["”", "П", "р", "о", "б", "а", "”"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "„Проба”");
|
||||
// The other curly quotes stay as typed.
|
||||
let id = type_at(&mut s, 50.0, 100.0, &["„", "a", " ", "’", "b", "’", "“"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "„a ’b’“");
|
||||
// In English a `”` that opens is `“`; the apostrophes stay.
|
||||
s.execute("document.setup", &json!({"language": "English: USA"})).unwrap();
|
||||
let id = type_at(&mut s, 50.0, 150.0, &["”", "i", "t", "’", "s", "”"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "“it’s”");
|
||||
// An apostrophe after a space stays an apostrophe, and a typed `‘` stays `‘`.
|
||||
let id = type_at(&mut s, 50.0, 200.0, &["t", "h", "e", " ", "’", "9", "0", "s", " ", "‘", "a"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "the ’90s ‘a");
|
||||
// Where a quote closes, `”` stays `”` even if the document's closing quote is another.
|
||||
s.execute("document.setup", &json!({"language": "German"})).unwrap();
|
||||
let id = type_at(&mut s, 50.0, 250.0, &["”", "a", "”"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "„a”");
|
||||
// More than one character is pasted text, not a typed quote.
|
||||
let id = type_at(&mut s, 50.0, 300.0, &["”a”"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "”a”");
|
||||
// An IME commit of a lone `”` that will open is not verbatim (it is rewritten); from the Glyphs
|
||||
// panel it would be inserted as it is.
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, 50.0, 312.0), v).unwrap();
|
||||
}
|
||||
assert!(!s.tool_typed_text_is_verbatim("”", v));
|
||||
assert!(s.tool_text_is_verbatim("”", v));
|
||||
s.tool_key(ToolKey::Escape, Default::default(), v).unwrap();
|
||||
// A `”` inserted from the Glyphs panel or pasted alone stays as inserted.
|
||||
let id = enter_at(&mut s, 50.0, 325.0, &["”", "a"], false);
|
||||
assert_eq!(text(&s, id).plain_text(), "”a");
|
||||
// With the option off a typed `”` stays as typed.
|
||||
s.execute("document.setup", &json!({"typographersQuotes": false})).unwrap();
|
||||
let id = type_at(&mut s, 50.0, 350.0, &["”", "b", "”"]);
|
||||
assert_eq!(text(&s, id).plain_text(), "”b”");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_punctuation_uses_the_document_quotes() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -2673,6 +2673,87 @@ fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() {
|
||||
assert_eq!(s.tool_id(), "rectangle");
|
||||
}
|
||||
|
||||
/// #6123: anchors that Cmd (Ctrl) held with the Pen selects with the Direct Selection it lends are
|
||||
/// what Copy and Cut take, as with Direct Selection chosen; the last anchor the Pen leaves selected
|
||||
/// while drawing is not, Copy taking the whole path (#2122), nor after a Cmd gesture that picks
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() {
|
||||
let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() });
|
||||
let v = view();
|
||||
let mut s = session();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
for x in [100.0, 200.0, 300.0, 400.0] {
|
||||
gesture(&mut s, &[(x, 300.0)], none);
|
||||
}
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
let pasted = |s: &mut Session| {
|
||||
let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone();
|
||||
let ids: Vec<NodeId> = ids.as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect();
|
||||
ids.iter().map(|i| path(s, *i).subpaths[0].anchors.len()).collect::<Vec<_>>()
|
||||
};
|
||||
// A Cmd marquee around the middle two anchors: Copy takes them and the segment between.
|
||||
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
|
||||
assert_eq!(s.tool_id(), "pen");
|
||||
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into());
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(pasted(&mut s), [2]);
|
||||
// Cut takes the segment between them out of the path, as with Direct Selection chosen.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.cut", &json!({})).unwrap();
|
||||
let left = path(&s, id).subpaths.iter().map(|sp| sp.anchors.len()).collect::<Vec<_>>();
|
||||
assert_eq!((s.clipboard.nodes.len(), left), (1, vec![2, 2]));
|
||||
// Drawing on: the last anchor the Pen leaves selected doesn't make Copy take that anchor.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
gesture(&mut s, &[(500.0, 300.0)], none);
|
||||
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into());
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(pasted(&mut s), [5]);
|
||||
// A Cmd+Shift marquee over nothing picks nothing: the Pen's last anchor stays its own.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let shift_cmd = Mods { shift: true, ..cmd };
|
||||
gesture(&mut s, &[(600.0, 500.0), (700.0, 600.0)], shift_cmd);
|
||||
assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 4)].into());
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(pasted(&mut s), [5]);
|
||||
// The tool started afresh in the same document (another view of it) keeps a pick picked.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
|
||||
s.reset_tool();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
assert_eq!(pasted(&mut s), [2]);
|
||||
}
|
||||
|
||||
/// #6123: with the other tools, Cmd (Ctrl) lends the selection tool chosen last. Direct Selection
|
||||
/// lent picks anchors, which Copy takes, as with the Curvature tool, which Cmd lends Direct
|
||||
/// Selection until a selection tool is chosen; the Selection tool lent selects the path whole.
|
||||
#[test]
|
||||
fn cmd_selected_anchors_with_other_tools_are_what_copy_takes() {
|
||||
let cmd = Mods { cmd: true, ..Mods::default() };
|
||||
let v = view();
|
||||
let copied = |tool: &str, last_selection: Option<&str>| {
|
||||
let mut s = session();
|
||||
let anchors = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300}, {"x": 300, "y": 300}, {"x": 400, "y": 300}]);
|
||||
s.execute("path.create", &json!({"anchors": anchors})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
if let Some(t) = last_selection {
|
||||
s.select_tool(t, v).unwrap();
|
||||
}
|
||||
s.select_tool(tool, v).unwrap();
|
||||
gesture(&mut s, &[(150.0, 250.0), (350.0, 350.0)], cmd);
|
||||
assert_eq!(s.tool_id(), tool);
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].clone();
|
||||
let id = NodeId(ids[0].as_u64().unwrap());
|
||||
path(&s, id).subpaths[0].anchors.len()
|
||||
};
|
||||
assert_eq!(copied("rectangle", Some("directSelection")), 2);
|
||||
assert_eq!(copied("ellipse", Some("directSelection")), 2);
|
||||
assert_eq!(copied("curvature", None), 2);
|
||||
assert_eq!(copied("rectangle", None), 4, "the Selection tool lent selects the path whole");
|
||||
}
|
||||
|
||||
/// #4980: Cmd/Ctrl held with the Pen drags as Direct Selection: an anchor of the path being drawn
|
||||
/// moves on its own and the Pen goes on drawing that path from it; another path's straight segment
|
||||
/// moves both its ends and a curved one bends; each drag is one undo step and the pointer is the
|
||||
|
||||
@@ -219,6 +219,7 @@ impl Session {
|
||||
// The Selection and Group Selection tools act on the instance whole.
|
||||
self.select_whole_instance(id);
|
||||
let lent = self.make_tool(id);
|
||||
self.selection_at_lend = self.active().map(|d| (d.uid, d.selection.clone()));
|
||||
self.lender = Some(std::mem::replace(&mut self.tool, lent));
|
||||
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
|
||||
}
|
||||
@@ -232,6 +233,12 @@ impl Session {
|
||||
let r = self.apply_actions(acts);
|
||||
self.keep_tool_settings();
|
||||
self.tool = lender;
|
||||
// A gesture that left the selection as it found it (a Shift marquee over nothing) picked
|
||||
// nothing: the selection keeps what it was, the Pen's last anchor or an earlier pick.
|
||||
let left = self.active().map(|d| (d.uid, d.selection.clone()));
|
||||
if left != self.selection_at_lend.take() {
|
||||
self.lent_selection = left;
|
||||
}
|
||||
self.after_command(None);
|
||||
r.map(drop)
|
||||
}
|
||||
@@ -333,6 +340,7 @@ impl Session {
|
||||
unit,
|
||||
stroke_unit,
|
||||
paste_plain_text: self.prefs.paste_text_formatting == "plain",
|
||||
typed_from_keyboard: self.keyboard_text,
|
||||
slices_hidden: self.menu.slices_hidden,
|
||||
slices_locked: self.menu.slices_locked,
|
||||
auto_add_delete: !self.prefs.disable_auto_add_delete,
|
||||
@@ -616,6 +624,17 @@ impl Session {
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
/// Text typed on the keyboard (a key or an IME commit) for the active tool: as
|
||||
/// [`Session::tool_text`], and Typographer's Quotes may also set a lone typed `”` as an opening
|
||||
/// quote ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]). The Glyphs panel and pastes
|
||||
/// use [`Session::tool_text`], so their quotes stay as inserted.
|
||||
pub fn tool_typed_text(&mut self, text: &str, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
self.keyboard_text = true;
|
||||
let r = self.tool_text(text, view);
|
||||
self.keyboard_text = false;
|
||||
r
|
||||
}
|
||||
|
||||
/// Would [`Session::tool_text`] insert `text` unchanged (no control-character filtering,
|
||||
/// newline normalization or document formatting such as Typographer's Quotes)? An IME commit
|
||||
/// the application rewrites is an application edit, not an accepted IME insertion.
|
||||
@@ -623,6 +642,16 @@ impl Session {
|
||||
self.with_tool_cx(view, |t, cx| t.text_input_is_verbatim(cx, text))
|
||||
}
|
||||
|
||||
/// [`Session::tool_text_is_verbatim`] for text typed on the keyboard, as
|
||||
/// [`Session::tool_typed_text`] would insert it: a lone typed `”` that Typographer's Quotes
|
||||
/// turns into an opening quote is not verbatim.
|
||||
pub fn tool_typed_text_is_verbatim(&mut self, text: &str, view: ViewInfo) -> bool {
|
||||
self.keyboard_text = true;
|
||||
let v = self.tool_text_is_verbatim(text, view);
|
||||
self.keyboard_text = false;
|
||||
v
|
||||
}
|
||||
|
||||
/// Does the active tool's open typing session still match the document? An IME commit after
|
||||
/// an external edit starts a fresh session (an application-side reset), so its receipt must
|
||||
/// not be accepted as an unchanged IME insertion.
|
||||
|
||||
@@ -230,6 +230,10 @@ fn structured_junk() {
|
||||
("document.export", json!({"format": "bmp", "rect": {"x": 1}, "artboard": 3})),
|
||||
("document.export", json!({"format": "svg", "rect": [0, 0, 10, 10]})),
|
||||
("select.add", json!({"ids": [[1], {"a": 1}]})),
|
||||
("select.font", json!({"family": "Source Sans 3", "scope": {"artboards": [0, u64::MAX, -1, "x", null, 1.5]}})),
|
||||
("select.font", json!({"family": "Source Sans 3", "scope": {"layers": [0, u64::MAX, -1, [1], {"a": 1}]}})),
|
||||
("select.font", json!({"family": "Source Sans 3", "scope": {"artboards": [], "layers": [1]}})),
|
||||
("select.font", json!({"family": "Source Sans 3", "scope": 7})),
|
||||
("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})),
|
||||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})),
|
||||
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000], [0, 0]]}, {"id": u64::MAX}], "mode": "toggle"})),
|
||||
|
||||
@@ -2381,6 +2381,53 @@ proptest! {
|
||||
);
|
||||
survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// Damaged or hostile TrueType data in a Type 42 font that a composite font shows (its table
|
||||
/// directory and its glyphs), any glyph index: drawn, measured and outlined, or left out.
|
||||
#[test]
|
||||
fn eps_type42_glyphs_never_panic(
|
||||
head in prop::collection::vec((0usize..2048, any::<u8>()), 0..12),
|
||||
body in prop::collection::vec((0usize..TYPE42_FONT.len(), any::<u8>()), 0..12),
|
||||
gid in 0u32..70_000,
|
||||
) {
|
||||
let mut font = TYPE42_FONT.to_vec();
|
||||
for (at, b) in head.into_iter().chain(body) {
|
||||
if let Some(x) = font.get_mut(at) {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
let text = type42_composite_eps(&font, gid);
|
||||
survive("damaged Type 42 glyphs", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// The TrueType font [`type42_composite_eps`] carries.
|
||||
const TYPE42_FONT: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
|
||||
/// An EPS showing glyph `gid` of TrueType font `font` through a Type 42 font and a composite font
|
||||
/// over it, and measuring and outlining it.
|
||||
fn type42_composite_eps(font: &[u8], gid: u32) -> String {
|
||||
let sfnts: String = font.chunks(32_000).map(|c| format!("<{}>\n", c.iter().map(|b| format!("{b:02x}")).collect::<String>())).collect();
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
|
||||
10 dict begin /FontName /T42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for Encoding 1 /c01 put\n\
|
||||
/CharStrings 2 dict def CharStrings begin /.notdef 0 def /c01 {gid} def end /sfnts [\n{sfnts}] def FontName currentdict end definefont pop\n\
|
||||
16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def /Encoding [0] def\n\
|
||||
/FDepVector [/T42 findfont] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 40 scalefont setfont 10 20 moveto (\\000\\001\\000\\001) show (\\000\\001) stringwidth pop pop\n\
|
||||
10 60 moveto (\\000\\001) true charpath fill\nshowpage\n%%EOF\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// [`type42_composite_eps`] as it is: the glyph draws as filled outlines, not as type.
|
||||
#[test]
|
||||
fn the_type42_fixture_draws_its_glyphs() {
|
||||
let r = vectorcraft_eps::import(type42_composite_eps(TYPE42_FONT, 9).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
|
||||
let mut kinds = vec![];
|
||||
r.document.walk(|n| kinds.push(n.kind_label()));
|
||||
assert!(!kinds.contains(&"Text") && kinds.iter().filter(|k| **k == "Path").count() >= 3, "{kinds:?}");
|
||||
}
|
||||
|
||||
/// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from
|
||||
|
||||
Reference in New Issue
Block a user