mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
401 lines
19 KiB
Rust
401 lines
19 KiB
Rust
//! File → Place in the app: the Place dialog, files dropped on the window, the loaded place
|
||
//! cursor and the Control bar's image details.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use egui::{Pos2, Shape, vec2};
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::NodeKind;
|
||
use vectorcraft_engine::Session;
|
||
|
||
use crate::VectorcraftApp;
|
||
use crate::canvas::Xf;
|
||
use crate::place::{DropAt, DropTarget};
|
||
|
||
/// A `w`×`h` red PNG declaring `ppi`.
|
||
fn png(w: u32, h: u32, ppi: f64) -> Vec<u8> {
|
||
let mut out = vec![];
|
||
image::RgbaImage::from_pixel(w, h, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||
vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out, (ppi, ppi))
|
||
}
|
||
|
||
/// `bytes` written to a fresh temporary file named `name` → its path.
|
||
fn temp_file(name: &str, bytes: &[u8]) -> String {
|
||
let dir = std::env::temp_dir().join(format!("vectorcraft-ui-place-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join(name);
|
||
std::fs::write(&path, bytes).unwrap();
|
||
path.to_string_lossy().to_string()
|
||
}
|
||
|
||
/// An app reading files from disk, with a 400×300 document.
|
||
fn app() -> VectorcraftApp {
|
||
let services = crate::Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() };
|
||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app
|
||
}
|
||
|
||
/// File → Place… with `path` chosen in the file picker.
|
||
fn place_picked(app: &mut VectorcraftApp, path: &str) {
|
||
let path = path.to_string();
|
||
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(path.clone())));
|
||
app.run("file.place", json!({})).unwrap();
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct Dropped(std::path::PathBuf);
|
||
|
||
impl egui::DroppedFile for Dropped {
|
||
fn path(&self) -> &std::path::Path {
|
||
&self.0
|
||
}
|
||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||
std::fs::read(&self.0).map_err(|e| e.to_string())
|
||
}
|
||
}
|
||
|
||
/// One headless frame of the whole window (800×600) with `events` and `dropped` files.
|
||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, mut events: Vec<egui::Event>, dropped: &[&str], shift: bool) {
|
||
events.push(egui::Event::ModifiersChanged(egui::Modifiers { shift, ..Default::default() }));
|
||
let raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||
events,
|
||
dropped_files: dropped.iter().map(|p| Arc::new(Dropped(p.into())) as egui::DroppedFileHandle).collect(),
|
||
..Default::default()
|
||
};
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
app.logic(ui.ctx());
|
||
app.ui(ui);
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node {
|
||
let st = app.session.active().unwrap();
|
||
let n = st.doc.node(st.selection.objects[0]).unwrap().clone();
|
||
assert!(matches!(n.kind, NodeKind::Image(_)), "{:?}", n.kind);
|
||
n
|
||
}
|
||
|
||
/// A tiny SVG document.
|
||
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="50" height="40"><rect width="20" height="10"/></svg>"#;
|
||
|
||
/// Two frames: fonts, then the canvas lays out → the canvas.
|
||
fn laid_out(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Rect {
|
||
frame(app, ctx, vec![], &[], false);
|
||
frame(app, ctx, vec![], &[], false);
|
||
app.canvas_rect.expect("the canvas is laid out")
|
||
}
|
||
|
||
/// Where the platform tells where files were dropped (the web), they go as in Illustrator: placed
|
||
/// at the pointer on the canvas whatever they are, opened off it (the tab bar).
|
||
#[test]
|
||
fn a_drop_with_a_position_places_on_the_canvas_and_opens_off_it() {
|
||
let mut app = app();
|
||
let ctx = egui::Context::default();
|
||
let rect = laid_out(&mut app, &ctx);
|
||
let pos = rect.center() + vec2(60.0, -40.0);
|
||
let want = Xf::new(rect, app.view().unwrap()).to_doc(pos);
|
||
let uid = app.session.active().unwrap().uid;
|
||
for name in ["a.png", "a.svg", "a.ai", "a.vectorcraft"] {
|
||
assert_eq!(app.drop_target(name, Some(pos), true), DropTarget::Place(DropAt { doc: uid, at: want, embed: true }), "{name}");
|
||
assert_eq!(app.drop_target(name, Some(Pos2::new(300.0, rect.top() - 10.0)), false), DropTarget::Open, "{name} on the tab bar");
|
||
}
|
||
}
|
||
|
||
/// Desktop drags carry no position (#472): a dropped document opens as a tab of its own, a
|
||
/// picture or text is placed in the middle of the view.
|
||
#[test]
|
||
fn a_drop_without_a_position_opens_documents_and_places_pictures() {
|
||
let mut app = app();
|
||
let ctx = egui::Context::default();
|
||
laid_out(&mut app, &ctx);
|
||
let center = app.view().unwrap().center;
|
||
let uid = app.session.active().unwrap().uid;
|
||
for name in ["a.svg", "a.svgz", "a.pdf", "a.ai", "a.eps", "a.vectorcraft", "a.drawcraft", "a.vctemplate", "a.dxf", "a.emf"] {
|
||
assert_eq!(app.drop_target(name, None, false), DropTarget::Open, "{name}");
|
||
}
|
||
for name in ["a.png", "a.jpg", "a.tiff", "a.webp", "a.txt"] {
|
||
assert_eq!(app.drop_target(name, None, false), DropTarget::Place(DropAt { doc: uid, at: center, embed: false }), "{name}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_dropped_png_is_placed_in_the_middle_of_the_view() {
|
||
let mut app = app();
|
||
let ctx = egui::Context::default();
|
||
laid_out(&mut app, &ctx);
|
||
let path = temp_file("drop.png", &png(20, 10, 72.0));
|
||
// Where egui last saw the pointer is stale during a desktop drag: it doesn't count.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0))], &[&path], false);
|
||
let n = selected_image(&app);
|
||
let want = app.view().unwrap().center;
|
||
let c = n.geometric_bounds().unwrap().center();
|
||
assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "{c:?} vs {want:?}");
|
||
assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_ref().map(|l| l.path.as_str()) == Some(path.as_str())), "linked by default");
|
||
assert_eq!(app.session.documents().len(), 1, "placed, not opened");
|
||
// Shift embeds.
|
||
frame(&mut app, &ctx, vec![], &[&path], true);
|
||
assert!(matches!(&selected_image(&app).kind, NodeKind::Image(im) if im.link.is_none()));
|
||
}
|
||
|
||
/// #472: a document dropped on the window opens as a new tab, not into the open document; the
|
||
/// pictures dropped with it are placed in the document that was open, before it opens.
|
||
#[test]
|
||
fn a_dropped_document_opens_as_a_tab_of_its_own() {
|
||
let mut app = app();
|
||
let ctx = egui::Context::default();
|
||
laid_out(&mut app, &ctx);
|
||
let svg = temp_file("dropped.svg", SVG.as_bytes());
|
||
let pic = temp_file("with-it.png", &png(8, 8, 72.0));
|
||
frame(&mut app, &ctx, vec![], &[&svg, &pic], false);
|
||
assert_eq!(app.session.documents().len(), 2, "opened, not placed");
|
||
assert_eq!(app.session.active_index(), Some(1), "the document opened is shown");
|
||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some(svg.as_str()));
|
||
assert_eq!((images(&app, 0), images(&app, 1)), (1, 0), "the picture is placed in the document that was open");
|
||
assert_eq!(app.ui.recent_files.first(), Some(&svg));
|
||
}
|
||
|
||
#[test]
|
||
fn files_dropped_with_no_document_open_open_and_are_recent() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
let path = temp_file("open-me.png", &png(8, 8, 72.0));
|
||
frame(&mut app, &ctx, vec![], &[], false);
|
||
frame(&mut app, &ctx, vec![], &[&path], false);
|
||
assert_eq!(app.session.documents().len(), 1, "no document: the drop opens");
|
||
assert_eq!(app.ui.recent_files.first(), Some(&path));
|
||
}
|
||
|
||
/// The images in open document `i`.
|
||
fn images(app: &VectorcraftApp, i: usize) -> usize {
|
||
let mut n = 0;
|
||
app.session.documents()[i].doc.walk(|node| n += usize::from(matches!(node.kind, NodeKind::Image(_))));
|
||
n
|
||
}
|
||
|
||
/// The web reads dropped files asynchronously (#359): a file that arrives after another document
|
||
/// became active still lands where it was dropped, and is dropped itself when that document closed.
|
||
#[test]
|
||
fn a_file_read_after_another_document_became_active_lands_where_it_was_dropped() {
|
||
let mut app = app();
|
||
let inbox = crate::place::PlaceInbox::default();
|
||
app.services.place_inbox = Some(inbox.clone());
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![], &[], false);
|
||
frame(&mut app, &ctx, vec![], &[], false);
|
||
let rect = app.canvas_rect.expect("the canvas is laid out");
|
||
let pos = rect.center() + vec2(60.0, -40.0);
|
||
let drop_on_active = |app: &VectorcraftApp| match app.drop_target("late.png", Some(pos), false) {
|
||
DropTarget::Place(d) => d,
|
||
t => panic!("{t:?}"),
|
||
};
|
||
let arrive =
|
||
|name: &str, drop| inbox.lock().unwrap().push(crate::place::PlaceArrival { name: name.into(), bytes: png(20, 10, 72.0), drop: Some(drop) });
|
||
// Dropped on A; another document opens while the file is read.
|
||
let on_a = drop_on_active(&app);
|
||
let want = Xf::new(rect, app.view().unwrap()).to_doc(pos);
|
||
app.run("file.new", json!({"width": 600, "height": 500})).unwrap();
|
||
assert_eq!(app.session.active_index(), Some(1));
|
||
arrive("late.png", on_a);
|
||
frame(&mut app, &ctx, vec![], &[], false);
|
||
assert_eq!(app.session.active_index(), Some(0), "the document it was dropped on is active again");
|
||
assert_eq!((images(&app, 0), images(&app, 1)), (1, 0));
|
||
let c = selected_image(&app).geometric_bounds().unwrap().center();
|
||
assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "where it was dropped: {c:?} vs {want:?}");
|
||
// Dropped on B, which closes before the file arrives: nothing is placed.
|
||
app.run("document.activate", json!({"index": 1})).unwrap();
|
||
let on_b = drop_on_active(&app);
|
||
app.run("file.close", json!({})).unwrap();
|
||
assert_eq!(app.session.documents().len(), 1);
|
||
arrive("orphan.png", on_b);
|
||
frame(&mut app, &ctx, vec![], &[], false);
|
||
assert_eq!(images(&app, 0), 1, "not placed in the document left open");
|
||
assert!(app.ui.status.contains("orphan.png") && app.ui.status.contains("closed"), "{}", app.ui.status);
|
||
}
|
||
|
||
#[test]
|
||
fn the_place_dialog_lists_the_files_and_loads_the_cursor_with_several() {
|
||
let mut app = app();
|
||
let a = temp_file("a.png", &png(300, 150, 300.0));
|
||
let b = temp_file("b.png", &png(10, 10, 72.0));
|
||
app.run("file.place", json!({"paths": [a, b]})).unwrap();
|
||
let d = app.ui.dialog.as_ref().expect("the Place dialog");
|
||
assert_eq!(d.kind, crate::dialogs::place::KIND);
|
||
assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one file and one selected object");
|
||
assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)");
|
||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
|
||
for label in ["Place", "a.png", "b.png", "Link", "Template", "Replace", "Cancel"] {
|
||
assert!(text.contains(label), "{label} in {text}");
|
||
}
|
||
crate::dialogs::confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
assert_eq!(app.session.tool_id(), "place");
|
||
assert_eq!(app.session.tool_options()["count"], 2);
|
||
// The cursor carries the current file's thumbnail and the number of files.
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let mut shapes = vec![];
|
||
for _ in 0..2 {
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::place::paint_cursor(&mut app, ui.ctx(), ui.painter(), Pos2::new(50.0, 50.0)));
|
||
out.textures_delta.clear();
|
||
shapes = out.shapes;
|
||
}
|
||
let mut text = String::new();
|
||
let mut textured = false;
|
||
for s in shapes {
|
||
match s.shape {
|
||
Shape::Text(t) => text.push_str(t.galley.text()),
|
||
Shape::Mesh(m) => textured |= m.texture_id != egui::TextureId::default(),
|
||
_ => {}
|
||
}
|
||
}
|
||
assert!(textured, "the thumbnail");
|
||
assert_eq!(text, "2", "the badge");
|
||
}
|
||
|
||
#[test]
|
||
fn one_file_places_centred_in_the_view_and_replace_swaps_the_selection() {
|
||
let mut app = app();
|
||
app.view_mut().unwrap().center = vectorcraft_geom::Point::new(120.0, 80.0);
|
||
place_picked(&mut app, &temp_file("one.png", &png(300, 150, 300.0)));
|
||
let d = app.ui.dialog.as_ref().expect("the Place dialog");
|
||
assert_eq!(d.kind, crate::dialogs::place::KIND);
|
||
assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one selected object");
|
||
assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)");
|
||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
|
||
for label in ["Place", "one.png", "Link", "Template", "Replace", "Cancel"] {
|
||
assert!(text.contains(label), "{label} in {text}");
|
||
}
|
||
crate::dialogs::confirm(&mut app).unwrap();
|
||
assert!(app.ui.dialog.is_none());
|
||
let first = selected_image(&app);
|
||
assert_eq!(first.geometric_bounds().unwrap().center(), vectorcraft_geom::Point::new(120.0, 80.0), "centred in the view");
|
||
// With one object selected, Replace applies.
|
||
place_picked(&mut app, &temp_file("two.png", &png(40, 40, 144.0)));
|
||
assert!(app.ui.dialog.as_ref().unwrap().bool("__replace"));
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("replace".into(), json!(true));
|
||
app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false));
|
||
crate::dialogs::confirm(&mut app).unwrap();
|
||
let second = selected_image(&app);
|
||
assert_eq!(second.name.as_deref(), Some("two.png"));
|
||
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 1, "replaced");
|
||
assert!(!app.ui.place_link, "Link is remembered");
|
||
}
|
||
|
||
#[test]
|
||
fn the_control_bar_shows_the_image_file_link_colour_mode_and_ppi() {
|
||
let mut app = app();
|
||
let path = temp_file("photo.png", &png(30, 30, 300.0));
|
||
app.run("file.place", json!({"path": path})).unwrap();
|
||
let text = crate::tests_labels::painted_text(&mut app, crate::chrome::control_bar);
|
||
for s in ["Linked File", "photo.png", "RGB PPI: 300"] {
|
||
assert!(text.contains(s), "{s} in {text}");
|
||
}
|
||
let v: Value = app.run("file.place", json!({"path": path, "link": false})).unwrap();
|
||
assert_eq!(v["linked"], false);
|
||
let text = crate::tests_labels::painted_text(&mut app, crate::chrome::control_bar);
|
||
assert!(text.contains("Embedded"), "{text}");
|
||
}
|
||
|
||
/// Click the Control bar's `label`.
|
||
fn click_label(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
|
||
use crate::tests_removeanchors::{at, click_control, control_frame};
|
||
let p = at(&control_frame(app, ctx, vec![]), label);
|
||
click_control(app, ctx, p);
|
||
}
|
||
|
||
#[test]
|
||
fn the_control_bar_and_properties_trace_a_selected_image() {
|
||
use crate::panels::image_trace::{TRACE_BUTTON_W, selected_preset};
|
||
use crate::tests_removeanchors::{at, click_control, control_frame, has, properties_frame};
|
||
let mut app = app();
|
||
let data = vectorcraft_format::base64_encode(&png(30, 30, 72.0));
|
||
app.run("file.place", json!({"name": "red.png", "dataBase64": data, "link": false})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let bar = control_frame(&mut app, &ctx, vec![]);
|
||
for s in ["Embedded", "Unembed…", "Image Trace", "Mask", "Crop Image", "Opacity:"] {
|
||
assert!(has(&bar, s), "{s}");
|
||
}
|
||
assert!(!has(&bar, "Stroke:"), "an image has no Fill and Stroke in the Control bar");
|
||
let props = properties_frame(&mut app, 260.0);
|
||
for s in ["Unembed…", "Image Trace", "Mask", "Crop Image"] {
|
||
assert!(has(&props, s), "Properties: {s}");
|
||
}
|
||
// The button's arrow lists the presets; choosing one traces the image with it.
|
||
click_control(&mut app, &ctx, at(&bar, "Image Trace") + vec2(TRACE_BUTTON_W / 2.0, 0.0));
|
||
click_label(&mut app, &ctx, "6 Colors");
|
||
assert_eq!(selected_preset(&app).as_deref(), Some("6 Colors"));
|
||
// The button itself traces with the Default preset.
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
click_label(&mut app, &ctx, "Image Trace");
|
||
assert_eq!(selected_preset(&app).as_deref(), Some("Default"));
|
||
// An Image Trace object: its preset (another traces it again), the panel and Expand.
|
||
let bar = control_frame(&mut app, &ctx, vec![]);
|
||
for s in ["Image Tracing", "Preset:", "Default", "Expand"] {
|
||
assert!(has(&bar, s), "{s}");
|
||
}
|
||
assert!(has(&properties_frame(&mut app, 260.0), "Release"));
|
||
click_control(&mut app, &ctx, at(&bar, "Default"));
|
||
click_label(&mut app, &ctx, "3 Colors");
|
||
assert_eq!(selected_preset(&app).as_deref(), Some("3 Colors"));
|
||
click_label(&mut app, &ctx, "Expand");
|
||
let st = app.session.active().unwrap();
|
||
let g = st.doc.node(st.selection.objects[0]).unwrap();
|
||
assert!(selected_preset(&app).is_none() && g.children().unwrap().iter().all(|c| !matches!(c.kind, NodeKind::Image(_))), "expanded");
|
||
}
|
||
|
||
#[test]
|
||
fn the_control_bars_mask_clips_the_image_and_selects_the_clipping_path() {
|
||
let mut app = app();
|
||
let data = vectorcraft_format::base64_encode(&png(30, 30, 72.0));
|
||
let img = app.run("file.place", json!({"name": "red.png", "dataBase64": data, "link": false})).unwrap()["ids"][0].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
click_label(&mut app, &ctx, "Mask");
|
||
let st = app.session.active().unwrap();
|
||
let group = &st.doc.layers[0].children().unwrap()[0];
|
||
assert!(matches!(group.kind, NodeKind::Group { clip: true, .. }));
|
||
let ids: Vec<u64> = group.children().unwrap().iter().map(|c| c.id.0).collect();
|
||
assert_eq!((ids[1], st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>()), (img, vec![ids[0]]));
|
||
}
|
||
|
||
#[test]
|
||
fn a_vectorcraft_document_places_linked_and_edit_original_opens_it() {
|
||
let mut app = app();
|
||
// A one-artboard document with a rectangle.
|
||
let mut src = Session::new();
|
||
src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
|
||
src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
|
||
let bytes = vectorcraft_format::save_file(&src.active().unwrap().doc);
|
||
let path = temp_file("badge.vectorcraft", &bytes);
|
||
place_picked(&mut app, &path);
|
||
let d = app.ui.dialog.as_ref().expect("the Place dialog");
|
||
assert!(d.bool("link") && d.bool("__documents") && !d.bool("__others"), "Link on, for a document");
|
||
// Link's tooltip says what it does for a document.
|
||
assert!(crate::dialogs::place::link_tip(d).contains("editable copy"));
|
||
crate::dialogs::confirm(&mut app).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
let id = st.selection.objects[0];
|
||
let vectorcraft_doc::NodeKind::PlacedDocument(p) = &st.doc.node(id).unwrap().kind else { panic!("not a placed document") };
|
||
assert_eq!(p.link.path, path);
|
||
// Edit Original opens the document in a new tab.
|
||
let tabs = app.session.documents().len();
|
||
app.run("links.editOriginal", json!({})).unwrap();
|
||
assert_eq!(app.session.documents().len(), tabs + 1);
|
||
assert_eq!(app.session.active().unwrap().path.as_deref(), Some(path.as_str()));
|
||
// A PNG keeps the image tooltip; a PNG and a document, both.
|
||
let pic = temp_file("pic.png", &png(10, 10, 72.0));
|
||
place_picked(&mut app, &pic);
|
||
let d = app.ui.dialog.as_ref().unwrap();
|
||
assert!(!d.bool("__documents") && crate::dialogs::place::link_tip(d).contains("image file"));
|
||
app.ui.dialog = None;
|
||
let both = vec![pic.clone(), path.clone()];
|
||
app.services.pick_open_multi = Some(Box::new(move || both.clone()));
|
||
app.run("file.place", json!({})).unwrap();
|
||
let d = app.ui.dialog.as_ref().unwrap();
|
||
assert!(crate::dialogs::place::link_tip(d).contains("VectorCraft documents"));
|
||
}
|