mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
M4.57: PNG, PDF and text clipboard flavours in and out
Copy and Cut also offer other apps a PNG of the copied objects, a PDF
with On Copy: PDF, and plain text when every copied object is type
(clipboard.flavours; clipboard.exportPng {scale?}, exportPdf,
exportText). Paste outside text editing takes what another app copied:
SVG, then PDF (clipboard.importPdf), then plain text as point text
(clipboard.importText), then a bitmap embedded at the view centre
(clipboard.importImage {dataBase64, mime?, center?}); pasting our own
copy keeps the lossless internal clipboard, and Cmd+V pastes a bitmap
that comes with no text. Services gains system_clipboard
(SystemClipboard: write, holds_ours, read, has): on Windows every
format at once, on macOS and Linux one (the text, else the PNG); the
web keeps SVG text only. With When pasting text: Keep Plain Text, text
the Type tool copied pastes without its formatting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a560f74e17
commit
0968e34e7e
@@ -6,8 +6,10 @@
|
||||
//! System clipboard: the copied objects as SVG markup (what other apps paste), and SVG markup from
|
||||
//! other apps turned into clipboard objects (pasted with the Paste commands). The UI owns the
|
||||
//! platform clipboard; these commands only convert. Within VectorCraft the internal clipboard stays
|
||||
//! lossless (live effects, masks, symbols); SVG is the outside format.
|
||||
//! lossless (live effects, masks, symbols); SVG is the outside format, with PNG, PDF and plain
|
||||
//! text besides it (see `flavours`).
|
||||
|
||||
mod flavours;
|
||||
mod resources;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
@@ -17,15 +19,16 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_brush::Brush;
|
||||
use vectorcraft_color::Swatch;
|
||||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
use crate::DocState;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
let mut v = vec![
|
||||
cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg),
|
||||
cmd!(
|
||||
query "clipboard.importSvg",
|
||||
@@ -45,7 +48,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
conflicts
|
||||
),
|
||||
]
|
||||
];
|
||||
v.extend(flavours::specs());
|
||||
v
|
||||
}
|
||||
|
||||
/// Is `text` SVG markup (as other apps put on the clipboard)?
|
||||
@@ -184,23 +189,11 @@ fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, None) => return Err(bad(C, "give svg, or dataBase64 of an SVG or SVGZ file")),
|
||||
};
|
||||
let src = vectorcraft_svg::import(&svg).map_err(|e| bad(C, e.to_string()))?;
|
||||
let mut clip = Clipboard::from_document(&src);
|
||||
let clip = Clipboard::from_document(&src);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, "the SVG has no drawable objects"));
|
||||
}
|
||||
let center = match point_param(p, "center") {
|
||||
Some(c) => c,
|
||||
None => s.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(),
|
||||
};
|
||||
if let Some(b) = clip.bounds() {
|
||||
let xf = Affine::translate(center - b.center());
|
||||
for n in &mut clip.nodes {
|
||||
n.transform(xf, false);
|
||||
}
|
||||
}
|
||||
let count = clip.nodes.len();
|
||||
s.clipboard = clip;
|
||||
Ok(json!({ "count": count }))
|
||||
Ok(json!({ "count": s.load_clipboard(clip, p)? }))
|
||||
}
|
||||
|
||||
fn conflicts(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what
|
||||
//! Paste takes from them (bitmaps, plain text and PDF).
|
||||
//!
|
||||
//! The UI owns the platform clipboard (`Services::system_clipboard` in the egui frontend): on Copy
|
||||
//! it publishes [`Session::clipboard_flavours`]; before a Paste it turns what another app put on
|
||||
//! the system clipboard into the internal clipboard with the `clipboard.import*` commands. These
|
||||
//! commands only convert, so agents can drive them headless.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageObject, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::super::fileio;
|
||||
use super::super::place::pt_per_px;
|
||||
use super::*;
|
||||
|
||||
/// Plain text: a type-only copy's text, else the SVG markup (Include SVG Code).
|
||||
pub const TEXT: &str = "text/plain";
|
||||
/// SVG markup (Include SVG Code).
|
||||
pub const SVG: &str = "image/svg+xml";
|
||||
/// A PDF of the copied objects (On Copy: PDF).
|
||||
pub const PDF: &str = "application/pdf";
|
||||
/// A PNG of the copied objects (always offered).
|
||||
pub const PNG: &str = "image/png";
|
||||
/// Any bitmap (PNG, BMP…), in what Paste asks the system clipboard for.
|
||||
pub const BITMAP: &str = "image/*";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 4] = [SVG, PDF, TEXT, BITMAP];
|
||||
|
||||
/// One representation of the clipboard's contents.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Flavour {
|
||||
pub mime: &'static str,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Longest side (px) of the PNG Copy offers: larger art is scaled down to fit.
|
||||
const COPY_PNG_SIDE: f64 = 4096.0;
|
||||
/// Most bytes of text `clipboard.importText` takes.
|
||||
const MAX_TEXT: usize = 1 << 20;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "clipboard.exportPng",
|
||||
"Clipboard as PNG",
|
||||
[],
|
||||
None,
|
||||
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px)} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty)",
|
||||
always,
|
||||
export_png
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.exportPdf",
|
||||
"Clipboard as PDF",
|
||||
[],
|
||||
None,
|
||||
"{} → {dataBase64} a one-page PDF of the copied objects, the page their visual bounds (null when the clipboard is empty)",
|
||||
always,
|
||||
export_pdf
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.exportText",
|
||||
"Clipboard as Text",
|
||||
[],
|
||||
None,
|
||||
"{} → {text} the copied type objects' text, one object per line (null unless every copied object is type, or a group of type)",
|
||||
always,
|
||||
export_text
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.flavours",
|
||||
"Clipboard Formats",
|
||||
[],
|
||||
None,
|
||||
"{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut",
|
||||
always,
|
||||
flavours
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.importImage",
|
||||
"Load Image into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (PNG, JPEG, GIF, WebP, TIFF or BMP bytes), mime?: the type the system clipboard gave (an image/… type; the format is read from the bytes), center?: [x, y]} replace the clipboard with the image, embedded at 100% of its physical size (its resolution, else 72 ppi), centred on `center` (default: the first artboard) → {count, width, height (pt), pixelWidth, pixelHeight}; then run edit.paste",
|
||||
has_doc,
|
||||
import_image
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.importText",
|
||||
"Load Text into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{text, center?: [x, y]} replace the clipboard with point text of `text` (one paragraph per line; trailing line breaks dropped) in the default type style, centred on `center` (default: the first artboard) → {count}; then run edit.paste",
|
||||
has_doc,
|
||||
import_text
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.importPdf",
|
||||
"Load PDF into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64, page?: 1, password? (an encrypted PDF), center?: [x, y]} replace the clipboard with the objects of one PDF page (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_pdf
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Is every object type (or a group of type only)?
|
||||
fn type_only(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Text(_) => true,
|
||||
NodeKind::Group { children, .. } => !children.is_empty() && children.iter().all(|c| type_only(c)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The copied type objects' text, one object per line (`None` unless the clipboard holds type
|
||||
/// only).
|
||||
pub fn clipboard_text(&self) -> Option<String> {
|
||||
let nodes = &self.clipboard.nodes;
|
||||
if nodes.is_empty() || !nodes.iter().all(type_only) {
|
||||
return None;
|
||||
}
|
||||
let mut lines = vec![];
|
||||
for n in nodes {
|
||||
n.walk(&mut |c| {
|
||||
if let NodeKind::Text(t) = &c.kind {
|
||||
lines.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
}
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// The clipboard as a document whose one artboard is the objects' visual bounds (`None` when
|
||||
/// it is empty or has no extent).
|
||||
fn clipboard_cropped(&self) -> Option<(Document, vectorcraft_geom::Rect)> {
|
||||
let b = self.clipboard.bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0)?;
|
||||
Some((fileio::with_single_artboard(self.clipboard.to_document(), b, "Clipboard"), b))
|
||||
}
|
||||
|
||||
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
|
||||
/// when the clipboard is empty.
|
||||
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
|
||||
const C: &str = "clipboard.exportPng";
|
||||
let Some((doc, b)) = self.clipboard_cropped() else { return Ok(None) };
|
||||
let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?;
|
||||
let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
|
||||
Ok(Some((png, w, h)))
|
||||
}
|
||||
|
||||
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
|
||||
/// clipboard is empty.
|
||||
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
|
||||
let Some((doc, _)) = self.clipboard_cropped() else { return Ok(None) };
|
||||
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
|
||||
}
|
||||
|
||||
/// The MIME types of [`Self::clipboard_flavours`], without making them.
|
||||
pub fn clipboard_flavour_types(&self) -> Vec<&'static str> {
|
||||
if self.clipboard.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let (svg, pdf) = (self.prefs.copy_as_svg, self.prefs.copy_as_pdf);
|
||||
let text = svg || self.clipboard.nodes.iter().all(type_only);
|
||||
// Art without an area (a lone guide-like line) has no page or picture.
|
||||
let area = self.clipboard.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
|
||||
[(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect()
|
||||
}
|
||||
|
||||
/// What Copy offers other apps, best first (see `clipboard.flavours`). A flavour that can't be
|
||||
/// made (art too large to render) is left out; the PNG is scaled down to at most
|
||||
/// [`COPY_PNG_SIDE`] pixels a side.
|
||||
pub fn clipboard_flavours(&self) -> Vec<Flavour> {
|
||||
let types = self.clipboard_flavour_types();
|
||||
let svg = types.contains(&SVG).then(|| self.clipboard_svg()).flatten();
|
||||
let mut out = vec![];
|
||||
for mime in types {
|
||||
let data = match mime {
|
||||
TEXT => self.clipboard_text().or_else(|| svg.clone()).map(String::into_bytes),
|
||||
SVG => svg.clone().map(String::into_bytes),
|
||||
PDF => self.clipboard_pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
|
||||
_ => {
|
||||
let side = self.clipboard.bounds().map_or(1.0, |b| b.width().max(b.height()));
|
||||
let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0);
|
||||
self.clipboard_png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
|
||||
}
|
||||
};
|
||||
out.extend(data.map(|data| Flavour { mime, data }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace the clipboard with `clip`, centred on `p`'s `center` (default: the first
|
||||
/// artboard's centre) → the number of objects.
|
||||
pub(super) fn load_clipboard(&mut self, mut clip: Clipboard, p: &Value) -> Result<usize> {
|
||||
let center = match point_param(p, "center") {
|
||||
Some(c) => c,
|
||||
None => self.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(),
|
||||
};
|
||||
if let Some(b) = clip.bounds() {
|
||||
let xf = Affine::translate(center - b.center());
|
||||
for n in &mut clip.nodes {
|
||||
n.transform(xf, false);
|
||||
}
|
||||
}
|
||||
let count = clip.nodes.len();
|
||||
self.clipboard = clip;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of `p`'s `dataBase64`.
|
||||
fn data_param(p: &Value, cmd: &str) -> Result<Vec<u8>> {
|
||||
let b64 = str_param(p, "dataBase64").ok_or_else(|| bad(cmd, "give dataBase64"))?;
|
||||
vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad base64"))
|
||||
}
|
||||
|
||||
fn export_png(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let scale = f64_or(p, "scale", 1.0);
|
||||
if !(scale > 0.0 && scale <= 64.0) {
|
||||
return Err(bad("clipboard.exportPng", "scale must be a number above 0, at most 64"));
|
||||
}
|
||||
Ok(match s.clipboard_png(scale)? {
|
||||
Some((png, w, h)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h }),
|
||||
None => json!({ "dataBase64": null }),
|
||||
})
|
||||
}
|
||||
|
||||
fn export_pdf(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "dataBase64": s.clipboard_pdf()?.map(|b| vectorcraft_format::base64_encode(&b)) }))
|
||||
}
|
||||
|
||||
fn export_text(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "text": s.clipboard_text() }))
|
||||
}
|
||||
|
||||
fn flavours(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "flavours": s.clipboard_flavour_types() }))
|
||||
}
|
||||
|
||||
fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importImage";
|
||||
if let Some(m) = str_param(p, "mime").filter(|m| !m.starts_with("image/") || *m == SVG) {
|
||||
return Err(bad(C, format!("`{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText")));
|
||||
}
|
||||
let img = fileio::raster_image(&data_param(p, C)?).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
let (w, h) = (img.width, img.height);
|
||||
let image = ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None };
|
||||
let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(image))], ..Default::default() };
|
||||
clip.images.insert(img.key, img.blob);
|
||||
let count = s.load_clipboard(clip, p)?;
|
||||
Ok(json!({ "count": count, "width": w as f64 * sx, "height": h as f64 * sy, "pixelWidth": w, "pixelHeight": h }))
|
||||
}
|
||||
|
||||
fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importText";
|
||||
let raw = str_param(p, "text").ok_or_else(|| bad(C, "give text"))?;
|
||||
if raw.len() > MAX_TEXT {
|
||||
return Err(bad(C, format!("the text is over {} MB", MAX_TEXT >> 20)));
|
||||
}
|
||||
let text = raw.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let text = text.trim_end_matches('\n');
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(C, "the text is empty"));
|
||||
}
|
||||
let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null));
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
|
||||
let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t)));
|
||||
let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?;
|
||||
Ok(json!({ "count": count }))
|
||||
}
|
||||
|
||||
fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importPdf";
|
||||
let bytes = data_param(p, C)?;
|
||||
let page = match p.get("page") {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(C, "page must be a whole number from 1"))? as usize,
|
||||
};
|
||||
let opts = fileio::LoadOptions { password: str_param(p, "password").map(str::to_string), ..Default::default() };
|
||||
let (mut doc, warnings) = fileio::page_document(&bytes, page - 1, &opts).map_err(|e| bad(C, e.to_string()))?;
|
||||
doc.drop_edit_modes();
|
||||
let clip = Clipboard::from_document(&doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, format!("page {page} of the PDF has no objects")));
|
||||
}
|
||||
let count = s.load_clipboard(clip, p)?;
|
||||
Ok(json!({ "count": count, "warnings": warnings }))
|
||||
}
|
||||
@@ -400,6 +400,20 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let x = f64_req(p, "x", "text.create")?;
|
||||
let y = f64_req(p, "y", "text.create")?;
|
||||
let text = str_param(p, "text").unwrap_or("");
|
||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
||||
if let Some(a) = p.get("area") {
|
||||
let w = f64_or(a, "width", 200.0);
|
||||
let h = f64_or(a, "height", 100.0);
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
|
||||
}
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
|
||||
}
|
||||
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
|
||||
/// defaults, filled with the current fill unless that is None or white (black then).
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
let mut style = CharStyle::default();
|
||||
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
|
||||
style.size = sz.clamp(0.1, 1296.0);
|
||||
@@ -415,13 +429,5 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None if !s.paint.fill.is_none() && s.paint.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE) => s.paint.fill.clone(),
|
||||
None => vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
|
||||
};
|
||||
let mut t = TextObject::point(Point::new(x, y), text, style);
|
||||
if let Some(a) = p.get("area") {
|
||||
let w = f64_or(a, "width", 200.0);
|
||||
let h = f64_or(a, "height", 100.0);
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
|
||||
}
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
|
||||
style
|
||||
}
|
||||
|
||||
@@ -181,7 +181,11 @@ fn use_artboards(p: &Value) -> Result<Option<bool>> {
|
||||
|
||||
/// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds).
|
||||
pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document {
|
||||
let mut d = doc.clone();
|
||||
with_single_artboard(doc.clone(), rect, name)
|
||||
}
|
||||
|
||||
/// `d` with one artboard, `rect`.
|
||||
pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document {
|
||||
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false }];
|
||||
d
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ mod svg;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
|
||||
pub(crate) use encode::{anti_alias, background};
|
||||
pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
use load::err;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image};
|
||||
|
||||
@@ -110,7 +110,7 @@ struct Loaded {
|
||||
}
|
||||
|
||||
/// Points per pixel of a raster image at the resolution it declares (72 ppi when none).
|
||||
fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
|
||||
pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
|
||||
let (x, y) = ppi.unwrap_or((72.0, 72.0));
|
||||
(72.0 / x, 72.0 / y)
|
||||
}
|
||||
|
||||
@@ -910,6 +910,8 @@ mod tests_clip;
|
||||
#[cfg(test)]
|
||||
mod tests_clipboard;
|
||||
#[cfg(test)]
|
||||
mod tests_clipflavours;
|
||||
#[cfg(test)]
|
||||
mod tests_clippaint;
|
||||
#[cfg(test)]
|
||||
mod tests_cmdsplit;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Clipboard flavours (M4.57): PNG, PDF and plain text out; bitmaps, plain text and PDF in; the
|
||||
//! Clipboard Handling preferences.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TextObject};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::clipboard::{PDF, PNG, SVG, TEXT};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
/// A red 40 × 40 pt square, copied.
|
||||
fn copy_red_square(s: &mut Session) {
|
||||
let id = run(s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 40}))["id"].clone();
|
||||
run(s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
run(s, "paint.setStroke", json!({"none": true}));
|
||||
run(s, "select.set", json!({"ids": [id]}));
|
||||
run(s, "edit.copy", json!({}));
|
||||
}
|
||||
|
||||
fn copy_text(s: &mut Session, texts: &[&str]) {
|
||||
let ids: Vec<Value> =
|
||||
texts.iter().enumerate().map(|(i, t)| run(s, "text.create", json!({"x": 20, "y": 40 + 30 * i, "text": t}))["id"].clone()).collect();
|
||||
run(s, "select.set", json!({ "ids": ids }));
|
||||
run(s, "edit.copy", json!({}));
|
||||
}
|
||||
|
||||
fn decode(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The pasted objects (the selection after a paste).
|
||||
fn pasted(s: &Session) -> Vec<vectorcraft_doc::Node> {
|
||||
let st = s.doc().unwrap();
|
||||
st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect()
|
||||
}
|
||||
|
||||
fn text_of(n: &vectorcraft_doc::Node) -> &TextObject {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => t,
|
||||
k => panic!("not type: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_png_of_a_red_square_is_red() {
|
||||
let mut s = session();
|
||||
assert!(run(&mut s, "clipboard.exportPng", json!({}))["dataBase64"].is_null(), "nothing copied");
|
||||
copy_red_square(&mut s);
|
||||
let r = run(&mut s, "clipboard.exportPng", json!({"scale": 2}));
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(80), Some(80)));
|
||||
let img = image::load_from_memory(&decode(&r)).unwrap().to_rgba8();
|
||||
assert_eq!(img.dimensions(), (80, 80));
|
||||
assert_eq!(img.get_pixel(40, 40).0, [255, 0, 0, 255]);
|
||||
assert_eq!(img.get_pixel(2, 77).0, [255, 0, 0, 255], "cropped to the square");
|
||||
for bad in [json!({"scale": 0}), json!({"scale": -1}), json!({"scale": 65})] {
|
||||
assert!(s.execute("clipboard.exportPng", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_pdf_pastes_back_as_vectors() {
|
||||
let mut s = session();
|
||||
assert!(run(&mut s, "clipboard.exportPdf", json!({}))["dataBase64"].is_null());
|
||||
copy_red_square(&mut s);
|
||||
let pdf = run(&mut s, "clipboard.exportPdf", json!({}));
|
||||
assert!(decode(&pdf).starts_with(b"%PDF"));
|
||||
// Plain PDF: no native document inside (the default PDF preset embeds one).
|
||||
let has_native = |b: &[u8]| b.windows(13).any(|w| w == b"/EmbeddedFile");
|
||||
assert!(!has_native(&decode(&pdf)));
|
||||
let saved = run(&mut s, "document.exportPdf", json!({}));
|
||||
assert!(has_native(&decode(&saved)), "the check finds the native document");
|
||||
let mut t = session();
|
||||
let r = run(&mut t, "clipboard.importPdf", json!({"dataBase64": pdf["dataBase64"], "center": [200, 150]}));
|
||||
assert!(r["count"].as_u64().unwrap() >= 1, "{r}");
|
||||
run(&mut t, "edit.pasteInPlace", json!({}));
|
||||
let st = t.doc().unwrap();
|
||||
let b = st.doc.bounds_of(&st.selection.in_paint_order(&st.doc), false).unwrap();
|
||||
assert!((b.center().x - 200.0).abs() < 0.5 && (b.center().y - 150.0).abs() < 0.5, "{b:?}");
|
||||
assert!((b.width() - 40.0).abs() < 0.5 && (b.height() - 40.0).abs() < 0.5, "{b:?}");
|
||||
let mut red = false;
|
||||
for n in pasted(&t) {
|
||||
n.walk(&mut |c| red |= c.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000"));
|
||||
}
|
||||
assert!(red, "the square keeps its red fill");
|
||||
assert!(t.execute("clipboard.importPdf", &json!({"dataBase64": vectorcraft_format::base64_encode(b"not a pdf")})).is_err());
|
||||
assert!(t.execute("clipboard.importPdf", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_image_then_paste_gives_an_image() {
|
||||
let mut s = session();
|
||||
copy_red_square(&mut s);
|
||||
let png = run(&mut s, "clipboard.exportPng", json!({}));
|
||||
let r = run(&mut s, "clipboard.importImage", json!({"dataBase64": png["dataBase64"], "mime": "image/png", "center": [100, 100]}));
|
||||
assert_eq!((r["count"].as_u64(), r["pixelWidth"].as_u64(), r["width"].as_f64()), (Some(1), Some(40), Some(40.0)));
|
||||
let r = run(&mut s, "edit.paste", json!({"center": [300, 200]}));
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
|
||||
let n = &pasted(&s)[0];
|
||||
let NodeKind::Image(im) = &n.kind else { panic!("not an image: {:?}", n.kind) };
|
||||
assert!(im.link.is_none(), "embedded");
|
||||
assert!(s.doc().unwrap().doc.images.contains_key(&im.key), "the blob came along");
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!((b.center().x - 300.0).abs() < 1e-6 && (b.center().y - 200.0).abs() < 1e-6 && (b.width() - 40.0).abs() < 1e-6, "{b:?}");
|
||||
// Not a bitmap.
|
||||
assert!(s.execute("clipboard.importImage", &json!({"dataBase64": png["dataBase64"], "mime": "image/svg+xml"})).is_err());
|
||||
assert!(s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(b"hello")})).is_err());
|
||||
assert!(s.execute("clipboard.importImage", &json!({"dataBase64": "!!"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_text_then_paste_gives_point_text() {
|
||||
let mut s = session();
|
||||
let r = run(&mut s, "clipboard.importText", json!({"text": "Hello\r\nworld\r\n"}));
|
||||
assert_eq!(r["count"], 1);
|
||||
run(&mut s, "edit.paste", json!({"center": [200, 150]}));
|
||||
let n = &pasted(&s)[0];
|
||||
let t = text_of(n);
|
||||
assert!(matches!(t.kind, vectorcraft_doc::TextKind::Point));
|
||||
assert_eq!(t.plain_text(), "Hello\nworld");
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!((b.center().x - 200.0).abs() < 1.0 && (b.center().y - 150.0).abs() < 1.0, "{b:?}");
|
||||
for bad in [json!({}), json!({"text": " \n\n"}), json!({"text": "x".repeat((1 << 20) + 1)})] {
|
||||
assert!(s.execute("clipboard.importText", &bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_only_copies_offer_their_text() {
|
||||
let mut s = session();
|
||||
assert!(run(&mut s, "clipboard.exportText", json!({}))["text"].is_null());
|
||||
copy_text(&mut s, &["One", "Two"]);
|
||||
assert_eq!(run(&mut s, "clipboard.exportText", json!({}))["text"], "One\nTwo");
|
||||
// A group of type is type.
|
||||
run(&mut s, "object.group", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert_eq!(run(&mut s, "clipboard.exportText", json!({}))["text"], "One\nTwo");
|
||||
// Type with a path is not.
|
||||
copy_red_square(&mut s);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "edit.copy", json!({}));
|
||||
assert!(run(&mut s, "clipboard.exportText", json!({}))["text"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_offers_flavours_by_the_preferences() {
|
||||
let mut s = session();
|
||||
assert!(s.clipboard_flavours().is_empty());
|
||||
assert_eq!(run(&mut s, "clipboard.flavours", json!({}))["flavours"], json!([]));
|
||||
copy_red_square(&mut s);
|
||||
let mimes = |s: &Session| s.clipboard_flavours().iter().map(|f| f.mime).collect::<Vec<_>>();
|
||||
// Defaults: Include SVG Code on, PDF off.
|
||||
assert_eq!(mimes(&s), [TEXT, SVG, PNG]);
|
||||
assert_eq!(run(&mut s, "clipboard.flavours", json!({}))["flavours"], json!([TEXT, SVG, PNG]));
|
||||
let f = s.clipboard_flavours();
|
||||
assert!(String::from_utf8_lossy(&f[0].data).contains("<svg") && f[0].data == f[1].data);
|
||||
assert!(f[2].data.starts_with(b"\x89PNG"));
|
||||
run(&mut s, "prefs.set", json!({"key": "copyAsPdf", "value": true}));
|
||||
assert_eq!(mimes(&s), [TEXT, SVG, PDF, PNG]);
|
||||
assert!(s.clipboard_flavours()[2].data.starts_with(b"%PDF"));
|
||||
run(&mut s, "prefs.set", json!({"values": {"copyAsSvg": false, "copyAsPdf": false}}));
|
||||
assert_eq!(mimes(&s), [PNG]);
|
||||
// Type only: its text is the plain text, even without SVG.
|
||||
copy_text(&mut s, &["Words"]);
|
||||
assert_eq!(mimes(&s), [TEXT, PNG]);
|
||||
assert_eq!(s.clipboard_flavours()[0].data, b"Words");
|
||||
run(&mut s, "prefs.set", json!({"key": "copyAsSvg", "value": true}));
|
||||
let f = s.clipboard_flavours();
|
||||
assert_eq!((f[0].data.as_slice(), f[1].mime), (b"Words".as_slice(), SVG));
|
||||
}
|
||||
|
||||
/// The Type tool pastes the text it copied with its formatting, unless pasted text is kept plain.
|
||||
fn paste_copied_range(plain: bool) -> f64 {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let id = run(&mut s, "text.create", json!({"x": 100, "y": 100, "text": "Big small", "size": 10}))["id"].clone();
|
||||
run(&mut s, "text.setRangeStyle", json!({"id": id, "start": 0, "end": 3, "size": 40}));
|
||||
if plain {
|
||||
run(&mut s, "prefs.set", json!({"key": "pasteTextFormatting", "value": "plain"}));
|
||||
}
|
||||
s.select_tool("type", v).unwrap();
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!() };
|
||||
let at = t.xf * Point::new(30.0, -3.0);
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, at.x, at.y), v).unwrap();
|
||||
}
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 3}));
|
||||
let r = run(&mut s, "text.getRange", json!({"id": id, "start": 0, "end": 3}));
|
||||
s.set_tool_option("copy", &r["runs"]);
|
||||
s.tool_key(ToolKey::End, Mods::default(), v).unwrap();
|
||||
s.tool_text("Big", v).unwrap();
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
|
||||
let t = text_of(&n);
|
||||
assert_eq!(t.plain_text(), "Big smallBig");
|
||||
t.runs.last().unwrap().style.size
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_plain_text_preference_drops_pasted_formatting() {
|
||||
assert_eq!(paste_copied_range(false), 40.0, "Keep Formatting");
|
||||
assert_eq!(paste_copied_range(true), 10.0, "Keep Plain Text");
|
||||
}
|
||||
@@ -88,6 +88,7 @@ impl Session {
|
||||
preview_bounds: self.prefs.use_preview_bounds,
|
||||
unit,
|
||||
stroke_unit,
|
||||
paste_plain_text: self.prefs.paste_text_formatting == "plain",
|
||||
};
|
||||
let tool = &mut self.tool;
|
||||
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {
|
||||
|
||||
Reference in New Issue
Block a user