Files
vectorcraft/crates/engine/src/cmd/docinfo.rs
T
bflatasticandClaude Opus 5.5 2e5ec7b251 M4.53: Linked images remember their file and keep a preview; missing links ask Replace or Ignore, modified ones follow Update Links
A linked image (`ImageObject.link`, now a LinkInfo: absolute path, path relative to the document,
size, modification time, hash, page; old path strings still load) keeps a low-resolution preview
(`ImageBlob.proxy`, at most 256 px a side) that a native save writes in place of the pixels of
images only linked images show. Opening a native document reads the linked files again, looking
for each at its path, then relative to the document and by name in its folder: missing files
show their preview, modified ones are read again only with Update Links: Automatically.
`document.open` reports `missingLinks`, `modifiedLinks` and `updatedLinks`.

links.check reports each link's status, links.update reads modified files again and
links.relink points images at another file or at same-named files in a folder (each keeps its
bounds; one undo step). In the app, a document opened without its linked files asks about each
(Replace…, Ignore, Apply to All), then offers to update modified links when the preference asks.
The renderer caches previews apart from the file's pixels; the Eyedropper samples previews in
the object's pixel space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:07:10 +07:00

226 lines
9.4 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.as_ref().map(|l| &l.path) }),
);
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));
}
}