Type: emoji go with type that effects reshape and Expand Appearance

Squashed from #3879 onto main.
This commit is contained in:
bflatastic
2026-10-24 04:49:06 +07:00
parent 94e1fa3ecc
commit c2ad9f8bbb
91 changed files with 519 additions and 168 deletions
+9 -2
View File
@@ -421,8 +421,15 @@ fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
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, placement: Default::default() };
let image = ImageObject {
key: img.key.clone(),
width: w,
height: h,
xf: Affine::scale_non_uniform(sx, sy),
link: None,
placement: Default::default(),
pixels: None,
};
let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(Box::new(image)))], ..Default::default() };
clip.images.insert(img.key, img.blob);
let count = s.load_clipboard(clip, p)?;
+6 -4
View File
@@ -558,10 +558,12 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
let mut listed = vec![];
let m = expanded_node(d, n, brushes, &mut listed);
out.extend(listed.into_iter().map(|(id, _)| id));
if let Some(m) = m
&& let Some(slot) = d.node_mut(id)
{
*slot = m;
if let Some(mut m) = m {
// Art made from type takes its emoji's pixels into the document.
d.adopt_pixels(&mut m);
if let Some(slot) = d.node_mut(id) {
*slot = m;
}
}
}
+9 -1
View File
@@ -1274,7 +1274,15 @@ impl Builder {
let sy = img.height as f64 / f64::from(r.height.max(1));
let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy);
self.doc.images.entry(r.key.clone()).or_insert(r.blob);
Some(NodeKind::Image(Box::new(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() })))
Some(NodeKind::Image(Box::new(ImageObject {
key: r.key,
width: r.width,
height: r.height,
xf,
link: None,
placement: Default::default(),
pixels: None,
})))
}
}
+2 -1
View File
@@ -215,7 +215,8 @@ fn flatten_images(d: &mut Document, images: &[(usize, NodeId)]) -> Result<()> {
let png = flat.png;
let key = super::super::menucmds::unique_key(d, "flattened");
d.images.insert(key.clone(), ImageBlob::new("image/png", png));
let image = ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default() };
let image =
ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default(), pixels: None };
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
let mut group = super::super::rasterfx::clip_group(d, flat.clip, FillRule::NonZero, image);
group.name = src.node(id).and_then(|n| n.name.clone());
+4 -2
View File
@@ -658,8 +658,10 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
let layer = d.layers[0].id;
let id = d.alloc_id();
let xf = Affine::scale_non_uniform(sx, sy);
let mut n =
Node::new(id, NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() })));
let mut n = Node::new(
id,
NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default(), pixels: None })),
);
n.name = Some(name.to_string());
d.images.insert(key, blob);
d.insert(Some(layer), 0, n).map_err(err)?;
@@ -248,6 +248,7 @@ fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
xf: vectorcraft_geom::Affine::scale(20.0),
link: None,
placement: Default::default(),
pixels: None,
};
let (id, layer) = (broken.alloc_id(), broken.layers[0].id);
broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
+9 -1
View File
@@ -1136,7 +1136,15 @@ fn raster_node(d: &mut Document, r: Raster) -> Node {
d.images.insert(key.clone(), r.blob);
let image = Node::new(
d.alloc_id(),
NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })),
NodeKind::Image(Box::new(ImageObject {
key,
width: r.width,
height: r.height,
xf: r.xf,
link: None,
placement: Default::default(),
pixels: None,
})),
);
let Some(path) = r.clip else { return image };
super::rasterfx::clip_group(d, path, FillRule::NonZero, image)
+2
View File
@@ -1334,6 +1334,8 @@ fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> {
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let mut g = vectorcraft_render::effects::expanded_live_group(Some(d), &n);
fix_ids(d, &mut g);
// Outlined type's emoji take their pixels into the document.
d.adopt_pixels(&mut g);
// Outlined type repeats its object's id on its pieces.
vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default());
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
+3 -1
View File
@@ -469,7 +469,7 @@ impl Rasterized {
let key = unique_key(d, "raster");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png));
let id = d.alloc_id();
let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default() };
let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default(), pixels: None };
let node = Node::new(id, NodeKind::Image(Box::new(image)));
match self.clip {
Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node),
@@ -721,6 +721,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale),
link: None,
placement: Default::default(),
pixels: None,
}));
}
Ok(())
@@ -788,6 +789,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))),
link: None,
placement: Default::default(),
pixels: None,
}));
}
Ok(())
+1
View File
@@ -385,6 +385,7 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
xf: Affine::scale_non_uniform(sx, sy),
link,
placement: Default::default(),
pixels: None,
};
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
}
+1 -1
View File
@@ -96,7 +96,7 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings, rich_black: b
let key = unique_key(&out, "print-bitmap");
out.images.insert(key.clone(), ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?));
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() };
let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None };
images.push(Arc::new(Node::new(out.alloc_id(), NodeKind::Image(Box::new(image)))));
}
let mut layer = Node::layer(out.alloc_id(), "Bitmap", LayerColor::Preset(0));
+9 -1
View File
@@ -225,7 +225,15 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let mut image = Node::new(
id,
NodeKind::Image(Box::new(ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() })),
NodeKind::Image(Box::new(ImageObject {
key,
width: img.width,
height: img.height,
xf,
link: None,
placement: Default::default(),
pixels: None,
})),
);
image.name = Some("Raster effect".into());
Some(image)
+1
View File
@@ -92,6 +92,7 @@ mod tests {
xf: Affine::scale(72.0 / ppi),
link: None,
placement: Default::default(),
pixels: None,
})),
)
}
+5 -13
View File
@@ -4,7 +4,7 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{AreaFit, Document, ImageObject, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject};
use vectorcraft_geom::{Affine, PathData, Vec2};
use super::edit::selected_roots;
@@ -559,18 +559,10 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf })));
continue;
}
// Colour bitmap glyphs (emoji) become embedded images, in place; glyphs that show
// the same image share its pixels, under a key of their own if another image has
// taken theirs.
if let Some((image, to_text)) = g.placed_image() {
let mut key = format!("glyph-{}", image.blob.content_key());
if d.images.get(&key).is_some_and(|b| b.bytes != image.blob.bytes) {
key = super::menucmds::unique_key(d, "glyph");
}
d.images.entry(key.clone()).or_insert_with(|| image.blob.clone());
let im =
ImageObject { key, width: image.width, height: image.height, xf: t.xf * to_text, link: None, placement: Default::default() };
children.push(Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))));
// Colour bitmap glyphs (emoji) become embedded images, in place, their pixels taken
// into the document: glyphs that show the same image share them.
if let Some(image) = g.placed_image_node(t.xf) {
children.push(Arc::new(d.reid(&image)));
continue;
}
// COLR glyphs become groups of their layers, in place, with ids of the document's.
+1
View File
@@ -42,6 +42,7 @@ fn red_image(s: &mut Session) -> NodeId {
xf: vectorcraft_geom::Affine::scale(10.0),
link: None,
placement: Default::default(),
pixels: None,
};
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
+9 -1
View File
@@ -1976,7 +1976,15 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node
0,
Node::new(
id,
NodeKind::Image(Box::new(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })),
NodeKind::Image(Box::new(ImageObject {
key: "img1".into(),
width: w,
height: h,
xf,
link: None,
placement: Default::default(),
pixels: None,
})),
),
)?;
sel.set([id]);
+60
View File
@@ -168,3 +168,63 @@ fn the_canvas_draws_svg_glyphs_as_their_art() {
assert_eq!(img.pixel(14, 40), [255, 0, 0, 255], "its own red");
assert_eq!(img.pixel(22, 40), [0, 0, 0, 255], "the type's black fill");
}
/// Expand Appearance of type with an effect keeps its emoji as images, their pixels taken into
/// the document; the PDF of the type before expanding draws them too.
#[test]
fn expand_appearance_keeps_the_emoji_of_type_with_effects() {
use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font, solid_png};
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
let mut s = session();
let r = s
.execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{COLOUR_CHAR}F"), "size": 32, "font": colour_bitmap_family(false)}))
.unwrap();
let t = NodeId(r["id"].as_u64().unwrap());
// Turned, not only moved: type that a Transform only moves stays type in exports.
s.execute("effect.apply", &json!({"ids": [t.0], "effect": "distort.transform", "params": {"rotate": 10}})).unwrap();
let pdf = vectorcraft_pdf::export_with_report(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
assert!(pdf.warnings.is_empty(), "{:?}", pdf.warnings);
assert!(pdf.bytes.windows(14).any(|w| w == b"/Subtype/Image"), "the emoji, in the PDF");
let r = ids(&s.execute("effect.expandAppearance", &json!({})).unwrap());
let mut images = vec![];
for id in r {
node(&s, id).walk(&mut |n| {
if let NodeKind::Image(im) = &n.kind {
images.push((**im).clone());
}
});
}
let d = &s.doc().unwrap().doc;
let glyph = vectorcraft_doc::ImageBlob::png(solid_png(COLOUR_PPEM.into(), COLOUR_LARGE));
assert_eq!(images.len(), 1, "the emoji");
assert!(images[0].pixels.is_none(), "the document holds them");
assert_eq!(d.images[&images[0].key].bytes, glyph.bytes);
vectorcraft_testkit::invariants::check_document(d).unwrap();
}
/// Emoji type in a warp envelope: SVG export writes each emoji's pixels once for all the warp's
/// pieces, and Object › Expand takes them into the document.
#[test]
fn an_envelope_shares_and_expands_the_emoji_of_its_type() {
use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font};
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
let mut s = session();
let r = s
.execute("text.create", &json!({"x": 10, "y": 50, "text": COLOUR_CHAR.to_string(), "size": 32, "font": colour_bitmap_family(false)}))
.unwrap();
s.execute("select.set", &json!({"ids": [r["id"]]})).unwrap();
let e = NodeId(s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 50})).unwrap()["id"].as_u64().unwrap());
let svg = vectorcraft_svg::export_full(&s.doc().unwrap().doc, &Default::default(), None).svg;
assert_eq!(svg.matches("data:image/png").count(), 1, "one copy of the pixels");
s.execute("object.envelope.expand", &json!({})).unwrap();
let mut images = vec![];
node(&s, e).walk(&mut |n| {
if let NodeKind::Image(im) = &n.kind {
images.push((**im).clone());
}
});
let d = &s.doc().unwrap().doc;
assert!(images.len() > 1, "the warp's pieces");
assert!(images.iter().all(|im| im.pixels.is_none() && d.images.contains_key(&im.key)), "the document holds them");
vectorcraft_testkit::invariants::check_document(d).unwrap();
}
+1
View File
@@ -178,6 +178,7 @@ fn image_node(s: &mut Session) -> NodeId {
xf: Default::default(),
link: None,
placement: Default::default(),
pixels: None,
};
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
+1 -1
View File
@@ -122,7 +122,7 @@ fn red_image(s: &mut Session) -> NodeId {
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png));
let id = d.alloc_id();
let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0);
let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default() };
let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default(), pixels: None };
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
Ok(id)
+2 -2
View File
@@ -160,7 +160,7 @@ fn clicking_an_image_samples_its_pixel() {
let l = d.layers[0].id;
let id = d.alloc_id();
let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0);
let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default() };
let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default(), pixels: None };
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string()))
})
.unwrap();
@@ -186,7 +186,7 @@ fn cmyk_doc_with_image(px: &[[u8; 4]]) -> Session {
let l = d.layers[0].id;
let id = d.alloc_id();
let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0);
let im = ImageObject { key: "px".into(), width: w, height: 1, xf, link: None, placement: Default::default() };
let im = ImageObject { key: "px".into(), width: w, height: 1, xf, link: None, placement: Default::default(), pixels: None };
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string()))
})
.unwrap();
+1
View File
@@ -462,6 +462,7 @@ fn relative_paths_are_written_against_the_saved_file() {
xf: Default::default(),
link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))),
placement: Default::default(),
pixels: None,
};
d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap();
let rel = |dest: String| {
+1 -1
View File
@@ -1103,7 +1103,7 @@ fn two_colour_image(s: &mut Session, clear_right: bool, linked: bool) -> NodeId
let id = d.alloc_id();
let xf = vectorcraft_geom::Affine::translate((100.0, 50.0)) * vectorcraft_geom::Affine::scale(2.0);
let link = linked.then(|| vectorcraft_doc::LinkInfo { path: "/tmp/pic.png".into(), ..Default::default() });
let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 8, height: 4, xf, link, placement: Default::default() };
let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 8, height: 4, xf, link, placement: Default::default(), pixels: None };
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
sel.set([id]);
Ok(id)
+1 -1
View File
@@ -890,7 +890,7 @@ fn create_outlines_keeps_an_image_already_under_a_glyph_images_key() {
vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap());
let mut s = session();
let glyph = vectorcraft_doc::ImageBlob::png(solid_png(COLOUR_PPEM.into(), COLOUR_LARGE));
let taken = format!("glyph-{}", glyph.content_key());
let taken = glyph.content_key();
let other = vectorcraft_doc::ImageBlob::png(solid_png(4, [0, 255, 0, 255]));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).images.insert(taken.clone(), other.clone());
s.execute("text.create", &json!({"x": 10, "y": 50, "text": COLOUR_CHAR.to_string(), "size": 32, "font": colour_bitmap_family(false)})).unwrap();
+9 -1
View File
@@ -103,7 +103,15 @@ fn groups_layers_type_images_and_symbols_keep_their_raster_effects() {
let xf = Affine::translate((40.0, 50.0)) * Affine::scale(2.5);
let im = Node::new(
d.alloc_id(),
NodeKind::Image(Box::new(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() })),
NodeKind::Image(Box::new(ImageObject {
key: "pic".into(),
width: 40,
height: 30,
xf,
link: None,
placement: Default::default(),
pixels: None,
})),
);
add(&mut d, with(im, shadow()));
assert_written("image", &d);