mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 19:46:58 -06:00
Pictures: WMF and EMF draw, with the metafile reader ported from WordCraft (#317)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a293b68258
commit
f8d54d7af4
@@ -99,11 +99,20 @@ fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_co
|
||||
Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE)))
|
||||
}
|
||||
|
||||
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA.
|
||||
/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF; WMF and EMF drawn at
|
||||
/// [`crate::metafile::DEFAULT_SIDE`]) to straight RGBA.
|
||||
pub fn decode(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
decode_at(bytes, crate::metafile::DEFAULT_SIDE)
|
||||
}
|
||||
|
||||
/// [`decode`], with a metafile drawn with its longer side `side` px.
|
||||
fn decode_at(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
if bytes.len() > 512 << 20 {
|
||||
return None;
|
||||
}
|
||||
if crate::metafile::is_metafile(bytes) {
|
||||
return crate::metafile::rasterize(bytes, side);
|
||||
}
|
||||
let img = image::load_from_memory(bytes).ok()?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w == 0 || h == 0 || (w as u64) * (h as u64) > 400_000_000 {
|
||||
@@ -131,13 +140,13 @@ pub fn decode_for(bytes: &Arc<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Optio
|
||||
}) {
|
||||
return Some(pm);
|
||||
}
|
||||
let Some(mut img) = decode(bytes) else {
|
||||
let target = 1u32 << bucket;
|
||||
let Some(mut img) = decode_at(bytes, target) else {
|
||||
with(|c| {
|
||||
c.bad.insert(addr, bytes.clone());
|
||||
});
|
||||
return None;
|
||||
};
|
||||
let target = 1u32 << bucket;
|
||||
let (w, h) = img.dimensions();
|
||||
if w.max(h) > target.max(16) && w.max(h) > 4096.min(target * 2) {
|
||||
let k = target as f64 / w.max(h) as f64;
|
||||
|
||||
@@ -12,6 +12,7 @@ mod colr;
|
||||
|
||||
mod compound;
|
||||
mod images;
|
||||
mod metafile;
|
||||
mod morph_path;
|
||||
mod morph_text;
|
||||
mod paint;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by
|
||||
//! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go
|
||||
//! through the same crop, tile and recolour path as any other picture.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_metafile::PlacedItem;
|
||||
use kurbo::{Affine, Rect, Shape as _};
|
||||
use vello_cpu::{Pixmap, RenderContext, Resources, peniko};
|
||||
|
||||
/// Longest side of a metafile drawn without a target size (export, thumbnails of the picture).
|
||||
pub const DEFAULT_SIDE: u32 = 1024;
|
||||
/// Largest side drawn.
|
||||
const MAX_SIDE: u32 = 8192;
|
||||
|
||||
/// Is `bytes` a WMF or EMF file?
|
||||
pub fn is_metafile(bytes: &[u8]) -> bool {
|
||||
deckcraft_metafile::is_metafile(bytes)
|
||||
}
|
||||
|
||||
/// Draw the metafile `bytes` into straight RGBA with its longer side `side` px (transparent where
|
||||
/// it draws nothing). `None` when it doesn't parse or has no size.
|
||||
pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
let mut pic = deckcraft_metafile::parse(bytes).ok()?;
|
||||
if !pic.has_size() {
|
||||
return None;
|
||||
}
|
||||
let side = side.clamp(16, MAX_SIDE) as f64;
|
||||
let (pw, ph) = (pic.width as f64, pic.height as f64);
|
||||
let k = side / pw.max(ph);
|
||||
let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let pixmaps: Vec<Option<Arc<Pixmap>>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect();
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let r = Rect::new(0.0, 0.0, w as f64, h as f64);
|
||||
ctx.push_clip_layer(&r.to_path(0.1));
|
||||
for it in pic.place(r, [0.0; 4]) {
|
||||
match it {
|
||||
PlacedItem::Path { path, fill, stroke, even_odd } => {
|
||||
if let Some(f) = fill {
|
||||
ctx.set_paint(rgba(f));
|
||||
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
ctx.fill_path(&path);
|
||||
}
|
||||
// GDI pens: round caps and joins; a cosmetic (zero-width) pen is one pixel.
|
||||
if let Some((s, sw)) = stroke {
|
||||
ctx.set_paint(rgba(s));
|
||||
ctx.set_stroke(kurbo::Stroke::new(sw.max(1.0)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
PlacedItem::Bitmap { index, rect } => {
|
||||
let Some(Some(pm)) = pixmaps.get(index) else { continue };
|
||||
let (bw, bh) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
|
||||
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium),
|
||||
});
|
||||
ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / bw, rect.height() / bh));
|
||||
ctx.fill_rect(&rect);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.pop_layer();
|
||||
ctx.flush();
|
||||
let mut px = vec![0u8; w as usize * h as usize * 4];
|
||||
if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut px) {
|
||||
ctx.render(pm, &mut Resources::new());
|
||||
}
|
||||
// Premultiplied to straight.
|
||||
for p in px.as_chunks_mut::<4>().0 {
|
||||
let a = p[3] as u32;
|
||||
if a != 0 && a != 255 {
|
||||
for c in p.iter_mut().take(3) {
|
||||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
image::RgbaImage::from_raw(w as u32, h as u32, px)
|
||||
}
|
||||
|
||||
fn rgba(c: [u8; 4]) -> peniko::Color {
|
||||
peniko::Color::from_rgba8(c[0], c[1], c[2], c[3])
|
||||
}
|
||||
|
||||
/// Straight RGBA rows to a premultiplied pixmap.
|
||||
fn premul(w: u32, h: u32, px: &[u8]) -> Option<Arc<Pixmap>> {
|
||||
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 {
|
||||
return None;
|
||||
}
|
||||
let data = px
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.take(w as usize * h as usize)
|
||||
.map(|p| {
|
||||
let a = p[3] as u16;
|
||||
let m = |c: u8| ((c as u16 * a + 127) / 255) as u8;
|
||||
vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] }
|
||||
})
|
||||
.collect();
|
||||
Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16)))
|
||||
}
|
||||
@@ -1075,3 +1075,73 @@ fn every_warp_preset_renders_text_inside_the_slide() {
|
||||
assert!(x0 <= x1 && y0 <= y1, "{name}: no ink");
|
||||
}
|
||||
}
|
||||
|
||||
/// A placeable WMF, 2 × 1 inch (2880 × 1440 twips): a solid red brush and a rectangle over the
|
||||
/// left half, nothing on the right.
|
||||
fn half_red_wmf() -> Vec<u8> {
|
||||
fn rec(func: u16, params: &[i16]) -> Vec<u8> {
|
||||
let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec();
|
||||
v.extend(func.to_le_bytes());
|
||||
for p in params {
|
||||
v.extend(p.to_le_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
// CREATEBRUSHINDIRECT (solid, COLORREF 0x000000FF = red), SELECTOBJECT 0, RECTANGLE (bottom,
|
||||
// right, top, left).
|
||||
let body = [rec(0x02FC, &[0, 0xFF, 0, 0]), rec(0x012D, &[0]), rec(0x041B, &[1440, 1440, 0, 0]), rec(0, &[])].concat();
|
||||
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
|
||||
f.extend(0u16.to_le_bytes());
|
||||
for v in [0i16, 0, 2880, 1440] {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
f.extend(1440u16.to_le_bytes());
|
||||
f.extend(0u32.to_le_bytes());
|
||||
f.extend(0u16.to_le_bytes());
|
||||
let total = (f.len() + 18 + body.len()) / 2;
|
||||
for v in [1u16, 9, 0x300] {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
f.extend((total as u32).to_le_bytes());
|
||||
f.extend(16u16.to_le_bytes());
|
||||
f.extend(0u32.to_le_bytes());
|
||||
f.extend(0u16.to_le_bytes());
|
||||
f.extend(body);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_pictures_draw_their_vectors() {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf());
|
||||
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
for scale in [0.5, 1.0, 3.0] {
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale, ..Default::default() });
|
||||
let at = |x: f64, y: f64| img.pixel((x * scale) as u32, (y * scale) as u32);
|
||||
let left = at(200.0, 200.0);
|
||||
assert!(left[0] > 200 && left[1] < 60 && left[2] < 60, "left half red at {scale}: {left:?}");
|
||||
let right = at(400.0, 200.0);
|
||||
assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "right half empty at {scale}: {right:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_metafiles_draw_nothing_and_never_panic() {
|
||||
let wmf = half_red_wmf();
|
||||
let mut cases: Vec<Vec<u8>> = (0..wmf.len()).step_by(3).map(|n| wmf[..n].to_vec()).collect();
|
||||
cases.push(vec![1, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
let mut huge = wmf.clone();
|
||||
huge[10] = 0xFF;
|
||||
huge[11] = 0x7F;
|
||||
cases.push(huge);
|
||||
for bytes in cases {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("image1.emf", "image/x-emf", bytes);
|
||||
let fill = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let _ = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user