mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Placed documents: File › Place links a VectorCraft document by default; output reads its file
This commit is contained in:
@@ -364,6 +364,22 @@ pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(
|
||||
|
||||
/// [`encode`], with every file the export writes.
|
||||
pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
// Placed documents output their files' art (a native file keeps what it keeps: see
|
||||
// `native::encode`).
|
||||
if !matches!(format, "vectorcraft" | "template") {
|
||||
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
if !warnings.is_empty() {
|
||||
let mut enc = encode_all_of(&full, format, p)?;
|
||||
enc.warnings.extend(warnings);
|
||||
return Ok(enc);
|
||||
}
|
||||
return encode_all_of(&full, format, p);
|
||||
}
|
||||
encode_all_of(doc, format, p)
|
||||
}
|
||||
|
||||
/// [`encode_all`] of `doc` as it is.
|
||||
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let mut enc = encode_files(doc, f, p)?;
|
||||
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
|
||||
|
||||
@@ -127,6 +127,13 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
|
||||
/// The `.vectorcraft` file of `doc` with the options in `p`.
|
||||
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
|
||||
let (mut so, extras) = save_options(cmd, f, doc, p)?;
|
||||
// Include Linked Files keeps placed documents' files; older versions get their art.
|
||||
let full = if so.include_linked || so.version < vectorcraft_format::VERSION {
|
||||
crate::cmd::place::document::full_documents(doc).0
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(doc)
|
||||
};
|
||||
let doc = &*full;
|
||||
if extras.preview {
|
||||
so.preview = preview_png(doc)?;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ mod pathops;
|
||||
mod patterncmds;
|
||||
pub mod pdfcmds;
|
||||
pub mod perspgrid;
|
||||
mod place;
|
||||
pub(crate) mod place;
|
||||
pub mod plugin;
|
||||
pub mod prefscmds;
|
||||
pub mod print;
|
||||
|
||||
@@ -67,6 +67,7 @@ fn refs(n: &mut Node, f: &mut impl FnMut(Ref)) {
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => f(Ref::Image(&mut im.key)),
|
||||
NodeKind::PlacedDocument(pl) => f(Ref::Image(&mut pl.key)),
|
||||
NodeKind::SymbolInstance { symbol, .. } => f(Ref::Symbol(symbol)),
|
||||
_ => {}
|
||||
}
|
||||
@@ -129,7 +130,7 @@ fn copies(m: &Renames) -> Vec<(&str, &str)> {
|
||||
|
||||
/// Bring the resources `nodes` (art from `src`) use into `dst`, renaming on conflict, and point
|
||||
/// `nodes` at the names they got.
|
||||
pub(super) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
|
||||
pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) {
|
||||
let mut used = Used::default();
|
||||
for n in nodes.iter_mut() {
|
||||
refs(n, &mut |r| used.note(r));
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Placed documents (`file.place` of a VectorCraft document from a file, with `link`): one
|
||||
//! locked object showing an artboard of the document, a [`PlacedDocument`].
|
||||
//!
|
||||
//! The object keeps the file's bytes (in the document's images, like image data) and the link; the
|
||||
//! art it shows is read from them the way placing the file without Link reads it
|
||||
//! ([`document_art`]: the artboard's art, with the file's own linked images found). The Links
|
||||
//! commands ([`super::super::links`]) tell when the file changed and read it again.
|
||||
//!
|
||||
//! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept
|
||||
//! as the blob's [`ImageBlob::proxy`]: a save writes only the preview (unless Include Linked
|
||||
//! Files), as for linked images. The file is read again when it is needed ([`read_file_again`]:
|
||||
//! output, or the screen zoomed past the preview), while it is unchanged, and output falls back to
|
||||
//! the preview with a warning when it can't be ([`full_documents`]).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::links::hash_bytes;
|
||||
use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for};
|
||||
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PlacedDocument, PlacementOptions};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use super::super::fileio;
|
||||
use super::super::*;
|
||||
|
||||
/// The largest file placed (bytes).
|
||||
pub(crate) const MAX_FILE: usize = 256 << 20;
|
||||
/// The format ids of VectorCraft documents.
|
||||
const NATIVE: [&str; 2] = ["vectorcraft", "template"];
|
||||
/// The type a placed file's bytes are stored with.
|
||||
const MIME: &str = "application/json";
|
||||
|
||||
/// Is `name` with `bytes` a VectorCraft document?
|
||||
pub(crate) fn is_native(name: &str, bytes: &[u8]) -> bool {
|
||||
fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id))
|
||||
}
|
||||
|
||||
/// An artboard of a VectorCraft document, read as art to place.
|
||||
pub(crate) struct DocumentArt {
|
||||
/// The document, its layers taken: the art's resources.
|
||||
pub doc: Document,
|
||||
/// The art on the artboard (guides, hidden and template layers left out).
|
||||
pub nodes: Vec<Node>,
|
||||
/// The artboard the art is clipped to (`None`: the art's bounds are shown).
|
||||
pub clip: Option<Rect>,
|
||||
/// What is shown: the artboard, or the art's bounds.
|
||||
pub frame: Rect,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when
|
||||
/// there is one, so its linked images are found), clipped to the artboard unless `bounding`.
|
||||
pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<DocumentArt> {
|
||||
let file = fileio::file_name(name);
|
||||
if !is_native(name, bytes) {
|
||||
return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document")));
|
||||
}
|
||||
if bytes.len() > MAX_FILE {
|
||||
return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20)));
|
||||
}
|
||||
let mut l = fileio::load(name, bytes)?;
|
||||
// Its linked images show their files; its placed documents stay as they are.
|
||||
super::super::links::resolve(&mut l.doc, path, false);
|
||||
l.doc.drop_edit_modes();
|
||||
let n = l.doc.artboards.len();
|
||||
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
|
||||
let board = board.ok_or_else(|| bad(cmd, format!("page {page}: `{file}` has {n} artboard(s)")))?;
|
||||
let layers = std::mem::take(&mut l.doc.layers);
|
||||
let nodes = super::on_artboard(&layers, board);
|
||||
let frame = if bounding { nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())) } else { Some(board) };
|
||||
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?;
|
||||
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
|
||||
}
|
||||
|
||||
/// `nodes` as one group, clipped to `clip` (its own ids: what drawing reads).
|
||||
fn group(nodes: Vec<Node>, clip: Option<Rect>) -> Node {
|
||||
let mut children: Vec<Arc<Node>> = nodes.into_iter().map(Arc::new).collect();
|
||||
if let Some(r) = clip {
|
||||
children.insert(0, Arc::new(super::clip_path(NodeId(0), r)));
|
||||
}
|
||||
Node::new(NodeId(0), NodeKind::Group { children, clip: clip.is_some() })
|
||||
}
|
||||
|
||||
/// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents
|
||||
/// ([`vectorcraft_doc::placed_document::set_loader`]).
|
||||
pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
|
||||
let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, "draw").ok()?;
|
||||
Some((a.doc, group(a.nodes, a.clip), a.frame))
|
||||
}
|
||||
|
||||
/// `p`'s file read again, while it is the file it was read from (how the document model gets it
|
||||
/// when only the preview is stored, [`vectorcraft_doc::placed_document::set_file_reader`]).
|
||||
pub(crate) fn read_file_again(p: &PlacedDocument) -> Option<Vec<u8>> {
|
||||
let bytes = fileio::read_file(&p.link.path).ok()?;
|
||||
if p.link.hash.as_ref().is_some_and(|h| *h != hash_bytes(&bytes)) {
|
||||
return None;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// A VectorCraft document read for placing linked.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Source {
|
||||
/// The file's bytes, with their preview.
|
||||
pub blob: ImageBlob,
|
||||
/// The blob's key ([`key_for`]).
|
||||
pub key: String,
|
||||
/// What is shown, in the file's coordinates: the artboard or the art's bounds.
|
||||
pub frame: Rect,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
|
||||
/// artboard `page` (its art's bounds when `bounding`).
|
||||
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<Source> {
|
||||
// The same document placed the same way is read once (placing it again is free).
|
||||
let key = key_for(&hash_bytes(bytes), page, bounding);
|
||||
if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() {
|
||||
return Ok(s);
|
||||
}
|
||||
let a = document_art(name, bytes, path, page, bounding, cmd)?;
|
||||
let mut blob = ImageBlob::new(MIME, bytes.to_vec());
|
||||
let shown = PlacedDocument {
|
||||
link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) },
|
||||
key: key.clone(),
|
||||
bounding,
|
||||
width: a.frame.width(),
|
||||
height: a.frame.height(),
|
||||
xf: Affine::IDENTITY,
|
||||
placement: PlacementOptions::default(),
|
||||
};
|
||||
let art = group(a.nodes, a.clip);
|
||||
blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new);
|
||||
// Drawing it needn't read it again.
|
||||
vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame));
|
||||
let s = Source { blob, key, frame: a.frame, warnings: a.warnings };
|
||||
let mut m = lock_memo();
|
||||
if m.len() >= MEMO_SIZE {
|
||||
m.remove(0);
|
||||
}
|
||||
m.push(s.clone());
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The most files remembered.
|
||||
const MEMO_SIZE: usize = 32;
|
||||
|
||||
/// Files read lately for placing, by key.
|
||||
static MEMO: std::sync::Mutex<Vec<Source>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn lock_memo() -> std::sync::MutexGuard<'static, Vec<Source>> {
|
||||
// Entries are only pushed and removed: a panic while held leaves it whole.
|
||||
MEMO.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The preview of `art` (of document `doc`, showing `frame`): [`PREVIEW_PX_PER_PT`], at most
|
||||
/// [`PREVIEW_MAX`] pixels a side; JPEG when opaque (inside its outer pixels), else PNG.
|
||||
fn preview(doc: &Document, art: &Node, frame: Rect) -> Option<Vec<u8>> {
|
||||
let k = PREVIEW_PX_PER_PT.min(PREVIEW_MAX as f64 / frame.width().max(frame.height()).max(1e-9));
|
||||
let side = |v: f64| (v * k).round().clamp(1.0, PREVIEW_MAX as f64) as u32;
|
||||
let (w, h) = (side(frame.width()), side(frame.height()));
|
||||
let view = Affine::scale_non_uniform(w as f64 / frame.width().max(1e-9), h as f64 / frame.height().max(1e-9))
|
||||
* Affine::translate((-frame.x0, -frame.y0));
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let img = vectorcraft_render::placed_document::render_inside(&mut r, doc, &Arc::new(art.clone()), w as u16, h as u16, view)?;
|
||||
// Premultiplied → straight.
|
||||
let px = img
|
||||
.pixels
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|&[r, g, b, a]| {
|
||||
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
|
||||
[un(r), un(g), un(b), a]
|
||||
})
|
||||
.collect();
|
||||
let rgba = image::RgbaImage::from_raw(w, h, px)?;
|
||||
let mut out = vec![];
|
||||
// Opaque but for the edge's anti-aliasing (an artboard drawn to whole pixels) and compositing's
|
||||
// rounding: JPEG.
|
||||
let inner = |x: u32, y: u32| x > 0 && y > 0 && x + 1 < w && y + 1 < h;
|
||||
if rgba.enumerate_pixels().all(|(x, y, p)| p[3] >= 250 || !inner(x, y)) {
|
||||
let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80).encode_image(&rgb).ok()?;
|
||||
} else {
|
||||
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `s` stored in `d` (objects showing the same thing share one blob).
|
||||
pub(crate) fn store(d: &mut Document, s: &Source) {
|
||||
d.images.entry(s.key.clone()).or_insert_with(|| s.blob.clone());
|
||||
}
|
||||
|
||||
/// A new placed document of `d` showing `s` at 100%, where its art lies in the file.
|
||||
pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) -> Node {
|
||||
store(d, s);
|
||||
let placed = PlacedDocument {
|
||||
link,
|
||||
key: s.key.clone(),
|
||||
bounding,
|
||||
width: s.frame.width(),
|
||||
height: s.frame.height(),
|
||||
xf: Affine::translate((s.frame.x0, s.frame.y0)),
|
||||
placement: PlacementOptions::default(),
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed)))
|
||||
}
|
||||
|
||||
/// `d` for output: the placed documents that keep only their previews get their files back,
|
||||
/// else (their files gone or changed) output as their previews, with a warning.
|
||||
pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) {
|
||||
let mut need: Vec<PlacedDocument> = vec![];
|
||||
d.visit_placed(|_, p| {
|
||||
if d.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) {
|
||||
need.push(p.clone());
|
||||
}
|
||||
});
|
||||
if need.is_empty() {
|
||||
return (Cow::Borrowed(d), vec![]);
|
||||
}
|
||||
let mut out = d.clone();
|
||||
let mut previews = BTreeSet::new();
|
||||
for p in need {
|
||||
let Some(blob) = d.images.get(&p.key) else { continue };
|
||||
match vectorcraft_doc::placed_document::full_bytes(&p, blob) {
|
||||
Some(bytes) => {
|
||||
out.images.insert(p.key.clone(), ImageBlob { mime: MIME.into(), bytes, proxy: blob.proxy.clone() });
|
||||
}
|
||||
None => {
|
||||
previews.insert(p.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut warnings = vec![];
|
||||
if !previews.is_empty() {
|
||||
warnings.push(format!(
|
||||
"{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)",
|
||||
previews.len()
|
||||
));
|
||||
out.placed_as_previews(&previews);
|
||||
}
|
||||
(Cow::Owned(out), warnings)
|
||||
}
|
||||
@@ -16,13 +16,17 @@
|
||||
//! (`crop: "bounding"`).
|
||||
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
|
||||
//! [`text`]).
|
||||
//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document:
|
||||
//! one locked object showing the artboard `page`, linked to the file and read again when it
|
||||
//! changes ([`document`]); without Link, an editable copy of its art.
|
||||
//!
|
||||
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
|
||||
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
|
||||
//! swatches it uses join the document ([`adopt`]). `file.place.queue` loads the place cursor (the
|
||||
//! `place` tool) with several files, which emits one `file.place` per click or drag.
|
||||
|
||||
mod adopt;
|
||||
pub(crate) mod adopt;
|
||||
pub(crate) mod document;
|
||||
mod text;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -53,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -94,6 +98,9 @@ enum Art {
|
||||
Image(RasterImage),
|
||||
/// A text file's text, placed as area type filling the natural box.
|
||||
Text(String),
|
||||
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
|
||||
/// bounds (not the artboard) are shown.
|
||||
Document(document::Source, LinkInfo, bool),
|
||||
Vector {
|
||||
/// The loaded document: the source of the art's resources.
|
||||
src: Box<Document>,
|
||||
@@ -157,6 +164,19 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
));
|
||||
}
|
||||
let crop = opts.crop != CropTo::Bounding;
|
||||
// A VectorCraft document: placed linked from its file, else as an editable copy.
|
||||
if matches!(format.id, "vectorcraft" | "template") {
|
||||
let page = page as u32;
|
||||
if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) {
|
||||
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, cmd)?;
|
||||
let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) };
|
||||
let (natural, warnings) = (s.frame, s.warnings.clone());
|
||||
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
|
||||
}
|
||||
let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, cmd)?;
|
||||
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
|
||||
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
|
||||
}
|
||||
if format.raster {
|
||||
let img = fileio::raster_image(&src.bytes)?;
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
@@ -176,7 +196,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
|
||||
let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
|
||||
let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
|
||||
// Linked images (a native document's) show their files, found from its folder.
|
||||
// Linked images show their files, found from its folder.
|
||||
super::links::resolve(&mut l.doc, src.path, false);
|
||||
(l.doc, l.warnings)
|
||||
}
|
||||
@@ -190,22 +210,31 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
// The page placed is a PDF import's only one.
|
||||
let index = if pdf { 0 } else { page - 1 };
|
||||
let n = doc.artboards.len();
|
||||
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| {
|
||||
let what = if matches!(format.id, "vectorcraft" | "template") { "artboard" } else { "page" };
|
||||
bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)"))
|
||||
})?;
|
||||
// A PDF import has one layer per page; a native document's (or an EPS file's) art is
|
||||
// whatever lies on the artboard.
|
||||
let pages: Vec<&Arc<Node>> = if pdf { layers.get(index).into_iter().collect() } else { layers.iter().collect() };
|
||||
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
|
||||
let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board));
|
||||
(nodes, crop.then_some(board))
|
||||
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
|
||||
// A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard.
|
||||
let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] };
|
||||
(on_artboard(pages, board), crop.then_some(board))
|
||||
};
|
||||
let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())));
|
||||
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
|
||||
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
|
||||
}
|
||||
|
||||
/// The placeable art of `layers` that lies on `board`.
|
||||
fn on_artboard(layers: &[Arc<Node>], board: Rect) -> Vec<Node> {
|
||||
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
|
||||
art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board))
|
||||
}
|
||||
|
||||
/// A clipping path of rectangle `r`, with id `id`.
|
||||
fn clip_path(id: NodeId, r: Rect) -> Node {
|
||||
let mut c = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut c.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A visible, non-template layer.
|
||||
fn placeable(l: &Node) -> bool {
|
||||
l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })
|
||||
@@ -243,15 +272,12 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
||||
Node::new(d.alloc_id(), NodeKind::Image(im))
|
||||
}
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))),
|
||||
Art::Document(s, link, bounding) => document::node(d, &s, link, bounding),
|
||||
Art::Vector { src, mut nodes, clip } => {
|
||||
adopt::adopt(d, &src, &mut nodes);
|
||||
let mut children: Vec<Arc<Node>> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect();
|
||||
if let Some(r) = clip {
|
||||
let mut c = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut c.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
children.insert(0, Arc::new(c));
|
||||
children.insert(0, Arc::new(clip_path(d.alloc_id(), r)));
|
||||
}
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children, clip: clip.is_some() })
|
||||
}
|
||||
|
||||
@@ -118,6 +118,9 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let preset = given.or(Some(set.advanced.flattener_preset.as_str()).filter(|n| !n.trim().is_empty())).unwrap_or("medium");
|
||||
set.advanced.flattener_preset = preset.to_string();
|
||||
}
|
||||
// Placed documents print their files' art.
|
||||
let (full, _) = super::place::document::full_documents(doc);
|
||||
let doc = &*full;
|
||||
// Raster effects print as images at the document's raster effects resolution.
|
||||
let flat = super::rasterfx::flatten_raster_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
|
||||
@@ -801,6 +801,9 @@ impl Default for Session {
|
||||
|
||||
impl Session {
|
||||
pub fn new() -> Self {
|
||||
// Placed documents are read by the native format's loader.
|
||||
vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document);
|
||||
vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again);
|
||||
Self {
|
||||
docs: vec![],
|
||||
active: None,
|
||||
@@ -1450,6 +1453,8 @@ mod tests_perspgrid;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
#[cfg(test)]
|
||||
mod tests_placed_document;
|
||||
#[cfg(test)]
|
||||
mod tests_plugins;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
|
||||
@@ -320,7 +320,8 @@ fn a_placed_document_brings_its_links_with_the_files_pixels() {
|
||||
let doc_path = dir.file("part.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
let mut s = session();
|
||||
s.execute("file.place", &json!({"path": doc_path})).unwrap();
|
||||
// As an editable copy (placed linked, it stays one locked object).
|
||||
s.execute("file.place", &json!({"path": doc_path, "link": false})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let mut placed = vec![];
|
||||
doc.visit_images(|_, im| placed.push(im.clone()));
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Placed documents: a VectorCraft document placed linked (`file.place`'s default for one read
|
||||
//! from a file), drawn and output as vectors, and saved with its preview.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeKind, PlacedDocument};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::tests_links::{BLUE, Folder, RED, centre_colour, near, open, save, session, write};
|
||||
use super::*;
|
||||
|
||||
/// A VectorCraft document at `path`: one `w`×`h` artboard filled with `rgb` (and `extra` more
|
||||
/// rectangles, which change the file's size).
|
||||
fn source(path: &str, w: f64, h: f64, rgb: [u8; 3], extra: usize) {
|
||||
let mut d = Document::new(w, h);
|
||||
let layer = d.layers[0].id;
|
||||
let [r, g, b] = rgb;
|
||||
for i in 0..=extra {
|
||||
let id = d.alloc_id();
|
||||
let look = Appearance::basic(Paint::solid(Color::rgb8(r, g, b)), Paint::None, 0.0);
|
||||
d.insert(Some(layer), i, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, w, h)), look)).unwrap();
|
||||
}
|
||||
write(path, &vectorcraft_format::save_file(&d));
|
||||
}
|
||||
|
||||
/// A document drawing symbol `Star` (a blue 100×50 rectangle) at `path`, titled `title` (tests
|
||||
/// that remove the file give it content of its own: files read are cached by content).
|
||||
fn star_source(path: &str, title: &str) {
|
||||
let mut d = Document::new(100.0, 50.0);
|
||||
d.title = title.into();
|
||||
let layer = d.layers[0].id;
|
||||
let art = Node::path(vectorcraft_doc::NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), look(BLUE));
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(art) });
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Star".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||||
write(path, &vectorcraft_format::save_file(&d));
|
||||
}
|
||||
|
||||
fn look(c: [u8; 3]) -> Appearance {
|
||||
Appearance::basic(Paint::solid(Color::rgb8(c[0], c[1], c[2])), Paint::None, 0.0)
|
||||
}
|
||||
|
||||
/// The active document gets a red symbol named `Star` of its own.
|
||||
fn parent_star(s: &mut Session) {
|
||||
let star = Node::path(vectorcraft_doc::NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look(RED));
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc)
|
||||
.symbols
|
||||
.push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(star) });
|
||||
}
|
||||
|
||||
/// Place `path` (linked: the default), centred on the artboard → its id.
|
||||
fn place(s: &mut Session, path: &str) -> NodeId {
|
||||
let r = s.execute("file.place", &json!({"path": path, "at": [200, 150]})).unwrap();
|
||||
assert_eq!(r["linked"], true, "{r}");
|
||||
NodeId(r["ids"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn placed(s: &Session, id: NodeId) -> PlacedDocument {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::PlacedDocument(p) => (**p).clone(),
|
||||
k => panic!("not a placed document: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
fn decode(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Images and paths in `bytes`, a PDF, read back.
|
||||
fn pdf_contents(bytes: &[u8]) -> (usize, usize) {
|
||||
// The page as drawn (not the VectorCraft document the PDF carries).
|
||||
let (doc, _) = super::cmd::fileio::page_document(bytes, 0, &Default::default()).unwrap();
|
||||
let (mut images, mut paths) = (0, 0);
|
||||
doc.walk(|n| match n.kind {
|
||||
NodeKind::Image(_) => images += 1,
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => paths += 1,
|
||||
_ => {}
|
||||
});
|
||||
(images, paths)
|
||||
}
|
||||
|
||||
/// The JSON of a `.vectorcraft` file (the data after it left out).
|
||||
fn file_json(bytes: &[u8]) -> Value {
|
||||
let end = bytes.windows(vectorcraft_format::BLOB_MAGIC.len()).position(|w| w == vectorcraft_format::BLOB_MAGIC).unwrap_or(bytes.len());
|
||||
serde_json::from_slice(&bytes[..end]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_places_linked_by_default_at_its_artboard_size() {
|
||||
let dir = Folder::new("placed-basic");
|
||||
let src = dir.file("logo.vectorcraft");
|
||||
source(&src, 100.0, 50.0, RED, 0);
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &src);
|
||||
let p = placed(&s, id);
|
||||
assert!((p.width - 100.0).abs() < 1e-6 && (p.height - 50.0).abs() < 1e-6, "{p:?}");
|
||||
assert_eq!(bounds(&s, id), Rect::new(150.0, 125.0, 250.0, 175.0));
|
||||
assert_eq!((p.link.path.as_str(), p.link.page), (src.as_str(), Some(1)));
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap();
|
||||
assert_eq!((n.name.as_deref(), n.kind_label()), (Some("logo.vectorcraft"), "Placed Document"));
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
// It moves and scales like any object.
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
assert_eq!(bounds(&s, id), Rect::new(160.0, 125.0, 260.0, 175.0));
|
||||
// Its art's bounds instead of the artboard.
|
||||
let mut d = Document::new(100.0, 50.0);
|
||||
let layer = d.layers[0].id;
|
||||
let id2 = d.alloc_id();
|
||||
d.insert(Some(layer), 0, Node::path(id2, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 30.0)), look(RED))).unwrap();
|
||||
let small = dir.file("small.vectorcraft");
|
||||
write(&small, &vectorcraft_format::save_file(&d));
|
||||
let r = s.execute("file.place", &json!({"path": small, "crop": "bounding", "at": [200, 150]})).unwrap();
|
||||
let p = placed(&s, NodeId(r["ids"][0].as_u64().unwrap()));
|
||||
assert!(p.bounding && (p.width - 30.0).abs() < 1e-6 && (p.height - 20.0).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_link_or_a_file_a_document_places_as_an_editable_copy() {
|
||||
let dir = Folder::new("placed-copy");
|
||||
let src = dir.file("logo.vectorcraft");
|
||||
source(&src, 100.0, 50.0, RED, 0);
|
||||
let mut s = session();
|
||||
let data = vectorcraft_format::base64_encode(&std::fs::read(&src).unwrap());
|
||||
for p in [json!({"path": src, "link": false}), json!({"name": "logo.vectorcraft", "dataBase64": data})] {
|
||||
let r = s.execute("file.place", &p).unwrap();
|
||||
assert_eq!(r["linked"], false, "{r}");
|
||||
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(n.kind_label(), "Clip Group", "{p}");
|
||||
}
|
||||
assert!(!s.doc().unwrap().doc.has_placed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_document_is_vectors_in_every_output() {
|
||||
let dir = Folder::new("placed-outputs");
|
||||
let src = dir.file("logo.vectorcraft");
|
||||
source(&src, 100.0, 50.0, RED, 0);
|
||||
let mut s = session();
|
||||
place(&mut s, &src);
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
let text = svg["text"].as_str().unwrap();
|
||||
assert!(!text.contains("<image") && (text.contains("<path") || text.contains("<rect")), "drawn as paths: {text}");
|
||||
let (images, paths) = pdf_contents(&decode(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap()));
|
||||
assert_eq!(images, 0, "no image in the PDF");
|
||||
assert!(paths > 0);
|
||||
let png = decode(&s.execute("document.serialize", &json!({"format": "png"})).unwrap());
|
||||
let [r, g, b, _] = image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(200, 150).0;
|
||||
assert!(near([r, g, b], RED), "{:?}", [r, g, b]);
|
||||
for format in ["eps", "emf", "dxf"] {
|
||||
let out = s.execute("document.serialize", &json!({"format": format})).unwrap();
|
||||
assert!(out.to_string().len() > 200, "{format}: {out}");
|
||||
}
|
||||
let info = s.execute("document.info", &json!({})).unwrap();
|
||||
assert!(info.to_string().contains("\"placedDocuments\""), "{info}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_saved_with_previews_outputs_its_files_art() {
|
||||
let dir = Folder::new("placed-preview-output");
|
||||
let src = dir.file("logo.vectorcraft");
|
||||
source(&src, 100.0, 50.0, RED, 0);
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &src);
|
||||
let doc_path = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
open(&mut s, &doc_path);
|
||||
let key = placed(&s, id).key;
|
||||
assert!(s.doc().unwrap().doc.images[&key].is_proxy(), "opened with the preview only");
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
// Output reads the file again: vectors, no warning.
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
assert_eq!(svg["warnings"], json!([]), "{}", svg["warnings"]);
|
||||
let text = svg["text"].as_str().unwrap();
|
||||
assert!(!text.contains("<image") && (text.contains("<path") || text.contains("<rect")), "vectors");
|
||||
// So does saving with Include Linked Files, and the document stays as it was.
|
||||
let full = file_json(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "includeLinked": true})).unwrap()));
|
||||
assert_eq!(full["images"][&key]["mime"], "application/json");
|
||||
assert!(s.doc().unwrap().doc.images[&key].is_proxy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_document_inside_a_placed_document_outputs_from_its_own_file() {
|
||||
let dir = Folder::new("placed-nested");
|
||||
let (logo, card) = (dir.file("logo.vectorcraft"), dir.file("card.vectorcraft"));
|
||||
source(&logo, 100.0, 50.0, RED, 0);
|
||||
// The card places the logo, and is saved with the logo's preview only.
|
||||
let mut s = session();
|
||||
place(&mut s, &logo);
|
||||
save(&mut s, &card);
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
place(&mut s, &card);
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
// The logo inside is read from its file: vectors, not its preview.
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
let text = svg["text"].as_str().unwrap();
|
||||
assert!(!text.contains("<image"), "the logo's file, not its preview: {text}");
|
||||
assert_eq!(pdf_contents(&decode(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap())).0, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_cant_be_placed_linked_is_an_error() {
|
||||
let dir = Folder::new("placed-errors");
|
||||
let src = dir.file("logo.vectorcraft");
|
||||
source(&src, 100.0, 50.0, RED, 0);
|
||||
let mut s = session();
|
||||
let place = |s: &mut Session, p: Value| s.execute("file.place", &p).unwrap_err().to_string();
|
||||
let e = place(&mut s, json!({"path": src, "page": 2}));
|
||||
assert!(e.contains("page 2") && e.contains("1 artboard"), "{e}");
|
||||
let e = place(&mut s, json!({"path": src, "crop": "art"}));
|
||||
assert!(e.contains("crop"), "{e}");
|
||||
let empty = dir.file("empty.vectorcraft");
|
||||
write(&empty, &vectorcraft_format::save_file(&Document::new(100.0, 50.0)));
|
||||
let e = place(&mut s, json!({"path": empty, "crop": "bounding"}));
|
||||
assert!(e.contains("no art to place"), "{e}");
|
||||
// Junk params are errors, not crashes.
|
||||
for v in [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([1, 2]), json!({"a": {"b": []}})] {
|
||||
for k in ["link", "page", "crop", "at", "rect"] {
|
||||
let _ = s.execute("file.place", &json!({"path": src, k: v}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_documents_resources_stay_its_own() {
|
||||
let dir = Folder::new("placed-symbols");
|
||||
let src = dir.file("badge.vectorcraft");
|
||||
star_source(&src, "star");
|
||||
let mut s = session();
|
||||
parent_star(&mut s);
|
||||
place(&mut s, &src);
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE), "the file's own Star");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.symbols.len(), 1, "nothing joins the document");
|
||||
let prefix = vectorcraft_doc::placed_document::RESOURCE_PREFIX;
|
||||
assert!(doc.images.keys().all(|k| !k.starts_with(prefix)));
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
assert!(!svg["text"].as_str().unwrap().contains("<image"));
|
||||
// Nor its saves.
|
||||
let saved = String::from_utf8(decode(&s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap())).unwrap();
|
||||
assert!(!saved.contains(prefix), "{saved}");
|
||||
// Saved for older apps: the art and its resources, as plain objects.
|
||||
let old = vectorcraft_format::load(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "version": 2})).unwrap())).unwrap();
|
||||
assert!(!old.has_placed());
|
||||
assert!(near(centre_colour(&old), BLUE));
|
||||
}
|
||||
Reference in New Issue
Block a user