mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 00:40:57 -06:00
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>
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 };
|
||
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);
|
||
}
|