vectorcraft/crates/engine/src/cmd/docinfo.rs

223 lines
9.3 KiB
Rust

//! Document Info panel data and Object → Make Pixel Perfect.
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Rect};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "document.info",
"Document Info",
["Window", "Document Info"],
None,
"{selectionOnly?} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …}",
has_doc,
info
),
cmd!(
"object.makePixelPerfect",
"Make Pixel Perfect",
["Object"],
None,
"{ids?} snap each object's edges to the pixel grid (odd stroke weights on half pixels) and round stroke weights",
has_selection,
pixel_perfect
),
]
}
fn paint_kind(p: &Paint) -> Option<&'static str> {
match p {
Paint::Gradient(_) => Some("gradients"),
Paint::Pattern { .. } => Some("patterns"),
_ => None,
}
}
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let d = &st.doc;
let selection_only = bool_or(p, "selectionOnly", false);
let roots: Vec<&Node> =
if selection_only { st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() } else { d.layers.iter().map(|l| &**l).collect() };
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
let mut fonts = BTreeSet::new();
let mut images: BTreeMap<String, Value> = BTreeMap::new();
let mut symbols = BTreeSet::new();
let mut spot = BTreeSet::new();
let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() };
for r in roots {
r.walk(&mut |n| {
let k = match &n.kind {
NodeKind::Layer { .. } => None,
NodeKind::Group { clip: true, .. } => Some("clipGroups"),
NodeKind::Group { .. } => Some("groups"),
NodeKind::Path { guide: true, .. } => Some("guides"),
NodeKind::Path { .. } => Some("paths"),
NodeKind::Compound { .. } => Some("compoundPaths"),
NodeKind::Text(t) => {
for run in &t.runs {
fonts.insert(format!("{} {}", run.style.font_family, run.style.font_style));
}
Some("textObjects")
}
NodeKind::Image(im) => {
images.insert(im.key.clone(), json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link }));
Some("images")
}
NodeKind::SymbolInstance { symbol, .. } => {
symbols.insert(symbol.clone());
Some("symbolInstances")
}
NodeKind::Blend { .. } => Some("blends"),
NodeKind::Envelope { .. } => Some("envelopes"),
NodeKind::Mesh(_) => Some("meshes"),
NodeKind::Repeat(_) => Some("repeats"),
};
if let Some(k) = k {
*counts.entry(k).or_default() += 1;
}
for item in &n.appearance.items {
let paint = match item {
vectorcraft_doc::AppearanceItem::Fill(f) => &f.paint,
vectorcraft_doc::AppearanceItem::Stroke(s) => &s.paint,
};
if let Some(k) = paint_kind(paint) {
*counts.entry(k).or_default() += 1;
}
if let Paint::Solid { swatch: Some(name), .. } = paint
&& d.swatch(name).is_some_and(|s| s.spot)
{
spot.insert(name.clone());
}
}
if n.mask.is_some() {
*counts.entry("opacityMasks").or_default() += 1;
}
if !n.appearance.effects.is_empty() {
*counts.entry("liveEffects").or_default() += 1;
}
if selection_only
&& let Some(g) = super::style::linked_style(d, n)
&& !styles.contains(&g.name.as_str())
{
styles.push(&g.name);
}
});
}
Ok(json!({
"document": document_summary(d),
"objects": counts,
"fonts": fonts,
"images": images,
"symbols": symbols,
"spotColors": spot,
"swatches": d.swatches_iter().count(),
"graphicStyles": d.graphic_styles.len(),
"graphicStyleNames": styles,
"characterStyles": d.char_styles.len(),
"paragraphStyles": d.para_styles.len(),
"patterns": d.patterns.len(),
}))
}
fn document_summary(d: &Document) -> Value {
let u = d.units.points();
json!({
"title": d.title,
"colorMode": match d.color_mode { ColorMode::Rgb => "RGB", ColorMode::Cmyk => "CMYK" },
"units": d.units.label(),
"artboards": d.artboards.iter().map(|a| json!({ "name": a.name, "width": a.rect.width() / u, "height": a.rect.height() / u })).collect::<Vec<_>>(),
"rasterEffectsPpi": d.raster_effects_ppi,
})
}
/// The affine snapping `b` to whole pixels (`half`: edges on half pixels, for odd stroke weights).
fn snap_xf(b: Rect, half: bool) -> Affine {
let off = if half { 0.5 } else { 0.0 };
let snap = |v: f64| (v - off).round() + off;
let (x0, y0) = (snap(b.x0), snap(b.y0));
// Keep at least one pixel of size; widths snap to whole pixels.
let (w, h) = (b.width().round().max(if b.width() > 0.0 { 1.0 } else { 0.0 }), b.height().round().max(if b.height() > 0.0 { 1.0 } else { 0.0 }));
let sx = if b.width() > 1e-9 { w / b.width() } else { 1.0 };
let sy = if b.height() > 1e-9 { h / b.height() } else { 1.0 };
Affine::translate((x0, y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-b.x0, -b.y0))
}
fn pixel_perfect(s: &mut Session, p: &Value) -> Result<Value> {
let ids: Vec<NodeId> = if p.get("ids").is_some() { targets(s, p)? } else { super::edit::selected_roots(s)? };
let n = ids.len();
s.edit("Make Pixel Perfect", |d, _| {
for id in &ids {
let Some(node) = d.node_mut(*id) else { continue };
// Whole-pixel stroke weights; odd ones centre on half pixels.
let mut odd = false;
for item in &mut node.appearance.items {
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item
&& st.width > 0.0
{
st.width = st.width.round().max(1.0);
odd |= st.width as i64 % 2 == 1 && st.align == vectorcraft_doc::StrokeAlign::Center;
}
}
if let Some(b) = node.geometric_bounds() {
node.transform(snap_xf(b, odd), false);
}
}
Ok(())
})?;
Ok(json!({ "count": n }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn info_counts_objects_fonts_and_selection_only() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
s.execute(
"paint.setFill",
&json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}}),
)
.unwrap();
s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap();
let i = s.execute("document.info", &json!({})).unwrap();
assert_eq!(
(i["objects"]["paths"].as_u64(), i["objects"]["textObjects"].as_u64(), i["objects"]["gradients"].as_u64()),
(Some(1), Some(1), Some(1))
);
assert_eq!(i["fonts"][0], "Source Sans 3 Regular");
assert_eq!(i["document"]["artboards"][0]["width"], 200.0);
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap();
assert!(i["objects"]["textObjects"].is_null() && i["objects"]["paths"] == 1);
}
#[test]
fn pixel_perfect_snaps_edges_and_strokes() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap();
s.execute("stroke.set", &json!({"weight": 0.8})).unwrap();
s.execute("object.makePixelPerfect", &json!({})).unwrap();
let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone();
let b = n.geometric_bounds().unwrap();
// 1 pt (odd) stroke → edges on half pixels, whole-pixel size.
assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.5, 20.5, 30.0, 10.0));
assert_eq!(n.appearance.stroke_width(), 1.0);
s.execute("stroke.set", &json!({"weight": 2})).unwrap();
s.execute("object.makePixelPerfect", &json!({})).unwrap();
let b = s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
assert_eq!((b.x0.fract(), b.y0.fract()), (0.0, 0.0));
}
}