mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
The Links panel lists every image (thumbnail, name, page, missing/modified/embedded badge) with Show filters, sorting and thumbnail sizes; its buttons and flyout relink (to a file or a folder), go to, update, edit the original, embed and unembed, show in folder and open Placement Options. Link Info shows format, colour space, location, ppi and effective ppi, dimensions, scale, rotation, size and dates. Edit > Edit Original and the Properties panel's image section reach the same commands. Engine: links.list, links.goTo, links.embed, links.unembed, links.info and links.placementOptions; links.relink and links.update now place the new file as the image's Placement Options say (transforms, bounds, file dimensions, fit, fill, the alignment point, clip to the old bounds). Model: ImageObject.placement. UI: links.editOriginal, links.reveal, ui.placementOptionsDialog; Services.open_file and pick_folder on the desktop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
76 lines
4.2 KiB
Rust
76 lines
4.2 KiB
Rust
//! SVG Options across each other: every styling, id mode and tspan mode reads back, and linked
|
||
//! images are named after their bytes.
|
||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, ImageBlob, ImageObject, Node, NodeKind, TextKind, TextObject, TextRun};
|
||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||
use vectorcraft_svg::{ExportOptions, ImageMode, ObjectIds, Styling, export, export_full, import};
|
||
|
||
/// A red rectangle with an awkward name, point type and area type of two paragraphs each.
|
||
fn doc() -> Document {
|
||
let mut d = Document::new(300.0, 200.0);
|
||
let l = d.layers[0].id;
|
||
let red = Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::solid(Color::BLACK), 2.0);
|
||
let mut a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), red);
|
||
a.name = Some("A \"quoted\" & <odd> 100%".into());
|
||
d.insert(Some(l), usize::MAX, a).unwrap();
|
||
let st = CharStyle { size: 12.0, font_family: "Source Sans 3".into(), fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
|
||
let mut t = TextObject::point(Point::new(20.0, 120.0), "", st.clone());
|
||
t.runs = vec![TextRun { text: "one two\nthree".into(), style: st.clone() }];
|
||
let i = d.alloc_id();
|
||
d.insert(Some(l), usize::MAX, Node::new(i, NodeKind::Text(Box::new(t.clone())))).unwrap();
|
||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 60.0)) };
|
||
t.xf = Affine::translate((20.0, 140.0));
|
||
t.runs = vec![TextRun { text: "para one\npara two".into(), style: st }];
|
||
let i = d.alloc_id();
|
||
d.insert(Some(l), usize::MAX, Node::new(i, NodeKind::Text(Box::new(t)))).unwrap();
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn every_combination_reads_back() {
|
||
let d = doc();
|
||
for styling in [Styling::PresentationAttributes, Styling::InlineStyle, Styling::StyleEntities, Styling::InternalCss] {
|
||
for object_ids in [ObjectIds::LayerNames, ObjectIds::Minimal, ObjectIds::Unique] {
|
||
for (fewer_tspans, minify) in [(false, false), (true, true)] {
|
||
let o = ExportOptions { styling, object_ids, fewer_tspans, minify, ..Default::default() };
|
||
let s = export(&d, &o);
|
||
let back = import(&s).unwrap_or_else(|e| panic!("{o:?}: {e}\n{s}"));
|
||
let mut red = false;
|
||
back.walk(|n| {
|
||
red |= n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.paint == Paint::solid(Color::rgb8(255, 0, 0))));
|
||
});
|
||
assert!(red, "{o:?}: the fill came back\n{s}");
|
||
// Paragraph breaks are new lines, never characters in a line.
|
||
assert!(!s.contains("\n</tspan>") && !s.contains("two\n"), "{o:?}\n{s}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A document with one embedded image under `key`.
|
||
fn image_doc(key: &str, bytes: Vec<u8>) -> Document {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
d.images.insert(key.into(), ImageBlob::new("image/png", bytes));
|
||
let l = d.layers[0].id;
|
||
let im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||
let n = Node::new(d.alloc_id(), NodeKind::Image(im));
|
||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn linked_images_are_named_after_their_bytes() {
|
||
let link = ExportOptions { images: ImageMode::Link, ..Default::default() };
|
||
// Keys such as "raster-1" repeat across documents: two exports into one folder must not clash.
|
||
let a = export_full(&image_doc("raster-1", vec![1, 2, 3]), &link, None);
|
||
let b = export_full(&image_doc("raster-1", vec![4, 5, 6]), &link, None);
|
||
assert_eq!((a.linked.len(), b.linked.len()), (1, 1));
|
||
assert_ne!(a.linked[0].name, b.linked[0].name);
|
||
assert!(a.linked[0].name.ends_with(".png") && a.svg.contains(&format!("href=\"{}\"", a.linked[0].name)), "{}", a.svg);
|
||
// The same bytes under another key are the same file.
|
||
assert_eq!(export_full(&image_doc("other", vec![1, 2, 3]), &link, None).linked[0].name, a.linked[0].name);
|
||
}
|