From 9efde43f1d827b95b838b75cc5d310362a9814f7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 04:20:33 -0800 Subject: [PATCH 1/7] Metafile import: EMF and WMF are read through craft-metafile, so EMF+ pictures open EMF and WMF import now plays the records with the shared craft-metafile reader (storytold/craft-libs#778, at the craft-libs rev the SVG, EPS and DXF crates already use) and maps its picture onto the document here: paths with their fills, strokes, caps, joins, miter limits and dashes, clipping groups, images and point type. The EMF and WMF record players in crates/metafile/src/import and the DIB decoder they used are removed. The writers are unchanged, and the PICT reader keeps its own player. What changes for a file being opened, placed or pasted, and for an EPS file's Windows metafile preview (read when its PostScript can't be): - An EMF that draws with EMF+ records is read from them. It opened empty, with a warning that EMF+ is not read. A file with both kinds of record is read from its EMF+ records too. - Hatched and pattern brushes fill with the average color of the brush's tile, translucent where the tile has gaps. Hatches filled with the hatch color and pattern brushes with gray. - Translucent fills, strokes and EMF+ text keep their alpha as opacity. - Text keeps a stretch across from the world transform; it kept only the turn. Text the file clips to a rectangle is clipped to it, and an underlined or struck-through font gives underlined or struck-through type. - A clip path whose bounding box holds the whole picture is kept unless it is that rectangle; a diamond or a bow tie around the picture was dropped. - Images drawn with SRCAND, SRCPAINT or SRCINVERT are drawn as they look over white, and images drawn with other raster operations are drawn as a plain copy. They were left out with a warning. - Excluded clip rectangles and the other clip region modes are applied. A clip path combined with RGN_OR, RGN_XOR or RGN_DIFF is ignored without the warning it gave. - An image past the reader's size or pixel limits, or one that can't be decoded, is left out without a warning of its own. A file past the reader's limits (more than 32 nested clip paths, 200,000 drawn items, 256 MiB held) is read up to that point without a warning. - An EMF record whose size is not a multiple of 4 ends the file, with the "ends early" warning. Tests: an EMF+ only picture imports its fill (it failed before), a dual EMF+ file draws its translucent EMF+ fill and not its fallback, hatch and pattern brush colors, clipped and underlined text, excluded clip rectangles, a shared clipping group, an odd record size, WMF at 1440 units an inch without a placeable header and at its own units with an inverted box, an EMF without a frame, diamond and bow-tie clips, text with a stretch, clips nested past the stack and 200,000 clip records; the fuzz test gains random EMF+ records. --- Cargo.lock | 12 + Cargo.toml | 2 + crates/engine/tests/import_fuzz.rs | 18 + crates/metafile/Cargo.toml | 3 +- crates/metafile/src/bytes.rs | 52 +-- crates/metafile/src/dib.rs | 288 +----------- crates/metafile/src/import.rs | 632 +++----------------------- crates/metafile/src/import/emf.rs | 392 ---------------- crates/metafile/src/import/pict.rs | 6 +- crates/metafile/src/import/picture.rs | 523 +++++++++++++++++++++ crates/metafile/src/import/wmf.rs | 338 -------------- crates/metafile/src/lib.rs | 13 +- crates/metafile/src/tests/import.rs | 304 ++++++++++++- docs/mcp.md | 9 +- docs/target-app-parity.md | 1 + 15 files changed, 948 insertions(+), 1645 deletions(-) delete mode 100644 crates/metafile/src/import/emf.rs create mode 100644 crates/metafile/src/import/picture.rs delete mode 100644 crates/metafile/src/import/wmf.rs diff --git a/Cargo.lock b/Cargo.lock index f3e85a08a7..32fcfc62ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1043,6 +1043,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-metafile" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=6d620e971e820b517b11a99cad040ebac670e1e2#6d620e971e820b517b11a99cad040ebac670e1e2" +dependencies = [ + "encoding_rs", + "image", + "kurbo 0.13.1", + "thiserror 2.0.21", +] + [[package]] name = "craft-pdf" version = "0.1.0" @@ -6681,6 +6692,7 @@ dependencies = [ name = "vectorcraft-metafile" version = "0.24.1" dependencies = [ + "craft-metafile", "image", "kurbo 0.13.1", "vectorcraft-brush", diff --git a/Cargo.toml b/Cargo.toml index c7856d589e..610e2b462c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,8 @@ craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } # DXF tags, reader and writer (craft-libs#778, #926, #927). craft-dxf = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } +# WMF, EMF and EMF+ reader (craft-libs#778). +craft-metafile = { git = "https://github.com/storytold/craft-libs", rev = "6d620e971e820b517b11a99cad040ebac670e1e2" } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 79178d6233..51615e70c8 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2933,6 +2933,24 @@ proptest! { survive_metafile("WMF records", "x.wmf", &wmf_of(&records))?; } + #[test] + fn random_emf_plus_records_never_panic(records in prop::collection::vec((0x4001u16..0x403B, any::(), arb_body()), 0..32)) { + // One comment of EMF+ records (type, flags, data) after an EmfPlusHeader. + let mut plus = vec![]; + let header = (0x4001u16, 0u16, [0xDBC0_1002u32, 0, 96, 96].iter().flat_map(|v| v.to_le_bytes()).collect::>()); + for (kind, flags, data) in std::iter::once(&header).chain(&records) { + plus.extend(kind.to_le_bytes()); + plus.extend(flags.to_le_bytes()); + plus.extend((12 + data.len() as u32).to_le_bytes()); + plus.extend((data.len() as u32).to_le_bytes()); + plus.extend(data); + } + let mut comment = (4 + plus.len() as u32).to_le_bytes().to_vec(); + comment.extend(b"EMF+"); + comment.extend(plus); + survive_metafile("EMF+ records", "x.emf", &emf_of(&[(70, comment)]))?; + } + #[test] fn pasted_metafiles_never_panic(which in 0usize..2, cut in 0usize..200_000, edits in prop::collection::vec((0usize..200_000, any::()), 0..12)) { paste_from_elsewhere("clipboard.importEmf", json!({"dataBase64": mutated_b64(metafile_samples()[which].clone(), cut, &edits)}))?; diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml index 01b5d6434a..9fdf99d732 100644 --- a/crates/metafile/Cargo.toml +++ b/crates/metafile/Cargo.toml @@ -3,7 +3,7 @@ name = "vectorcraft-metafile" version.workspace = true edition.workspace = true license.workspace = true -description = "VectorCraft metafiles: hand-written EMF and WMF writers and readers, and a QuickDraw picture (PICT) reader" +description = "VectorCraft metafiles: hand-written EMF and WMF writers, EMF, EMF+ and WMF import through craft-metafile, and a QuickDraw picture (PICT) reader" [lints] workspace = true @@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true } vectorcraft-effects = { workspace = true } vectorcraft-brush = { workspace = true } vectorcraft-text = { workspace = true } +craft-metafile = { workspace = true } kurbo = { workspace = true } image = { workspace = true } diff --git a/crates/metafile/src/bytes.rs b/crates/metafile/src/bytes.rs index ddf534b793..133a476866 100644 --- a/crates/metafile/src/bytes.rs +++ b/crates/metafile/src/bytes.rs @@ -1,5 +1,4 @@ -//! Little-endian values: the buffer both writers build records in, and a reader that never reads -//! past its data (every read is an `Option`). +//! Little-endian values: the buffer both writers build records in. /// Bytes being written. #[derive(Default)] @@ -45,52 +44,3 @@ impl Out { } } } - -/// Sequential little-endian reads from a slice. -#[derive(Clone, Copy)] -pub(crate) struct Reader<'a> { - data: &'a [u8], - pos: usize, -} - -impl<'a> Reader<'a> { - pub fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0 } - } - pub fn bytes(&mut self, n: usize) -> Option<&'a [u8]> { - let b = self.data.get(self.pos..self.pos.checked_add(n)?)?; - self.pos += n; - Some(b) - } - fn array(&mut self) -> Option<[u8; N]> { - self.bytes(N)?.try_into().ok() - } - pub fn u16(&mut self) -> Option { - self.array().map(u16::from_le_bytes) - } - pub fn i16(&mut self) -> Option { - self.array().map(i16::from_le_bytes) - } - pub fn u32(&mut self) -> Option { - self.array().map(u32::from_le_bytes) - } - pub fn i32(&mut self) -> Option { - self.array().map(i32::from_le_bytes) - } - pub fn f32(&mut self) -> Option { - self.array().map(f32::from_le_bytes) - } - pub fn skip(&mut self, n: usize) -> Option<()> { - self.bytes(n).map(drop) - } - /// The bytes not read yet. - pub fn rest(&self) -> &'a [u8] { - self.data.get(self.pos..).unwrap_or_default() - } -} - -/// `len` bytes of `data` from `offset` (record-relative offsets read from untrusted files). -pub(crate) fn slice(data: &[u8], offset: u32, len: u32) -> Option<&[u8]> { - let start = usize::try_from(offset).ok()?; - data.get(start..start.checked_add(usize::try_from(len).ok()?)?) -} diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs index 4aeb5b4381..9bc720f435 100644 --- a/crates/metafile/src/dib.rs +++ b/crates/metafile/src/dib.rs @@ -1,28 +1,22 @@ //! Device-independent bitmaps (DIBs), the pixels inside image records: written bottom-up as 24-bit -//! (opaque, composited over white) or 32-bit premultiplied BGRA (AlphaBlend's source); read from 1, -//! 4, 8, 16, 24 and 32-bit uncompressed and bit-field bitmaps and from embedded JPEG and PNG. +//! (opaque, composited over white) or 32-bit premultiplied BGRA (AlphaBlend's source). use std::io::Cursor; -use crate::bytes::Reader; - /// Most pixels along a side of an image the writers store (larger ones are scaled down). const MAX_SIDE: u32 = 8192; /// Most pixels an image the writers store holds. const MAX_PIXELS: u64 = 1 << 24; -/// Most pixels along a side, and in all, of a bitmap the reader decodes. +/// Most pixels along a side, and in all, of a bitmap the PICT reader decodes. pub(crate) const MAX_READ_SIDE: u32 = 1 << 15; pub(crate) const MAX_READ_PIXELS: u64 = 1 << 26; /// Most pixels the bitmaps of one file may decode to together (four of the largest one), so a small /// file of many image records can't cost minutes of decoding (#3600). pub(crate) const MAX_FILE_PIXELS: u64 = 1 << 28; -/// What [`decode`] says of a bitmap past what is left of the file's budget. +/// What [`charge`] says of a bitmap past what is left of the file's budget. pub(crate) const OVER_BUDGET: &str = "the bitmaps hold more pixels than one file may"; const BI_RGB: u32 = 0; -const BI_BITFIELDS: u32 = 3; -const BI_JPEG: u32 = 4; -const BI_PNG: u32 = 5; /// Pixels with straight (not premultiplied) alpha, row by row from the top. #[derive(Clone, Debug, PartialEq)] @@ -121,48 +115,6 @@ pub(crate) fn bgra32(img: &Rgba, opacity: f32) -> (Vec, Vec) { (header(img.width, img.height, 32, bits.len()), bits) } -/// How the fourth byte of 32-bit pixels reads. -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum Alpha { - /// Unused (StretchDIBits and BitBlt ignore it): every pixel opaque. - Ignore, - /// Premultiplied alpha (AlphaBlend with per-pixel alpha). - Premultiplied, -} - -/// A colour channel of `v` under `mask`, scaled to 0–255. -fn channel(v: u32, mask: u32) -> u8 { - if mask == 0 { - return 0; - } - let shift = mask.trailing_zeros(); - let max = u64::from(mask >> shift); - let x = u64::from((v & mask) >> shift); - ((x * 255 + max / 2) / max.max(1)).min(255) as u8 -} - -/// PNG `bytes` without its compressed or text ancillary chunks (`iCCP`, `zTXt`, `iTXt`, `tEXt`), -/// which the pixels don't need and which a decoder expands when it is made. Other bytes, and a PNG -/// whose chunks don't read to the end, are returned as they are. -fn png_without_metadata(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> { - const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; - if !bytes.starts_with(SIGNATURE) { - return bytes.into(); - } - let mut out = SIGNATURE.to_vec(); - let mut at = SIGNATURE.len(); - while at < bytes.len() { - let (Some(len), Some(kind)) = (bytes.get(at..at + 4), bytes.get(at + 4..at + 8)) else { return bytes.into() }; - let len = u32::from_be_bytes([len[0], len[1], len[2], len[3]]) as usize; - let Some(end) = at.checked_add(12).and_then(|n| n.checked_add(len)).filter(|end| *end <= bytes.len()) else { return bytes.into() }; - if !matches!(kind, b"iCCP" | b"zTXt" | b"iTXt" | b"tEXt") { - out.extend_from_slice(&bytes[at..end]); - } - at = end; - } - out.into() -} - /// Take a bitmap of `w` × `h` from what is `left` of the file's budget, before it is decoded. pub(crate) fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> { let n = u64::from(w) * u64::from(h); @@ -170,215 +122,24 @@ pub(crate) fn charge(w: u32, h: u32, left: &mut u64) -> Result<(), String> { Ok(()) } -/// Read a DIB from its BITMAPINFO (`bmi`: the header and colour table) and pixel `bits`, taking its -/// pixels from what is `left` of the file's budget ([`MAX_FILE_PIXELS`]). -pub(crate) fn decode(bmi: &[u8], bits: &[u8], alpha: Alpha, left: &mut u64) -> Result { - let bad = || "a bitmap is damaged".to_string(); - let mut r = Reader::new(bmi); - let size = r.u32().ok_or_else(bad)?; - let (width, height, bpp, compression, colors, palette_entry, masks_at) = if size == 12 { - // BITMAPCOREHEADER: 16-bit sizes, RGB triples. - let w = i32::from(r.u16().ok_or_else(bad)?); - let h = i32::from(r.u16().ok_or_else(bad)?); - r.skip(2).ok_or_else(bad)?; - let bpp = r.u16().ok_or_else(bad)?; - (w, h, bpp, BI_RGB, 0, 3, 0) - } else if size >= 40 { - let w = r.i32().ok_or_else(bad)?; - let h = r.i32().ok_or_else(bad)?; - r.skip(2).ok_or_else(bad)?; - let bpp = r.u16().ok_or_else(bad)?; - let c = r.u32().ok_or_else(bad)?; - r.skip(12).ok_or_else(bad)?; - let used = r.u32().ok_or_else(bad)?; - (w, h, bpp, c, used, 4, 40) - } else { - return Err(bad()); - }; - if compression == BI_JPEG || compression == BI_PNG { - // The size comes from the image header, so an over-limit bitmap is refused before any pixels are decoded. - // A PNG's compressed ICC profile and text chunks expand when its decoder is made, and - // nothing here uses them: they are left out before the size is read and the pixels decoded. - let bits = png_without_metadata(bits); - let (w, h) = image::ImageReader::new(Cursor::new(&*bits)).with_guessed_format().map_err(|_| bad())?.into_dimensions().map_err(|_| bad())?; - if w > MAX_READ_SIDE || h > MAX_READ_SIDE || u64::from(w) * u64::from(h) > MAX_READ_PIXELS { - return Err("a bitmap is too large to read".into()); - } - charge(w, h, left)?; - let img = image::load_from_memory(&bits).map_err(|_| bad())?.to_rgba8(); - return Ok(Rgba { width: w, height: h, pixels: img.into_raw() }); - } - if compression != BI_RGB && compression != BI_BITFIELDS { - return Err("compressed (RLE) bitmaps can't be read yet".into()); - } - let top_down = height < 0; - let (w, h) = (width.unsigned_abs(), height.unsigned_abs()); - if w == 0 || h == 0 { - return Err("a bitmap is empty".into()); - } - if w > MAX_READ_SIDE || h > MAX_READ_SIDE || u64::from(w) * u64::from(h) > MAX_READ_PIXELS { - return Err("a bitmap is too large to read".into()); - } - let header_len = size as usize; - // Bit-field masks follow a plain 40-byte header, or sit inside the larger ones. - let fields = compression == BI_BITFIELDS; - let masks: Option<[u32; 4]> = fields.then(|| { - let mut m = Reader::new(bmi.get(masks_at..).unwrap_or_default()); - let rgb = [m.u32().unwrap_or(0), m.u32().unwrap_or(0), m.u32().unwrap_or(0)]; - // An alpha mask comes with BITMAPV3INFOHEADER and later. - let a = if header_len >= 56 { m.u32().unwrap_or(0) } else { 0 }; - [rgb[0], rgb[1], rgb[2], a] - }); - let palette_at = header_len + if fields && header_len == 40 { 12 } else { 0 }; - let bpp_u = u32::from(bpp); - let palette: Vec<[u8; 3]> = if bpp_u <= 8 { - let n = if colors == 0 { 1usize << bpp_u } else { (colors as usize).min(256) }; - bmi.get(palette_at..) - .unwrap_or_default() - .chunks_exact(palette_entry) - .take(n) - .map(|e| [e.get(2).copied().unwrap_or(0), e.get(1).copied().unwrap_or(0), e.first().copied().unwrap_or(0)]) - .collect() - } else { - vec![] - }; - if !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) { - return Err(format!("{bpp}-bit bitmaps can't be read")); - } - let row = stride(w, bpp_u).ok_or_else(bad)?; - if bits.len() < row.checked_mul(h as usize).ok_or_else(bad)? { - return Err(bad()); - } - charge(w, h, left)?; - let (mr, mg, mb, ma) = match (masks, bpp) { - (Some([r, g, b, a]), _) => (r, g, b, a), - (None, 16) => (0x7c00, 0x03e0, 0x001f, 0), - _ => (0x00ff_0000, 0x0000_ff00, 0x0000_00ff, 0xff00_0000), - }; - let mut pixels = Vec::with_capacity(w as usize * h as usize * 4); - for y in 0..h as usize { - let src_row = if top_down { y } else { h as usize - 1 - y }; - let line = bits.get(src_row * row..src_row * row + row).ok_or_else(bad)?; - for x in 0..w as usize { - let px: [u8; 4] = match bpp { - 1 | 4 | 8 => { - let bit = x * bpp_u as usize; - let byte = line.get(bit / 8).copied().unwrap_or(0); - let idx = (byte >> (8 - bpp_u as usize - bit % 8)) & ((1u16 << bpp_u) - 1) as u8; - let [r, g, b] = palette.get(idx as usize).copied().unwrap_or([0, 0, 0]); - [r, g, b, 255] - } - 16 => { - let v = u32::from(u16::from_le_bytes([line.get(x * 2).copied().unwrap_or(0), line.get(x * 2 + 1).copied().unwrap_or(0)])); - [channel(v, mr), channel(v, mg), channel(v, mb), 255] - } - 24 => { - let p = line.get(x * 3..x * 3 + 3).unwrap_or(&[0, 0, 0]); - [p[2], p[1], p[0], 255] - } - _ => { - let p = line.get(x * 4..x * 4 + 4).unwrap_or(&[0, 0, 0, 0]); - let v = u32::from_le_bytes([p[0], p[1], p[2], p[3]]); - let a = if alpha == Alpha::Premultiplied && ma != 0 { channel(v, ma) } else { 255 }; - let un = - |c: u8| if alpha == Alpha::Premultiplied && a < 255 { (u32::from(c) * 255 / u32::from(a.max(1))).min(255) as u8 } else { c }; - [un(channel(v, mr)), un(channel(v, mg)), un(channel(v, mb)), if alpha == Alpha::Premultiplied { a } else { 255 }] - } - }; - pixels.extend_from_slice(&px); - } - } - Ok(Rgba { width: w, height: h, pixels }) -} - -/// A packed DIB (a BITMAPINFO and its bits, one after the other, as WMF records hold them) split -/// into the two. -pub(crate) fn split(packed: &[u8]) -> Option<(&[u8], &[u8])> { - let mut r = Reader::new(packed); - let size = r.u32()? as usize; - let (bpp, compression, used, entry) = if size == 12 { - r.skip(6)?; - (r.u16()?, BI_RGB, 0, 3) - } else { - r.skip(10)?; - let bpp = r.u16()?; - let c = r.u32()?; - r.skip(12)?; - (bpp, c, r.u32()? as usize, 4) - }; - let masks = if compression == BI_BITFIELDS && size == 40 { 12 } else { 0 }; - let colors = if used > 0 { - used.min(256) - } else if bpp <= 8 { - 1usize << bpp - } else { - 0 - }; - let len = size.checked_add(masks)?.checked_add(colors * entry)?; - Some((packed.get(..len)?, packed.get(len..)?)) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn a_pngs_compressed_metadata_is_left_out_before_it_is_decoded() { - use image::ImageEncoder; - let mut png = vec![]; - let mut encoder = image::codecs::png::PngEncoder::new(&mut png); - encoder.set_icc_profile(vec![0; 1 << 20]).unwrap(); - encoder.write_image(&[10, 20, 30], 1, 1, image::ExtendedColorType::Rgb8).unwrap(); - assert!(png.windows(4).any(|w| w == b"iCCP")); - let stripped = png_without_metadata(&png); - assert!(!stripped.windows(4).any(|w| w == b"iCCP") && stripped.len() < png.len()); - let img = image::load_from_memory(&stripped).unwrap().to_rgba8(); - assert_eq!(img.get_pixel(0, 0).0, [10, 20, 30, 255]); - // Not a PNG, or a PNG cut short: unchanged. - assert_eq!(&*png_without_metadata(b"BM...."), b"BM...."); - assert_eq!(&*png_without_metadata(&png[..png.len() - 3]), &png[..png.len() - 3]); - } - fn sample() -> Rgba { // 3 × 2: red, green, blue / half-transparent black, transparent, white. let pixels = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255]; Rgba { width: 3, height: 2, pixels } } - /// A whole file's budget. - fn budget() -> u64 { - MAX_FILE_PIXELS - } - - /// A BITMAPINFOHEADER of an embedded PNG. - fn png_header() -> Vec { - let mut bmi = vec![0u8; 40]; - bmi[..4].copy_from_slice(&40u32.to_le_bytes()); - bmi[16..20].copy_from_slice(&BI_PNG.to_le_bytes()); - bmi - } - - #[test] - fn oversized_png_is_refused_from_its_header() { - // A width over the limit in the IHDR chunk: decoding the pixels would fail with a different error. - let png = crate::tests::png_claiming(MAX_READ_SIDE + 1, 1); - let err = decode(&png_header(), &png, Alpha::Ignore, &mut budget()).unwrap_err(); - assert_eq!(err, "a bitmap is too large to read"); - } - #[test] fn bitmaps_share_the_file_budget() { // #3600: each bitmap is charged before it is decoded, and one past what is left is refused. - let (bmi, bits) = rgb24(&sample(), 1.0); let mut left = 6 + 5; - assert!(decode(&bmi, &bits, Alpha::Ignore, &mut left).is_ok()); + assert!(charge(3, 2, &mut left).is_ok()); assert_eq!(left, 5); - assert_eq!(decode(&bmi, &bits, Alpha::Ignore, &mut left).unwrap_err(), OVER_BUDGET); + assert_eq!(charge(3, 2, &mut left).unwrap_err(), OVER_BUDGET); assert_eq!(left, 5, "a refused bitmap costs nothing"); - // An embedded PNG is charged from its header, so one past the budget is never decoded. - let mut left = (1 << 20) - 1; - let png = crate::tests::png_claiming(1 << 10, 1 << 10); - assert_eq!(decode(&png_header(), &png, Alpha::Ignore, &mut left).unwrap_err(), OVER_BUDGET); } #[test] @@ -402,45 +163,6 @@ mod tests { assert_eq!(&half[12..16], &[0, 0, 128, 128]); } - #[test] - fn rgb24_round_trips_over_white() { - let (bmi, bits) = rgb24(&sample(), 1.0); - assert_eq!(bmi.len(), 40); - // Rows of 9 bytes padded to 12. - assert_eq!(bits.len(), 24); - let back = decode(&bmi, &bits, Alpha::Ignore, &mut budget()).unwrap(); - assert_eq!((back.width, back.height), (3, 2)); - assert_eq!(&back.pixels[..12], &[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]); - // Half-transparent black over white is mid grey; transparent is white. - assert_eq!(&back.pixels[12..16], &[127, 127, 127, 255]); - assert_eq!(&back.pixels[16..20], &[255, 255, 255, 255]); - } - - #[test] - fn bgra32_round_trips_with_alpha() { - let (bmi, bits) = bgra32(&sample(), 1.0); - let back = decode(&bmi, &bits, Alpha::Premultiplied, &mut budget()).unwrap(); - assert_eq!(&back.pixels[..12], &sample().pixels[..12]); - assert_eq!(back.pixels[15], 128); - assert_eq!(back.pixels[19], 0); - // Read as StretchDIBits does, alpha is ignored. - assert!(decode(&bmi, &bits, Alpha::Ignore, &mut budget()).unwrap().opaque()); - } - - #[test] - fn palette_bitmaps_and_damage() { - // A 1-bit 2 × 1 bitmap: black then white. - let mut bmi = header(2, 1, 1, 4); - bmi.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]); - let back = decode(&bmi, &[0b0100_0000, 0, 0, 0], Alpha::Ignore, &mut budget()).unwrap(); - assert_eq!(back.pixels, vec![0, 0, 0, 255, 255, 255, 255, 255]); - // Too few bits, a silly size, an unknown depth: errors, no panic. - assert!(decode(&bmi, &[0], Alpha::Ignore, &mut budget()).is_err()); - assert!(decode(&header(1 << 20, 1 << 20, 24, 0), &[], Alpha::Ignore, &mut budget()).is_err()); - assert!(decode(&header(1, 1, 7, 0), &[0; 4], Alpha::Ignore, &mut budget()).is_err()); - assert!(decode(&[1, 2], &[], Alpha::Ignore, &mut budget()).is_err()); - } - #[test] fn large_images_are_scaled_to_fit() { let img = image::RgbaImage::new(MAX_SIDE * 2, 4); diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index da7816663e..43023e0183 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -1,16 +1,14 @@ -//! The metafile reader: the format parsers (`emf`, `wmf`, and `pict` for QuickDraw pictures) play -//! their records on a [`Player`], a small model of the device context they draw into (objects, -//! transforms, the current position, the path bracket, the clip), which turns what they draw into -//! document objects. +//! The metafile readers. EMF (with its EMF+ records) and WMF files are read by `craft_metafile`, +//! and `picture` turns what they draw into document objects. QuickDraw pictures (`pict`) play their +//! opcodes on a [`Player`], a small model of the drawing state (pen, brush, font, the clip), which +//! turns what they draw into document objects. //! //! Fills and strokes become paths with a fill and a stroke; lines drawn one after another with the //! same pen join into one path. Clips become clipping groups around what they clip. Images keep -//! their pixels (as PNG), text becomes point type in the font the file names. Records the reader -//! doesn't know are counted and skipped, with one warning. +//! their pixels (as PNG), text becomes point type in the font the file names. -mod emf; pub(crate) mod pict; -mod wmf; +mod picture; use std::sync::Arc; @@ -20,7 +18,7 @@ use vectorcraft_doc::{ Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind, StrokeLayer, TextObject, }; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, Vec2, shapes}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, shapes}; use crate::dib::{self, Rgba}; use crate::{Imported, Kind}; @@ -30,24 +28,13 @@ const MAX_NODES: usize = 1 << 20; /// Objects and clips reaching further than this from the origin (points, well inside the canvas) /// are left out: they come from damaged or hostile coordinates. const LIMIT: f64 = 1e6; -/// Most nested saved device contexts. -const MAX_SAVED: usize = 1024; /// Width of a cosmetic pen (one pixel of a 96 ppi screen), points. const HAIRLINE: f64 = 0.75; -/// Curves made for arcs and ellipses are this close to the true curve (logical units are -/// transformed after, so it is relative). -const ARC_TOLERANCE: f64 = 0.01; /// Raster operations. pub(crate) const SRCCOPY: u32 = 0x00CC_0020; -const PATCOPY: u32 = 0x00F0_0021; -const WHITENESS: u32 = 0x00FF_0062; -const BLACKNESS: u32 = 0x0000_0042; const SKIPPED_ROPS: &str = "images drawn with raster operations other than a plain copy (masks, inversions) were left out"; -const HATCHES: &str = "hatched brushes are filled with their colour"; -const PATTERN_BRUSHES: &str = "pattern brushes are filled grey"; -const EXCLUDED_CLIPS: &str = "clip areas cut out of the clip (excluded rectangles, differences) are ignored"; const BAD_IMAGE: &str = "an image that couldn't be read was left out"; const TOO_MANY_PIXELS: &str = "images past the most pixels one file may hold were left out"; const FAR_AWAY: &str = "objects far outside the picture were left out"; @@ -57,6 +44,50 @@ fn sane(r: Rect) -> bool { [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) } +/// Is `path` an upright rectangle around the whole of `frame`? Writers clip to one first; it clips +/// nothing in the picture. +fn around(path: &BezPath, frame: Rect) -> bool { + if path.elements().len() > 6 { + return false; + } + let b = path.bounding_box(); + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; + let near = |p: Point, q: Point| (p - q).hypot() <= 1e-6 * (1.0 + b.width().max(b.height())); + // Each edge, the closing one included, runs along a side: from a corner to itself or to a + // neighbor (corners are in order around the box), never across it. + let along = |i: usize, j: usize| (i + 4 - j) % 4 != 2; + let mut seen = [false; 4]; + let (mut first, mut last) = (None, None); + for e in path.elements() { + let (p, start) = match e { + PathEl::MoveTo(p) => (*p, true), + PathEl::LineTo(p) => (*p, false), + PathEl::ClosePath => continue, + PathEl::QuadTo(..) | PathEl::CurveTo(..) => return false, + }; + let Some(i) = corners.iter().position(|c| near(*c, p)) else { return false }; + if start { + if let (Some(f), Some(l)) = (first, last) + && !along(f, l) + { + return false; + } + first = Some(i); + } else if last.is_some_and(|l| !along(l, i)) { + return false; + } + last = Some(i); + seen[i] = true; + } + if let (Some(f), Some(l)) = (first, last) + && !along(f, l) + { + return false; + } + let b = b.inflate(0.5, 0.5); + seen.iter().all(|s| *s) && b.contains(frame.origin()) && b.contains(Point::new(frame.x1, frame.y1)) +} + /// A pen: its style flags (`PS_*`), width in logical units, colour and dash entries. #[derive(Clone, Debug, PartialEq)] pub(crate) struct PenObj { @@ -68,7 +99,6 @@ pub(crate) struct PenObj { } impl PenObj { - pub const NULL: PenObj = PenObj { style: PS_NULL, width: 0.0, color: [0; 3], entries: vec![] }; pub fn solid(color: [u8; 3]) -> Self { Self { style: 0, width: 0.0, color, entries: vec![] } } @@ -77,28 +107,6 @@ impl PenObj { pub(crate) const PS_NULL: u32 = 5; const PS_GEOMETRIC: u32 = 0x0001_0000; -/// A brush. -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum BrushObj { - Null, - Solid([u8; 3]), - Hatched([u8; 3]), - /// A bitmap pattern (written grey). - Pattern, -} - -impl BrushObj { - /// From a LOGBRUSH's style and colour. - pub fn from_log(style: u32, color: [u8; 3]) -> Self { - match style { - 0 => Self::Solid(color), - 1 => Self::Null, - 2 => Self::Hatched(color), - _ => Self::Pattern, - } - } -} - /// A font: its height in logical units (negative: the character height, positive: the cell /// height), escapement (tenths of a degree), weight, italics and face. #[derive(Clone, Debug, Default, PartialEq)] @@ -110,35 +118,17 @@ pub(crate) struct FontObj { pub face: String, } -/// A GDI object. -#[derive(Clone, Debug)] -pub(crate) enum Obj { - Pen(PenObj), - Brush(BrushObj), - Font(FontObj), - /// Palettes, regions, colour spaces: they take a slot but don't change the drawing. - Other, -} - -/// The state records change, saved and restored as a whole. +/// The drawing state. #[derive(Clone, Debug)] struct Dc { pen: PenObj, - brush: BrushObj, + /// The brush's color. + brush: [u8; 3], font: FontObj, text_color: [u8; 3], text_align: u32, fill_rule: FillRule, miter: f64, - /// Arcs run counterclockwise (the default). - arc_ccw: bool, - /// Logical → page (EMF world transform). - world: Affine, - map_mode: u32, - win_org: Vec2, - win_ext: Vec2, - vp_org: Vec2, - vp_ext: Vec2, clips: Vec>, } @@ -146,105 +136,38 @@ impl Default for Dc { fn default() -> Self { Self { pen: PenObj::solid([0; 3]), - brush: BrushObj::Solid([255; 3]), + brush: [255; 3], font: FontObj::default(), text_color: [0; 3], text_align: 0, fill_rule: FillRule::EvenOdd, miter: 10.0, - arc_ccw: true, - world: Affine::IDENTITY, - map_mode: 1, - win_org: Vec2::ZERO, - win_ext: Vec2::new(1.0, 1.0), - vp_org: Vec2::ZERO, - vp_ext: Vec2::new(1.0, 1.0), clips: vec![], } } } -/// How an arc closes. -#[derive(Clone, Copy, PartialEq, Eq)] -pub(crate) enum ArcKind { - Open, - Chord, - Pie, -} - pub(crate) struct Player { kind: Kind, - /// Device units (EMF) or logical units (WMF) → document points. + /// Picture units → document points. dev_to_doc: Affine, - /// Millimetres per device pixel (EMF's fixed mapping modes). - mm_per_px: f64, dc: Dc, - saved: Vec, - objects: Vec>, - /// The lowest object slot that may be free (WMF numbering). - free_hint: usize, - /// The path being recorded (document space), whether a figure is open in it and where that - /// figure started (logical). - path: Option, - figure: bool, - figure_start: Point, /// The artboard (document space): clips holding all of it clip nothing worth keeping. frame: Rect, - /// The current position (logical). - pos: Point, - /// Lines drawn one after another outside a path bracket, not drawn yet. + /// Lines drawn one after another, not drawn yet. pending: BezPath, out: Drawn, doc: Document, warnings: Vec, - skipped: usize, next_clip: u32, /// Pixels the file's bitmaps may still decode to ([`dib::MAX_FILE_PIXELS`]). pub pixels_left: u64, - /// The file holds EMF+ records (which aren't read). - pub emf_plus: bool, } fn rgb(c: [u8; 3]) -> Paint { Paint::solid(Color::rgb8(c[0], c[1], c[2])) } -/// The arc of the ellipse in `r` from the ray through `start` to the ray through `end`. -pub(crate) fn arc_path(r: Rect, start: Point, end: Point, ccw: bool, kind: ArcKind) -> BezPath { - let r = r.abs(); - let c = r.center(); - let radii = Vec2::new(r.width() / 2.0, r.height() / 2.0); - let mut bp = BezPath::new(); - if radii.x <= 0.0 || radii.y <= 0.0 { - return bp; - } - let angle = |p: Point| ((p.y - c.y) / radii.y).atan2((p.x - c.x) / radii.x); - let (a0, a1) = (angle(start), angle(end)); - let tau = std::f64::consts::TAU; - // Counterclockwise on a y-down device is towards smaller angles. - let sweep = if ccw { - let s = (a0 - a1).rem_euclid(tau); - -(if s == 0.0 { tau } else { s }) - } else { - let s = (a1 - a0).rem_euclid(tau); - if s == 0.0 { tau } else { s } - }; - let arc = kurbo::Arc { center: c, radii, start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 }; - let tol = ARC_TOLERANCE * radii.x.max(radii.y).max(1e-9); - if kind == ArcKind::Pie { - bp.move_to(c); - let first = c + Vec2::new(radii.x * a0.cos(), radii.y * a0.sin()); - bp.line_to(first); - arc.append_iter(tol).for_each(|el| bp.push(el)); - } else { - kurbo::Shape::path_elements(&arc, tol).for_each(|el| bp.push(el)); - } - if kind != ArcKind::Open { - bp.close_path(); - } - bp -} - /// A rectangle with elliptical corners `rx` × `ry`. pub(crate) fn round_rect(r: Rect, rx: f64, ry: f64) -> BezPath { let r = r.abs(); @@ -261,7 +184,7 @@ pub(crate) fn round_rect(r: Rect, rx: f64, ry: f64) -> BezPath { } impl Player { - fn new(kind: Kind, dev_to_doc: Affine, mm_per_px: f64, artboard: Rect) -> Self { + fn new(kind: Kind, dev_to_doc: Affine, artboard: Rect) -> Self { let side = |v: f64| if v.is_finite() { v.clamp(1.0, LIMIT) } else { 1.0 }; let (w, h) = (side(artboard.width()), side(artboard.height())); let mut doc = Document::new(w, h); @@ -269,24 +192,14 @@ impl Player { Self { kind, dev_to_doc, - mm_per_px, dc: Dc::default(), - saved: vec![], - objects: vec![], - free_hint: 0, - path: None, - figure: false, - figure_start: Point::ZERO, frame: artboard, - pos: Point::ZERO, pending: BezPath::new(), out: vec![], doc, warnings: vec![], - skipped: 0, next_clip: 0, pixels_left: dib::MAX_FILE_PIXELS, - emf_plus: false, } } @@ -296,188 +209,19 @@ impl Player { } } - /// A record of a kind the reader doesn't know. - pub fn skip(&mut self) { - self.skipped += 1; - } - // ---------- mapping ---------- - /// Logical → document. + /// Picture units → document. fn xf(&self) -> Affine { - if self.kind == Kind::Wmf { - return self.dev_to_doc; - } - let d = &self.dc; - let page_to_dev = match d.map_mode { - // Fixed modes: millimetres a unit, y up. - 2..=6 => { - let mm = [0.1, 0.01, 0.254, 0.0254, 25.4 / 1440.0][(d.map_mode - 2) as usize]; - let s = mm / self.mm_per_px.max(1e-12); - Affine::translate(d.vp_org) * Affine::scale_non_uniform(s, -s) * Affine::translate(-d.win_org) - } - 7 | 8 => { - let ratio = |v: f64, w: f64| if w.abs() > 1e-12 && v.is_finite() { v / w } else { 1.0 }; - let (mut sx, mut sy) = (ratio(d.vp_ext.x, d.win_ext.x), ratio(d.vp_ext.y, d.win_ext.y)); - if d.map_mode == 7 { - // Isotropic: the same scale both ways, the smaller one. - let s = sx.abs().min(sy.abs()); - (sx, sy) = (s.copysign(sx), s.copysign(sy)); - } - Affine::translate(d.vp_org) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-d.win_org) - } - _ => Affine::translate(d.vp_org - d.win_org), - }; - self.dev_to_doc * page_to_dev * d.world + self.dev_to_doc } - /// How much logical lengths grow in the document. + /// How much picture lengths grow in the document. fn scale(&self) -> f64 { let s = self.xf().determinant().abs().sqrt(); if s.is_finite() { s } else { 0.0 } } - pub fn set_map_mode(&mut self, m: u32) { - self.dc.map_mode = m; - } - pub fn set_window_org(&mut self, x: f64, y: f64) { - self.dc.win_org = Vec2::new(x, y); - } - pub fn set_window_ext(&mut self, x: f64, y: f64) { - self.dc.win_ext = Vec2::new(x, y); - } - pub fn set_viewport_org(&mut self, x: f64, y: f64) { - self.dc.vp_org = Vec2::new(x, y); - } - pub fn set_viewport_ext(&mut self, x: f64, y: f64) { - self.dc.vp_ext = Vec2::new(x, y); - } - pub fn scale_ext(&mut self, window: bool, x: (f64, f64), y: (f64, f64)) { - let e = if window { &mut self.dc.win_ext } else { &mut self.dc.vp_ext }; - if x.1 != 0.0 && y.1 != 0.0 { - *e = Vec2::new(e.x * x.0 / x.1, e.y * y.0 / y.1); - } - } - /// `SetWorldTransform` / `ModifyWorldTransform` (`mode`: 1 identity, 2 left, 3 right, 4 set). - pub fn world(&mut self, x: Affine, mode: u32) { - if !x.as_coeffs().iter().all(|v| v.is_finite()) { - return; - } - self.dc.world = match mode { - 1 => Affine::IDENTITY, - 2 => self.dc.world * x, - 3 => x * self.dc.world, - _ => x, - }; - } - - // ---------- state ---------- - - pub fn set_fill_mode(&mut self, alternate: bool) { - self.dc.fill_rule = if alternate { FillRule::EvenOdd } else { FillRule::NonZero }; - } - pub fn set_miter(&mut self, m: f64) { - if m.is_finite() && m >= 1.0 { - self.dc.miter = m; - } - } - pub fn set_arc_ccw(&mut self, ccw: bool) { - self.dc.arc_ccw = ccw; - } - pub fn set_text_color(&mut self, c: [u8; 3]) { - self.dc.text_color = c; - } - pub fn set_text_align(&mut self, a: u32) { - self.dc.text_align = a; - } - - pub fn save(&mut self) { - if self.saved.len() < MAX_SAVED { - self.saved.push(self.dc.clone()); - } - } - - /// `RestoreDC(n)`: a negative `n` counts back from the latest save, a positive one is a save's - /// number. - pub fn restore(&mut self, n: i32) { - let len = self.saved.len(); - let keep = if n < 0 { len.checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1).filter(|k| *k < len) }; - if let Some(k) = keep { - self.saved.truncate(k + 1); - if let Some(dc) = self.saved.pop() { - self.dc = dc; - } - } - } - - // ---------- objects ---------- - - /// Put `obj` in slot `index` (EMF numbers its objects). - pub fn create_at(&mut self, index: u32, obj: Obj) { - let i = index as usize; - if i == 0 || i > u16::MAX as usize { - return; - } - if self.objects.len() <= i { - self.objects.resize(i + 1, None); - } - if let Some(s) = self.objects.get_mut(i) { - *s = Some(obj); - } - } - - /// Put `obj` in the lowest free slot (WMF). - pub fn create(&mut self, obj: Obj) { - let start = self.free_hint.min(self.objects.len()); - let i = self.objects.iter().skip(start).position(Option::is_none).map_or(self.objects.len(), |p| p + start); - if i > u16::MAX as usize { - // The table is full past the hint: the next creation looks from the end, not again - // through every slot. - self.free_hint = i; - return; - } - if i == self.objects.len() { - self.objects.push(None); - } - if let Some(s) = self.objects.get_mut(i) { - *s = Some(obj); - } - self.free_hint = i + 1; - } - - pub fn delete(&mut self, index: u32) { - if let Some(s) = self.objects.get_mut(index as usize) { - *s = None; - self.free_hint = self.free_hint.min(index as usize); - } - } - - /// Select object `index`, or a stock object (EMF: the high bit set). - pub fn select(&mut self, index: u32) { - if self.kind == Kind::Emf && index & 0x8000_0000 != 0 { - match index & 0x7fff_ffff { - 0 | 18 => self.dc.brush = BrushObj::Solid([255; 3]), - 1 => self.dc.brush = BrushObj::Solid([0xc0; 3]), - 2 => self.dc.brush = BrushObj::Solid([0x80; 3]), - 3 => self.dc.brush = BrushObj::Solid([0x40; 3]), - 4 => self.dc.brush = BrushObj::Solid([0; 3]), - 5 => self.dc.brush = BrushObj::Null, - 6 => self.dc.pen = PenObj::solid([255; 3]), - 7 | 19 => self.dc.pen = PenObj::solid([0; 3]), - 8 => self.dc.pen = PenObj::NULL, - 10..=17 => self.dc.font = FontObj::default(), - _ => {} - } - return; - } - match self.objects.get(index as usize).cloned().flatten() { - Some(Obj::Pen(p)) => self.dc.pen = p, - Some(Obj::Brush(b)) => self.dc.brush = b, - Some(Obj::Font(f)) => self.dc.font = f, - _ => {} - } - } - // ---------- output ---------- fn push(&mut self, mut node: Node) { @@ -491,19 +235,8 @@ impl Player { self.out.push((self.dc.clips.clone(), node)); } - fn fill_paint(&mut self) -> Option { - match self.dc.brush.clone() { - BrushObj::Null => None, - BrushObj::Solid(c) => Some(rgb(c)), - BrushObj::Hatched(c) => { - self.warn(HATCHES); - Some(rgb(c)) - } - BrushObj::Pattern => { - self.warn(PATTERN_BRUSHES); - Some(rgb([0x80; 3])) - } - } + fn fill_paint(&self) -> Paint { + rgb(self.dc.brush) } fn stroke_layer(&self) -> Option { @@ -549,8 +282,8 @@ impl Player { return; } let mut items = vec![]; - if fill && let Some(p) = self.fill_paint() { - items.push(AppearanceItem::Fill(FillLayer::new(p))); + if fill { + items.push(AppearanceItem::Fill(FillLayer::new(self.fill_paint()))); } if stroke && let Some(st) = self.stroke_layer() { items.push(AppearanceItem::Stroke(st)); @@ -573,124 +306,7 @@ impl Player { } } - /// A figure in logical units: added to the path being recorded, else drawn (filled when it is - /// an area). - pub fn figure(&mut self, mut bp: BezPath, area: bool) { - bp.apply_affine(self.xf()); - match &mut self.path { - Some(path) => { - path.extend(bp); - self.figure = false; - } - None => self.draw(bp, area, true), - } - } - - /// Polygon / Polyline (and Bézier ones): `pts` in logical units. - pub fn poly(&mut self, pts: &[Point], closed: bool, bezier: bool) { - let Some((first, rest)) = pts.split_first() else { return }; - let mut bp = BezPath::new(); - bp.move_to(*first); - if bezier { - for c in rest.as_chunks::<3>().0 { - bp.curve_to(c[0], c[1], c[2]); - } - } else { - rest.iter().for_each(|p| bp.line_to(*p)); - } - if closed { - bp.close_path(); - } - self.figure(bp, closed); - } - - /// Several polygons (one area) or polylines. - pub fn poly_poly(&mut self, polys: &[Vec], closed: bool) { - let mut bp = BezPath::new(); - for p in polys { - let Some((first, rest)) = p.split_first() else { continue }; - bp.move_to(*first); - rest.iter().for_each(|q| bp.line_to(*q)); - if closed { - bp.close_path(); - } - } - self.figure(bp, closed); - } - - pub fn move_to(&mut self, p: Point) { - self.pos = p; - self.figure = false; - } - - /// LineTo / PolylineTo / PolyBezierTo: from the current position through `pts` (logical). - pub fn poly_to(&mut self, pts: &[Point], bezier: bool) { - let Some(last) = pts.last().copied() else { return }; - let xf = self.xf(); - let start = xf * self.pos; - let target = match &mut self.path { - Some(path) => { - if !self.figure { - path.move_to(start); - self.figure = true; - self.figure_start = self.pos; - } - path - } - None => { - if self.pending.elements().last().and_then(|e| e.end_point()) != Some(start) { - self.pending.move_to(start); - } - &mut self.pending - } - }; - if bezier { - for c in pts.as_chunks::<3>().0 { - target.curve_to(xf * c[0], xf * c[1], xf * c[2]); - } - } else { - pts.iter().for_each(|p| target.line_to(xf * *p)); - } - self.pos = last; - } - - pub fn arc(&mut self, r: Rect, start: Point, end: Point, kind: ArcKind) { - let bp = arc_path(r, start, end, self.dc.arc_ccw, kind); - self.figure(bp, kind != ArcKind::Open); - } - - // ---------- paths and clips ---------- - - pub fn begin_path(&mut self) { - self.path = Some(BezPath::new()); - self.figure = false; - } - pub fn end_path(&mut self) { - // The path stays until it is filled, stroked or made the clip. - } - pub fn close_figure(&mut self) { - if let Some(p) = &mut self.path - && self.figure - { - p.close_path(); - self.figure = false; - self.pos = self.figure_start; - } - } - pub fn abort_path(&mut self) { - self.path = None; - } - - /// FillPath / StrokePath / StrokeAndFillPath. - pub fn paint_path(&mut self, fill: bool, stroke: bool) { - if let Some(mut bp) = self.path.take() { - // Filling closes the open figure. - if fill && self.figure { - bp.close_path(); - } - self.draw(bp, fill, stroke); - } - } + // ---------- clips ---------- fn push_clip(&mut self, path: BezPath, rule: FillRule, replace: bool) { if replace { @@ -699,10 +315,7 @@ impl Player { if !sane(path.bounding_box()) { return self.warn(FAR_AWAY); } - // A rectangle around the whole picture (what writers clip to first) clips nothing in it. - let lines = path.elements().iter().all(|e| !matches!(e, PathEl::QuadTo(..) | PathEl::CurveTo(..))); - let around = path.bounding_box().inflate(0.5, 0.5); - if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) { + if around(&path, self.frame) { return; } if self.dc.clips.len() >= MAX_NEST { @@ -712,69 +325,10 @@ impl Player { self.dc.clips.push(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) })); } - /// SelectClipPath (`mode`: 1 and, 5 copy; the others cut out, which isn't read). - pub fn clip_path(&mut self, mode: u32) { - let Some(bp) = self.path.take() else { return }; - match mode { - 1 => self.push_clip(bp, self.dc.fill_rule, false), - 5 => self.push_clip(bp, self.dc.fill_rule, true), - _ => self.warn(EXCLUDED_CLIPS), - } - } - - /// IntersectClipRect (logical units). - pub fn clip_rect(&mut self, r: Rect) { - let mut bp = shapes::rectangle(r.abs()).to_bezpath(); - bp.apply_affine(self.xf()); - self.push_clip(bp, FillRule::NonZero, false); - } - - /// A clip region of `rects` in device units (`mode` as [`Self::clip_path`]); none with mode - /// copy removes the clip. - pub fn clip_region(&mut self, rects: &[Rect], mode: u32) { - if rects.is_empty() { - if mode == 5 { - self.dc.clips.clear(); - } - return; - } - let mut bp = BezPath::new(); - for r in rects { - bp.extend(shapes::rectangle(r.abs()).to_bezpath()); - } - bp.apply_affine(self.dev_to_doc); - match mode { - 1 => self.push_clip(bp, FillRule::NonZero, false), - 5 => self.push_clip(bp, FillRule::NonZero, true), - _ => self.warn(EXCLUDED_CLIPS), - } - } - - pub fn exclude_clip(&mut self) { - self.warn(EXCLUDED_CLIPS); - } - - // ---------- blits, images and text ---------- - - /// A raster operation without a source over `dest` (logical): the brush, white or black. - pub fn pattern_blit(&mut self, dest: Rect, rop: u32) { - let brush = match rop { - PATCOPY => self.dc.brush.clone(), - WHITENESS => BrushObj::Solid([255; 3]), - BLACKNESS => BrushObj::Solid([0; 3]), - _ => return self.skip(), - }; - let pen = std::mem::replace(&mut self.dc.pen, PenObj::NULL); - let outer = std::mem::replace(&mut self.dc.brush, brush); - let mut bp = shapes::rectangle(dest.abs()).to_bezpath(); - bp.apply_affine(self.xf()); - self.draw(bp, true, false); - self.dc.pen = pen; - self.dc.brush = outer; - } + // ---------- images and text ---------- /// An image whose source rectangle `src` (pixels, from the top-left) is drawn into `dest` - /// (logical: origin and signed size) with raster operation `rop`, at `opacity`. + /// (picture units: origin and signed size) with raster operation `rop`, at `opacity`. pub fn image(&mut self, img: Result, src: Rect, dest: Rect, rop: u32, opacity: f64) { if rop != SRCCOPY { self.warn(SKIPPED_ROPS); @@ -793,8 +347,8 @@ impl Player { self.image_xf(img, Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h), opacity); } - /// An image placed by `xf` (its pixels, from the top-left, → logical), at `opacity`. An image - /// that `xf` flattens or sends past finite numbers is left out, and adds nothing to the + /// An image placed by `xf` (its pixels, from the top-left, → picture units), at `opacity`. An + /// image that `xf` flattens or sends past finite numbers is left out, and adds nothing to the /// document's images. pub(crate) fn image_xf(&mut self, img: Rgba, xf: Affine, opacity: f64) { if img.width == 0 || img.height == 0 { @@ -814,14 +368,13 @@ impl Player { self.push(n); } - /// Text at `at` (logical; the current position with TA_UPDATECP) in the current font. + /// Text at `at` (picture units) in the current font. pub fn text(&mut self, at: Point, s: &str) { let s: String = s.chars().filter(|c| *c != '\0').collect(); if s.trim().is_empty() { return; } let align = self.dc.text_align; - let at = if align & 1 != 0 { self.pos } else { at }; let f = self.dc.font.clone(); let k = self.scale(); // A negative height is the em; a positive one the cell (the em and the internal leading). @@ -863,18 +416,7 @@ impl Player { /// The document: what was drawn on one layer, clipped art in clipping groups. fn finish(mut self) -> Imported { self.flush(); - if self.skipped > 0 { - let n = self.skipped; - self.warnings.push(format!( - "{n} record{} of kinds VectorCraft doesn't read {} skipped", - if n == 1 { "" } else { "s" }, - if n == 1 { "was" } else { "were" } - )); - } let out = std::mem::take(&mut self.out); - if out.is_empty() && self.emf_plus { - self.warnings.push("the picture is drawn with EMF+ records, which aren't read yet: nothing was imported".into()); - } let children = nest(&mut self.doc, out); let mut layer = Node::layer(self.doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); if let Some(c) = layer.children_mut() { @@ -904,33 +446,7 @@ fn crop(img: Rgba, src: Rect) -> Rgba { Rgba { width: x1 - x0, height: y1 - y0, pixels } } -/// Read a metafile. +/// Read an EMF or WMF file. pub(crate) fn import(bytes: &[u8]) -> Result { - match crate::sniff(bytes) { - Some(Kind::Emf) => emf::play(bytes), - Some(Kind::Wmf) => wmf::play(bytes), - None => Err("not an EMF or WMF file".into()), - } -} - -#[cfg(test)] -mod table_tests { - use super::*; - - /// Once the WMF table is full, a rejected creation leaves the hint at its end, so the next - /// ones don't scan every slot again; a freed slot is still found. - #[test] - fn a_full_object_table_is_not_rescanned() { - let mut p = Player::new(Kind::Wmf, Affine::IDENTITY, 1.0, Rect::new(0.0, 0.0, 10.0, 10.0)); - for _ in 0..=u16::MAX as usize + 1 { - p.create(Obj::Other); - } - p.delete(5); - p.create(Obj::Other); - p.create(Obj::Other); - assert!(p.free_hint > u16::MAX as usize); - p.delete(7); - p.create(Obj::Other); - assert!(p.objects[7].is_some()); - } + picture::import(bytes) } diff --git a/crates/metafile/src/import/emf.rs b/crates/metafile/src/import/emf.rs deleted file mode 100644 index ece16c7864..0000000000 --- a/crates/metafile/src/import/emf.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! EMF records (MS-EMF) played on a [`Player`]: the header gives the frame (the artboard) and the -//! reference device (device pixels → millimetres), then every record in turn until EMR_EOF. - -use vectorcraft_geom::{Affine, Point, Rect}; - -use super::{ArcKind, BrushObj, FontObj, Obj, PenObj, Player}; -use crate::bytes::{Reader, slice}; -use crate::dib::{self, Alpha}; -use crate::{Imported, Kind}; - -/// Points per millimetre. -const PT_PER_MM: f64 = 72.0 / 25.4; -/// Most points one record may give (a hostile count can't make a huge allocation). -const MAX_POINTS: usize = 1 << 22; -/// Records that change nothing VectorCraft keeps (background mode and colour, raster modes, -/// palettes, colour management, comments…). -const IGNORED: &[u32] = - &[1, 13, 15, 16, 18, 20, 21, 23, 25, 28, 48, 50, 51, 52, 65, 66, 70, 98, 100, 101, 104, 110, 111, 112, 113, 115, 119, 120, 121]; - -fn rect(r: &mut Reader) -> Option { - Some(Rect::new(f64::from(r.i32()?), f64::from(r.i32()?), f64::from(r.i32()?), f64::from(r.i32()?))) -} - -fn point32(r: &mut Reader) -> Option { - Some(Point::new(f64::from(r.i32()?), f64::from(r.i32()?))) -} - -fn point16(r: &mut Reader) -> Option { - Some(Point::new(f64::from(r.i16()?), f64::from(r.i16()?))) -} - -fn color(r: &mut Reader) -> Option<[u8; 3]> { - let [red, g, b, _] = r.u32()?.to_le_bytes(); - Some([red, g, b]) -} - -fn xform(r: &mut Reader) -> Option { - let mut v = [0.0; 6]; - for x in &mut v { - *x = f64::from(r.f32()?); - } - Some(Affine::new(v)) -} - -/// `n` points (32-bit or 16-bit), `None` when the record is shorter. -fn points(r: &mut Reader, n: u32, wide: bool) -> Option> { - let n = n as usize; - if n > MAX_POINTS || r.rest().len() < n * if wide { 8 } else { 4 } { - return None; - } - (0..n).map(|_| if wide { point32(r) } else { point16(r) }).collect() -} - -/// The polygons of a PolyPolygon / PolyPolyline record. -fn polys(r: &mut Reader, wide: bool) -> Option>> { - r.skip(16)?; - let n = r.u32()? as usize; - let total = r.u32()?; - if n > MAX_POINTS || r.rest().len() < n * 4 { - return None; - } - let counts: Vec = (0..n).map(|_| r.u32()).collect::>()?; - let mut all = points(r, total, wide)?.into_iter(); - Some(counts.iter().map(|c| all.by_ref().take(*c as usize).collect()).collect()) -} - -/// A LOGFONTW → the font. -fn font(r: &mut Reader) -> Option { - let height = f64::from(r.i32()?); - r.skip(4)?; - let escapement = f64::from(r.i32()?); - r.skip(4)?; - let weight = r.i32()?; - let italic = r.bytes(1)?.first().is_some_and(|b| *b != 0); - r.skip(7)?; - let units: Vec = (0..32).map_while(|_| r.u16()).take_while(|c| *c != 0).collect(); - Some(FontObj { height, escapement, weight, italic, face: String::from_utf16_lossy(&units) }) -} - -/// The bitmap of an image record: its BITMAPINFO and bits at the offsets the record gives. -fn bitmap(rec: &[u8], off_bmi: u32, cb_bmi: u32, off_bits: u32, cb_bits: u32, alpha: Alpha, left: &mut u64) -> Result { - let bmi = slice(rec, off_bmi, cb_bmi).ok_or("damaged")?; - let bits = slice(rec, off_bits, cb_bits).ok_or("damaged")?; - dib::decode(bmi, bits, alpha, left) -} - -/// The rows a DIB's source rectangle counts from the bottom in (StretchDIBits on a bottom-up DIB). -fn bottom_up(rec: &[u8], off_bmi: u32) -> bool { - let at = off_bmi as usize; - let size = rec.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); - let height = rec.get(at + 8..at + 12).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes); - size.is_some_and(|s| s >= 40) && height.is_some_and(|h| h > 0) -} - -/// The source rectangle `(x, y, w, h)` of an image of `img`'s height, counted from the top. -fn source(x: i32, y: i32, w: i32, h: i32, img: &Result, from_bottom: bool) -> Rect { - let ih = img.as_ref().map_or(0.0, |i| f64::from(i.height)); - let (x, y, w, h) = (f64::from(x), f64::from(y), f64::from(w), f64::from(h)); - let top = if from_bottom { ih - y - h } else { y }; - Rect::new(x, top, x + w, top + h) -} - -/// The size and placement of the picture: (device → document, millimetres a pixel, the artboard). -fn frame(bytes: &[u8]) -> Option<(Affine, f64, Rect)> { - let mut r = Reader::new(bytes); - r.skip(4)?; - let header_size = r.u32()?; - let bounds = rect(&mut r)?; - let frame = rect(&mut r)?; - // Signature, version, bytes, records, handles, description and palette size. - r.skip(32)?; - let device = (f64::from(r.i32()?), f64::from(r.i32()?)); - let mm = (f64::from(r.i32()?), f64::from(r.i32()?)); - // The device in micrometres (more precise), when the header has it. - let micro = if header_size >= 108 { - r.skip(12)?; - Some((f64::from(r.u32()?), f64::from(r.u32()?))) - } else { - None - }; - let ok = |v: f64| v.is_finite() && v > 0.0; - let mm_per_px = match micro { - Some((mx, _)) if ok(mx) && ok(device.0) => mx / 1000.0 / device.0, - _ if ok(mm.0) && ok(device.0) => mm.0 / device.0, - _ => 25.4 / 96.0, - }; - let mm_per_px = if mm_per_px.is_finite() && mm_per_px > 1e-9 { mm_per_px } else { 25.4 / 96.0 }; - let k = mm_per_px * PT_PER_MM; - // The frame (0.01 mm) in device pixels; an empty frame falls back to the bounds. - let f = frame.abs(); - let (origin, size) = if f.width() > 0.0 && f.height() > 0.0 { - (Point::new(f.x0 / 100.0 / mm_per_px, f.y0 / 100.0 / mm_per_px), (f.width() / 100.0 * PT_PER_MM, f.height() / 100.0 * PT_PER_MM)) - } else { - let b = bounds.abs(); - (b.origin(), ((b.width() + 1.0) * k, (b.height() + 1.0) * k)) - }; - let dev_to_doc = Affine::scale(k) * Affine::translate(-origin.to_vec2()); - let side = |v: f64| if v.is_finite() { v.clamp(1.0, 1e6) } else { 1.0 }; - Some((dev_to_doc, mm_per_px, Rect::new(0.0, 0.0, side(size.0), side(size.1)))) -} - -pub(super) fn play(bytes: &[u8]) -> Result { - let (dev_to_doc, mm_per_px, artboard) = frame(bytes).ok_or("the EMF header is damaged")?; - let mut p = Player::new(Kind::Emf, dev_to_doc, mm_per_px, artboard); - let mut at = 0usize; - let mut ended = false; - while let Some(head) = bytes.get(at..at + 8) { - let kind = u32::from_le_bytes([head[0], head[1], head[2], head[3]]); - let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; - let Some(rec) = bytes.get(at..at.saturating_add(size)).filter(|_| size >= 8) else { break }; - if kind == 14 { - ended = true; - break; - } - if !matches!(kind, 27 | 54 | 5 | 6 | 88 | 89) { - p.flush(); - } - if record(&mut p, kind, rec).is_none() { - p.skip(); - } - at += size; - } - if !ended { - p.warn("the file ends early: what it held was imported"); - } - Ok(p.finish()) -} - -/// PolyBezier, Polygon, Polyline, PolyBezierTo, PolylineTo and their 16-bit forms. -fn rec_poly(p: &mut Player, kind: u32, r: &mut Reader) -> Option<()> { - let wide = kind < 85; - r.skip(16)?; - let n = r.u32()?; - let pts = points(r, n, wide)?; - match if wide { kind } else { kind - 83 } { - 2 => p.poly(&pts, false, true), - 3 => p.poly(&pts, true, false), - 4 => p.poly(&pts, false, false), - 5 => p.poly_to(&pts, true), - _ => p.poly_to(&pts, false), - } - Some(()) -} - -/// CreatePen (38). -fn rec_create_pen(p: &mut Player, r: &mut Reader) -> Option<()> { - let ih = r.u32()?; - let style = r.u32()?; - let width = f64::from(r.i32()?); - r.skip(4)?; - p.create_at(ih, Obj::Pen(PenObj { style, width, color: color(r)?, entries: vec![] })); - Some(()) -} - -/// ExtCreatePen (95). -fn rec_ext_create_pen(p: &mut Player, r: &mut Reader) -> Option<()> { - let ih = r.u32()?; - r.skip(16)?; - let style = r.u32()?; - let width = f64::from(r.u32()?); - let brush = r.u32()?; - let c = color(r)?; - r.skip(4)?; - let n = (r.u32()? as usize).min(64); - let entries: Vec = (0..n).map_while(|_| r.u32()).map(f64::from).collect(); - // A pen painted with the null brush draws nothing. - let style = if brush == 1 { super::PS_NULL } else { style }; - p.create_at(ih, Obj::Pen(PenObj { style, width, color: c, entries })); - Some(()) -} - -/// GdiComment (70): EMF+ records ride in comments starting "EMF+". -fn rec_comment(p: &mut Player, r: &mut Reader) -> Option<()> { - r.skip(4)?; - p.emf_plus |= r.u32()? == 0x2B46_4D45; - Some(()) -} - -/// ExtSelectClipRgn (75). -fn rec_clip_region(p: &mut Player, r: &mut Reader) -> Option<()> { - let cb = r.u32()?; - let mode = r.u32()?; - let data = r.bytes(cb as usize)?; - let mut d = Reader::new(data); - let rects = if cb >= 32 { - d.skip(8)?; - let n = d.u32()? as usize; - d.skip(20)?; - if n > MAX_POINTS || d.rest().len() < n * 16 { - return None; - } - (0..n).map(|_| rect(&mut d)).collect::>>()? - } else { - vec![] - }; - p.clip_region(&rects, mode); - Some(()) -} - -/// The rectangle at `(x, y)` of size `(w, h)`. -fn at_size(x: i32, y: i32, w: i32, h: i32) -> Rect { - Rect::new(f64::from(x), f64::from(y), f64::from(x) + f64::from(w), f64::from(y) + f64::from(h)) -} - -/// BitBlt (76) and StretchBlt (77). -fn rec_blt(p: &mut Player, kind: u32, rec: &[u8], r: &mut Reader) -> Option<()> { - r.skip(16)?; - let dest = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); - let rop = r.u32()?; - let (xs, ys) = (r.i32()?, r.i32()?); - r.skip(24 + 4 + 4)?; - let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); - let (ws, hs) = if kind == 77 { (r.i32()?, r.i32()?) } else { (dest.2, dest.3) }; - let d = at_size(dest.0, dest.1, dest.2, dest.3); - if cb_bmi == 0 { - p.pattern_blit(d, rop); - } else { - let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore, &mut p.pixels_left); - let src = source(xs, ys, ws, hs, &img, false); - p.image(img, src, d, rop, 1.0); - } - Some(()) -} - -/// StretchDIBits (81). -fn rec_stretch_dibits(p: &mut Player, rec: &[u8], r: &mut Reader) -> Option<()> { - r.skip(16)?; - let (xd, yd, xs, ys, ws, hs) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?); - let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); - r.skip(4)?; - let rop = r.u32()?; - let (wd, hd) = (r.i32()?, r.i32()?); - let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore, &mut p.pixels_left); - let src = source(xs, ys, ws, hs, &img, bottom_up(rec, off_bmi)); - p.image(img, src, at_size(xd, yd, wd, hd), rop, 1.0); - Some(()) -} - -/// AlphaBlend (114). -fn rec_alpha_blend(p: &mut Player, rec: &[u8], r: &mut Reader) -> Option<()> { - r.skip(16)?; - let (xd, yd, wd, hd) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); - let blend = r.bytes(4)?; - let (constant, per_pixel) = (blend.get(2).copied().unwrap_or(255), blend.get(3).is_some_and(|f| *f & 1 != 0)); - let (xs, ys) = (r.i32()?, r.i32()?); - r.skip(24 + 4 + 4)?; - let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); - let (ws, hs) = (r.i32()?, r.i32()?); - let alpha = if per_pixel { Alpha::Premultiplied } else { Alpha::Ignore }; - let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, alpha, &mut p.pixels_left); - let src = source(xs, ys, ws, hs, &img, false); - p.image(img, src, at_size(xd, yd, wd, hd), crate::import::SRCCOPY, f64::from(constant) / 255.0); - Some(()) -} - -/// ExtTextOutA (83) and ExtTextOutW (84). -fn rec_text(p: &mut Player, kind: u32, rec: &[u8], r: &mut Reader) -> Option<()> { - r.skip(16 + 4 + 8)?; - let at = point32(r)?; - let n = r.u32()? as usize; - let off = r.u32()?; - let s = if kind == 84 { - let raw = slice(rec, off, u32::try_from(n.checked_mul(2)?).ok()?)?; - String::from_utf16_lossy(&raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect::>()) - } else { - slice(rec, off, u32::try_from(n).ok()?)?.iter().map(|b| char::from(*b)).collect() - }; - p.text(at, &s); - Some(()) -} - -/// Play one record (`rec`: all of it, with its type and size). `None`: unknown or damaged. -fn record(p: &mut Player, kind: u32, rec: &[u8]) -> Option<()> { - let mut r = Reader::new(rec.get(8..)?); - match kind { - 2..=6 | 85..=89 => rec_poly(p, kind, &mut r)?, - 7 | 90 => p.poly_poly(&polys(&mut r, kind == 7)?, false), - 8 | 91 => p.poly_poly(&polys(&mut r, kind == 8)?, true), - 9 => p.set_window_ext(f64::from(r.i32()?), f64::from(r.i32()?)), - 10 => p.set_window_org(f64::from(r.i32()?), f64::from(r.i32()?)), - 11 => p.set_viewport_ext(f64::from(r.i32()?), f64::from(r.i32()?)), - 12 => p.set_viewport_org(f64::from(r.i32()?), f64::from(r.i32()?)), - 17 => p.set_map_mode(r.u32()?), - 19 => p.set_fill_mode(r.u32()? == 1), - 22 => p.set_text_align(r.u32()?), - 24 => p.set_text_color(color(&mut r)?), - 27 => p.move_to(point32(&mut r)?), - 29 => p.exclude_clip(), - 30 => p.clip_rect(rect(&mut r)?), - 31 | 32 => { - let (xn, xd, yn, yd) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); - p.scale_ext(kind == 32, (f64::from(xn), f64::from(xd)), (f64::from(yn), f64::from(yd))); - } - 33 => p.save(), - 34 => p.restore(r.i32()?), - 35 => p.world(xform(&mut r)?, 4), - 36 => { - let x = xform(&mut r)?; - p.world(x, r.u32()?); - } - 37 => p.select(r.u32()?), - 38 => rec_create_pen(p, &mut r)?, - 39 => { - let ih = r.u32()?; - let style = r.u32()?; - p.create_at(ih, Obj::Brush(BrushObj::from_log(style, color(&mut r)?))); - } - 40 => p.delete(r.u32()?), - 42 => p.figure(vectorcraft_geom::shapes::ellipse(rect(&mut r)?.abs()).to_bezpath(), true), - 43 => p.figure(vectorcraft_geom::shapes::rectangle(rect(&mut r)?.abs()).to_bezpath(), true), - 44 => { - let b = rect(&mut r)?; - let (cx, cy) = (f64::from(r.i32()?), f64::from(r.i32()?)); - p.figure(super::round_rect(b, cx, cy), true); - } - 45..=47 => { - let b = rect(&mut r)?; - let (s, e) = (point32(&mut r)?, point32(&mut r)?); - p.arc(b, s, e, [ArcKind::Open, ArcKind::Chord, ArcKind::Pie][(kind - 45) as usize]); - } - 49 | 99 | 122 => p.create_at(r.u32()?, Obj::Other), - 54 => { - let pt = point32(&mut r)?; - p.poly_to(&[pt], false); - } - 57 => p.set_arc_ccw(r.u32()? != 2), - 58 => p.set_miter(f64::from(r.u32()?)), - 59 => p.begin_path(), - 60 => p.end_path(), - 61 => p.close_figure(), - 62 => p.paint_path(true, false), - 63 => p.paint_path(true, true), - 64 => p.paint_path(false, true), - 67 => p.clip_path(r.u32()?), - 68 => p.abort_path(), - 70 => rec_comment(p, &mut r)?, - 75 => rec_clip_region(p, &mut r)?, - 76 | 77 => rec_blt(p, kind, rec, &mut r)?, - 81 => rec_stretch_dibits(p, rec, &mut r)?, - 82 => { - let ih = r.u32()?; - p.create_at(ih, Obj::Font(font(&mut r)?)); - } - 83 | 84 => rec_text(p, kind, rec, &mut r)?, - 93 | 94 => p.create_at(r.u32()?, Obj::Brush(BrushObj::Pattern)), - 95 => rec_ext_create_pen(p, &mut r)?, - 114 => rec_alpha_blend(p, rec, &mut r)?, - k if IGNORED.contains(&k) => {} - _ => return None, - } - Some(()) -} diff --git a/crates/metafile/src/import/pict.rs b/crates/metafile/src/import/pict.rs index a9b4cae9da..f6abbc6b83 100644 --- a/crates/metafile/src/import/pict.rs +++ b/crates/metafile/src/import/pict.rs @@ -24,7 +24,7 @@ use std::io::Cursor; use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect, Shape, Vec2, shapes}; -use super::{BAD_IMAGE, BrushObj, FontObj, PS_GEOMETRIC, PenObj, Player, SRCCOPY, TOO_MANY_PIXELS, crop, round_rect}; +use super::{BAD_IMAGE, FontObj, PS_GEOMETRIC, PenObj, Player, SRCCOPY, TOO_MANY_PIXELS, crop, round_rect}; use crate::dib::{self, Rgba}; use crate::{Imported, Kind}; @@ -600,7 +600,7 @@ impl Pict { let base = Affine::translate(-frame.origin().to_vec2()); let artboard = Rect::new(0.0, 0.0, frame.width(), frame.height()); // A picture's coordinates map straight onto the document, as a WMF's do. - let p = Player::new(Kind::Wmf, base, 25.4 / 72.0, artboard); + let p = Player::new(Kind::Wmf, base, artboard); Self { p, frame, @@ -783,7 +783,7 @@ impl Pict { /// Fill `bp` (picture space) with `color`. fn fill(&mut self, mut bp: BezPath, color: [u8; 3], rule: FillRule) { bp.apply_affine(self.p.xf()); - self.p.dc.brush = BrushObj::Solid(color); + self.p.dc.brush = color; self.p.dc.fill_rule = rule; self.p.draw(bp, true, false); } diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs new file mode 100644 index 0000000000..d269fd27ca --- /dev/null +++ b/crates/metafile/src/import/picture.rs @@ -0,0 +1,523 @@ +//! EMF (with or without EMF+ records) and WMF files: `craft_metafile` plays the records into a +//! picture, and the picture's items become document objects here. +//! +//! - A filled or stroked path becomes a path with a fill and a stroke (caps, joins, miter limit +//! and dashes from the pen). Strokes drawn one after another, each starting where the last one +//! ended, with the same pen and clip, join into one path. +//! - A path filled with a pattern or hatched brush is filled with the average color of the +//! brush's tile. +//! - A bitmap becomes an image (its pixels as PNG) placed where the picture draws it, turned or +//! skewed with it. +//! - Text becomes point type in the font the file names, underlined or struck through as the font +//! is, and clipped to its rectangle when the file clips it. +//! - Clip regions and clip paths become clipping groups around what they clip; a rectangle around +//! the whole picture clips nothing and is left out. +//! +//! A WMF without a placeable header is read at 1440 units an inch, and one whose placeable box is +//! empty or inverted at the units an inch the header gives. An EMF without a usable frame is read +//! at its device's pixels a millimetre (96 pixels an inch when the header doesn't say). + +use std::collections::HashMap; +use std::sync::Arc; + +use craft_metafile::{Cap, ClipPath, HAlign, Item, Join, LineStyle, Seg, VAlign}; +use vectorcraft_color::Paint; +use vectorcraft_doc::clipnest::{Clip, Drawn, MAX_NEST, deep_clips, nest}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeId, + NodeKind, StrokeLayer, TextObject, +}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, shapes}; + +use super::{BAD_IMAGE, FAR_AWAY, HAIRLINE, LIMIT, MAX_NODES, around, rgb, sane}; +use crate::dib::Rgba; +use crate::wmf::PLACEABLE_KEY; +use crate::{Imported, Kind}; + +const PATTERNS: &str = "hatched and pattern brushes are filled with their average color"; +const ENDS_EARLY: &str = "the file ends early: what it held was imported"; + +/// Points a unit of a WMF without a placeable header (1440 units an inch). +const WMF_UNIT: f64 = 72.0 / 1440.0; +/// Points a millimetre. +const PT_PER_MM: f64 = 72.0 / 25.4; + +/// Points a picture unit across and down. `craft_metafile` gives a placeable WMF with a box and an +/// EMF with a frame in points, and other files in their logical (WMF) or device (EMF) units. +fn unit(bytes: &[u8], kind: Kind) -> (f64, f64) { + let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); + let i16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| i16::from_le_bytes([b[0], b[1]])); + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let i32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let positive = |v: Option| v.filter(|v| *v > 0).map(f64::from); + match kind { + Kind::Wmf => { + if bytes.get(..4) != Some(&PLACEABLE_KEY.to_le_bytes()[..]) { + return (WMF_UNIT, WMF_UNIT); + } + if let (Some(l), Some(t), Some(r), Some(b)) = (i16_at(6), i16_at(8), i16_at(10), i16_at(12)) + && r > l + && b > t + { + return (1.0, 1.0); + } + let k = 72.0 / u16_at(14).filter(|v| *v > 0).map_or(1440.0, f64::from); + (k, k) + } + Kind::Emf => { + // Device pixels a millimetre on each axis: szlDevice over szlMicrometers when the header + // has them, else over szlMillimeters (both sizes must be positive). + let device = positive(i32_at(72)).zip(positive(i32_at(76))); + let micro = (u32_at(4).is_some_and(|s| s >= 108)) + .then(|| u32_at(100).zip(u32_at(104)).filter(|(w, h)| *w > 0 && *h > 0).map(|(w, h)| (f64::from(w) / 1000.0, f64::from(h) / 1000.0))) + .flatten(); + let mm = micro.or_else(|| positive(i32_at(80)).zip(positive(i32_at(84)))); + let px_mm = device.zip(mm).map(|((w, h), (mw, mh))| (w / mw, h / mh)); + let frame = matches!((i32_at(24), i32_at(28), i32_at(32), i32_at(36)), (Some(l), Some(t), Some(r), Some(b)) if r > l && b > t); + let ok = |v: f64| v.is_finite() && v > 0.0; + match px_mm { + Some(_) if frame => (1.0, 1.0), + Some((x, y)) if ok(x) && ok(y) => (PT_PER_MM / x, PT_PER_MM / y), + _ => (0.75, 0.75), + } + } + } +} + +/// What a stroke is drawn with, to tell whether two strokes join into one path. +#[derive(Clone, PartialEq)] +struct Pen { + color: [u8; 4], + width: f32, + line: LineStyle, + even_odd: bool, + chain: Vec, +} + +/// Strokes drawn one after another, not added yet: their pen, clips and path (document space). +struct Pending { + pen: Pen, + clips: Vec>, + path: BezPath, +} + +/// Clip paths already made, by address (the `Arc` keeps the address from being reused); None for +/// one that clips nothing in the picture. +type MadeClips = HashMap<*const ClipPath, (Arc, Option>)>; +/// The last clip region and the clip made for it. +type MadeRegion = (Vec<[f32; 4]>, Option>); + +struct Mapper { + /// Picture units → points. + xf: Affine, + /// How much lengths (stroke widths, dashes) grow from the picture to points. + k: f64, + frame: Rect, + doc: Document, + out: Drawn, + warnings: Vec, + next_clip: u32, + paths: MadeClips, + region: Option, + pending: Option, +} + +fn color(c: [u8; 4]) -> Paint { + rgb([c[0], c[1], c[2]]) +} + +/// The average color of a tile's pixels, weighted by their alpha, and their average alpha; None +/// for a clear tile. +fn average(rgba: &[u8]) -> Option<[u8; 4]> { + let pixels = rgba.as_chunks::<4>().0; + let mut sum = [0u64; 4]; + for p in pixels { + let a = u64::from(p[3]); + sum[0] += u64::from(p[0]) * a; + sum[1] += u64::from(p[1]) * a; + sum[2] += u64::from(p[2]) * a; + sum[3] += a; + } + let a = sum[3]; + let alpha = (a / pixels.len().max(1) as u64) as u8; + (a > 0).then(|| [(sum[0] / a) as u8, (sum[1] / a) as u8, (sum[2] / a) as u8, alpha.max(1)]) +} + +impl Mapper { + fn new(width: f64, height: f64, (kx, ky): (f64, f64)) -> Self { + let side = |v: f64| if v.is_finite() { v.clamp(1.0, LIMIT) } else { 1.0 }; + let (w, h) = (side(width * kx), side(height * ky)); + let mut doc = Document::new(w, h); + doc.layers.clear(); + Self { + xf: Affine::scale_non_uniform(kx, ky), + // The mean of the two, as the reader scales stroke lengths. + k: (kx + ky) / 2.0, + frame: Rect::new(0.0, 0.0, w, h), + doc, + out: vec![], + warnings: vec![], + next_clip: 0, + paths: HashMap::new(), + region: None, + pending: None, + } + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn path(&self, segs: &[Seg]) -> BezPath { + let mut bp = craft_metafile::bez_path(segs); + bp.apply_affine(self.xf); + bp + } + + /// A clip of `path` (document space), or None when it clips nothing in the picture. + fn clip(&mut self, path: BezPath, rule: FillRule) -> Option> { + if around(&path, self.frame) { + return None; + } + self.next_clip += 1; + Some(Arc::new(Clip { id: self.next_clip, region: Some((path, rule)) })) + } + + /// The clips an item is drawn under, outermost first; None when it shows nowhere. + fn chain(&mut self, region: Option>, clips: &[Arc]) -> Option>> { + let mut chain = vec![]; + if let Some(rects) = region { + if rects.is_empty() { + return None; + } + let made = match &self.region { + Some((last, made)) if *last == rects => made.clone(), + _ => { + let mut bp = BezPath::new(); + for [x0, y0, x1, y1] in &rects { + bp.extend(shapes::rectangle(Rect::new(f64::from(*x0), f64::from(*y0), f64::from(*x1), f64::from(*y1))).to_bezpath()); + } + bp.apply_affine(self.xf); + if !sane(bp.bounding_box()) { + self.warn(FAR_AWAY); + return None; + } + let made = self.clip(bp, FillRule::NonZero); + self.region = Some((rects, made.clone())); + made + } + }; + chain.extend(made); + } + for c in clips { + let key = Arc::as_ptr(c); + let made = match self.paths.get(&key) { + Some((_, made)) => made.clone(), + None => { + let bp = self.path(&c.segs); + if !sane(bp.bounding_box()) { + self.warn(FAR_AWAY); + return None; + } + let made = self.clip(bp, if c.even_odd { FillRule::EvenOdd } else { FillRule::NonZero }); + self.paths.insert(key, (c.clone(), made.clone())); + made + } + }; + chain.extend(made); + } + if chain.len() > MAX_NEST { + chain.truncate(MAX_NEST); + self.warn(&deep_clips()); + } + Some(chain) + } + + fn push(&mut self, clips: Vec>, mut node: Node) { + if self.out.len() >= MAX_NODES { + return; + } + if !node.visual_bounds().is_none_or(sane) { + return self.warn(FAR_AWAY); + } + node.id = self.doc.alloc_id(); + self.out.push((clips, node)); + } + + /// Add the strokes waiting to be joined. + fn flush(&mut self) { + if let Some(p) = self.pending.take() { + let node = self.path_node(p.path, None, Some((p.pen.color, p.pen.width, &p.pen.line)), p.pen.even_odd); + if let Some(n) = node { + self.push(p.clips, n); + } + } + } + + fn stroke_layer(&self, c: [u8; 4], width: f32, line: &LineStyle) -> StrokeLayer { + let w = f64::from(width) * self.k; + let mut st = StrokeLayer::new(color(c), if w > 0.0 && w.is_finite() { w } else { HAIRLINE }); + st.opacity = f32::from(c[3]) / 255.0; + st.cap = match line.cap { + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + Cap::Flat => LineCap::Butt, + }; + st.join = match line.join { + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + Join::Miter => LineJoin::Miter, + }; + let miter = f64::from(line.miter_limit); + if miter.is_finite() && miter >= 1.0 { + st.miter_limit = miter; + } + let dash = Dash { pattern: line.dash.iter().map(|d| f64::from(*d) * self.k).collect(), offset: 0.0, align_corners: false }; + st.dash = dash.is_dashed().then_some(dash); + st + } + + /// A path node painted with `fill` and `stroke`; None when neither shows. + fn path_node(&self, bp: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f32, &LineStyle)>, even_odd: bool) -> Option { + if bp.elements().is_empty() { + return None; + } + let mut items = vec![]; + if let Some(c) = fill.filter(|c| c[3] > 0) { + let mut f = FillLayer::new(color(c)); + f.opacity = f32::from(c[3]) / 255.0; + items.push(AppearanceItem::Fill(f)); + } + if let Some((c, w, line)) = stroke.filter(|s| s.0[3] > 0) { + items.push(AppearanceItem::Stroke(self.stroke_layer(c, w, line))); + } + if items.is_empty() { + return None; + } + let mut n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance { items, ..Appearance::default() }); + if let NodeKind::Path { rule, .. } = &mut n.kind { + *rule = if even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; + } + Some(n) + } + + fn item(&mut self, item: Item) { + match item { + Item::Path { segs, fill, stroke, line, even_odd, region, clips } => { + let Some(chain) = self.chain(region, &clips) else { return self.flush() }; + let bp = self.path(&segs); + // An open stroke with nothing filled waits, to join the next one. + if let (None, Some((c, w))) = (fill, stroke) + && !segs.iter().any(|s| matches!(s, Seg::Close)) + { + let pen = Pen { color: c, width: w, line, even_odd, chain: chain.iter().map(|c| c.id).collect() }; + let start = bp.elements().first().and_then(|e| match e { + PathEl::MoveTo(p) => Some(*p), + _ => None, + }); + if let Some(p) = &mut self.pending + && p.pen == pen + && start.is_some() + && p.path.elements().last().and_then(|e| e.end_point()) == start + { + p.path.extend(bp.elements().iter().skip(1).copied()); + return; + } + self.flush(); + self.pending = Some(Pending { pen, clips: chain, path: bp }); + return; + } + self.flush(); + let stroke = stroke.map(|(c, w)| (c, w, &line)); + if let Some(n) = self.path_node(bp, fill, stroke, even_odd) { + self.push(chain, n); + } + } + Item::Pattern { segs, even_odd, tile, region, clips } => { + self.flush(); + let Some(chain) = self.chain(region, &clips) else { return }; + let Some(c) = average(&tile.rgba) else { return }; + self.warn(PATTERNS); + let bp = self.path(&segs); + if let Some(n) = self.path_node(bp, Some(c), None, even_odd) { + self.push(chain, n); + } + } + Item::Bitmap { transform, width, height, rgba, region, clips, .. } => { + self.flush(); + let Some(chain) = self.chain(region, &clips) else { return }; + self.image(Rgba { width, height, pixels: rgba }, Affine::new(transform), chain); + } + Item::Text(t) => { + self.flush(); + let Some(mut chain) = self.chain(t.region.clone(), &t.clips) else { return }; + // The text's own clip rectangle, inside the others. + if let Some([x0, y0, x1, y1]) = t.clip { + let mut bp = shapes::rectangle(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1)).abs()).to_bezpath(); + bp.apply_affine(self.xf); + if !sane(bp.bounding_box()) { + return self.warn(FAR_AWAY); + } + if let Some(own) = self.clip(bp, FillRule::NonZero) { + // Past the stack, the text's own clip takes the innermost place kept. + if chain.len() >= MAX_NEST { + chain.truncate(MAX_NEST.saturating_sub(1)); + self.warn(&deep_clips()); + } + chain.push(own); + } + } + self.text(&t, chain); + } + } + } + + /// An image whose unit square `unit` places in the picture. + fn image(&mut self, img: Rgba, unit: Affine, chain: Vec>) { + if img.width == 0 || img.height == 0 { + return; + } + let xf = self.xf * unit * Affine::scale_non_uniform(1.0 / f64::from(img.width), 1.0 / f64::from(img.height)); + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + return; + } + let Ok(png) = img.to_png() else { return self.warn(BAD_IMAGE) }; + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + self.doc.images.entry(key.clone()).or_insert(blob); + let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None }; + self.push(chain, Node::new(NodeId(0), NodeKind::Image(Box::new(im)))); + } + + fn text(&mut self, t: &craft_metafile::Text, chain: Vec>) { + let s: String = t.text.chars().filter(|c| *c != '\0').collect(); + if s.trim().is_empty() || t.color[3] == 0 { + return; + } + // Text space → document without the move: its vertical scale gives the type size, and what + // is left of it (a turn, a stretch across) goes on the text object. + let [a, b, c, d, ..] = t.transform; + let linear = self.xf * Affine::new([a, b, c, d, 0.0, 0.0]); + let [_, _, c, d, ..] = linear.as_coeffs(); + let k = c.hypot(d); + let shape = if k > 1e-12 && k.is_finite() && linear.determinant().abs() > 1e-24 { Affine::scale(1.0 / k) * linear } else { Affine::IDENTITY }; + let f = &t.font; + let h = f64::from(f.height); + // A negative height is the em; a positive one the cell (the em and the internal leading). + let em = if h < 0.0 { -h } else { h * 0.85 }; + let size = if em > 0.0 && (em * k).is_finite() { (em * k).clamp(0.5, 5000.0) } else { 12.0 }; + let mut style = CharStyle { size, fill: color(t.color), stroke: Paint::None, ..CharStyle::default() }; + if !f.family.trim().is_empty() { + style.font_family = f.family.trim().to_string(); + } + style.font_style = match (f.weight >= 600, f.italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + } + .into(); + style.underline = f.underline; + style.strikethrough = f.strike; + // Where the baseline is below the reference point. + let drop = match t.v_align { + VAlign::Baseline => 0.0, + VAlign::Bottom => -0.2 * size, + VAlign::Top => 0.8 * size, + }; + let origin = self.xf * Point::new(t.transform[4], t.transform[5]); + let mut text = TextObject::point(Point::ZERO, &s, style); + text.xf = Affine::translate(origin.to_vec2()) * shape * Affine::translate((0.0, drop)); + text.para.justify = match t.h_align { + HAlign::Center => Justify::Center, + HAlign::Right => Justify::Right, + HAlign::Left => Justify::Left, + }; + text.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text).bounds); + let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(text))); + n.opacity = f32::from(t.color[3]) / 255.0; + self.push(chain, n); + } + + /// The document: what was drawn on one layer, clipped art in clipping groups. + fn finish(mut self, kind: Kind, skipped: u32) -> Imported { + self.flush(); + if skipped > 0 { + let n = skipped; + self.warnings.push(format!( + "{n} record{} of kinds VectorCraft doesn't read {} skipped", + if n == 1 { "" } else { "s" }, + if n == 1 { "was" } else { "were" } + )); + } + let out = std::mem::take(&mut self.out); + let children = nest(&mut self.doc, out); + let mut layer = Node::layer(self.doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); + if let Some(c) = layer.children_mut() { + *c = children; + } + self.doc.layers = vec![Arc::new(layer)]; + Imported { document: self.doc, kind, warnings: self.warnings } + } +} + +/// Does the file stop before its end-of-file record? +fn ends_early(bytes: &[u8], kind: Kind) -> bool { + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); + let mut at = match kind { + Kind::Emf => 0, + Kind::Wmf => { + let start = if u32_at(0) == Some(PLACEABLE_KEY) { 22 } else { 0 }; + let Some(words) = u16_at(start + 2) else { return true }; + start + usize::from(words) * 2 + } + }; + loop { + let size = match kind { + Kind::Emf => { + let (Some(kind), Some(size)) = (u32_at(at), u32_at(at.saturating_add(4))) else { return true }; + if kind == 14 { + return false; + } + // Records are a multiple of 4 bytes long; reading stops at one that isn't. + if !size.is_multiple_of(4) { + return true; + } + size as usize + } + Kind::Wmf => { + let (Some(words), Some(function)) = (u32_at(at), u16_at(at.saturating_add(4))) else { return true }; + if function == 0 { + return false; + } + (words as usize).saturating_mul(2) + } + }; + if size < 6 { + return true; + } + at = at.saturating_add(size); + } +} + +/// Read an EMF or WMF file. +pub(crate) fn import(bytes: &[u8]) -> Result { + let kind = crate::sniff(bytes).ok_or("not an EMF or WMF file")?; + let pic = craft_metafile::parse(bytes).map_err(|e| match e { + craft_metafile::Error::NotMetafile => "not an EMF or WMF file".to_string(), + craft_metafile::Error::BadHeader => format!("the {} header is damaged", kind.id().to_uppercase()), + craft_metafile::Error::NoRecords => format!("the {} file holds no records", kind.id().to_uppercase()), + })?; + let k = unit(bytes, kind); + let mut m = Mapper::new(f64::from(pic.width), f64::from(pic.height), k); + if ends_early(bytes, kind) { + m.warn(ENDS_EARLY); + } + for item in pic.items { + m.item(item); + } + Ok(m.finish(kind, pic.skipped_records)) +} diff --git a/crates/metafile/src/import/wmf.rs b/crates/metafile/src/import/wmf.rs deleted file mode 100644 index 7a682e59a9..0000000000 --- a/crates/metafile/src/import/wmf.rs +++ /dev/null @@ -1,338 +0,0 @@ -//! WMF records (MS-WMF) played on a [`Player`]. A placeable header gives the picture's box in -//! logical units and the units an inch; without one, the first window origin and extent are the -//! box, at 1440 units an inch. - -use vectorcraft_geom::{Affine, Point, Rect, shapes}; - -use super::{ArcKind, BrushObj, FontObj, Obj, PenObj, Player}; -use crate::bytes::Reader; -use crate::dib::{self, Alpha}; -use crate::wmf::PLACEABLE_KEY; -use crate::{Imported, Kind}; - -/// Units an inch when the file doesn't say (twips). -const DEFAULT_INCH: f64 = 1440.0; -/// Records that change nothing VectorCraft keeps (background, raster and mapping details, palettes, -/// escapes). -const IGNORED: &[u16] = &[ - 0x0102, 0x0103, 0x0104, 0x0105, 0x0107, 0x0108, 0x0201, 0x020A, 0x020B, 0x020C, 0x020D, 0x020E, 0x020F, 0x0211, 0x0410, 0x0412, 0x0231, 0x0234, - 0x0035, 0x0037, 0x0139, 0x0436, 0x0626, -]; - -fn point(r: &mut Reader) -> Option { - Some(Point::new(f64::from(r.i16()?), f64::from(r.i16()?))) -} - -/// A point stored y first. -fn point_yx(r: &mut Reader) -> Option { - let y = f64::from(r.i16()?); - Some(Point::new(f64::from(r.i16()?), y)) -} - -/// A rectangle stored bottom, right, top, left. -fn rect_brtl(r: &mut Reader) -> Option { - let (b, rt, t, l) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - Some(Rect::new(f64::from(l), f64::from(t), f64::from(rt), f64::from(b))) -} - -fn color(r: &mut Reader) -> Option<[u8; 3]> { - let [red, g, b, _] = r.u32()?.to_le_bytes(); - Some([red, g, b]) -} - -fn points(r: &mut Reader, n: usize) -> Option> { - if r.rest().len() < n * 4 { - return None; - } - (0..n).map(|_| point(r)).collect() -} - -/// ANSI text (read as Latin-1). -fn ansi(b: &[u8]) -> String { - b.iter().map(|c| char::from(*c)).collect() -} - -/// The records' start, and the box and units an inch of a placeable header. -fn header(bytes: &[u8]) -> Option<(usize, Option<(Rect, f64)>)> { - let mut r = Reader::new(bytes); - let placeable = if r.u32()? == PLACEABLE_KEY { - r.skip(2)?; - let (l, t, rt, b) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - let inch = r.u16()?; - let inch = if inch == 0 { DEFAULT_INCH } else { f64::from(inch) }; - Some((Rect::new(f64::from(l), f64::from(t), f64::from(rt), f64::from(b)).abs(), inch)) - } else { - None - }; - let start = if placeable.is_some() { 22 } else { 0 }; - let mut h = Reader::new(bytes.get(start..)?); - h.skip(2)?; - let words = usize::from(h.u16()?); - if words < 9 { - return None; - } - Some((start + words * 2, placeable)) -} - -/// The first window origin and extent, as the picture's box when there is no placeable header. -fn window(bytes: &[u8], mut at: usize) -> Option { - let (mut org, mut ext) = (None, None); - while let Some(head) = bytes.get(at..at + 6) { - let words = u32::from_le_bytes([head[0], head[1], head[2], head[3]]) as usize; - let function = u16::from_le_bytes([head[4], head[5]]); - if words < 3 || function == 0 { - break; - } - let mut r = Reader::new(bytes.get(at + 6..).unwrap_or_default()); - match function { - 0x020B if org.is_none() => org = point_yx(&mut r), - 0x020C if ext.is_none() => ext = point_yx(&mut r), - _ => {} - } - if org.is_some() && ext.is_some() { - break; - } - at = at.saturating_add(words.saturating_mul(2)); - } - let o = org.unwrap_or(Point::ZERO); - let e = ext?; - Some(Rect::new(o.x, o.y, o.x + e.x, o.y + e.y).abs()) -} - -/// Logical → document for a placeable file whose window (`org`, `ext`) is mapped onto its box `b`; -/// a negative extent flips that axis, as in GDI. -fn window_map(b: Rect, k: f64, org: Point, ext: Point) -> Affine { - let ratio = |box_len: f64, e: f64| if e.abs() > 1e-12 { box_len / e } else { 1.0 }; - Affine::scale_non_uniform(k * ratio(b.width(), ext.x), k * ratio(b.height(), ext.y)) * Affine::translate(-org.to_vec2()) -} - -pub(super) fn play(bytes: &[u8]) -> Result { - let (start, placeable) = header(bytes).ok_or("the WMF header is damaged")?; - let (frame, inch) = match placeable { - Some((b, inch)) if b.width() > 0.0 && b.height() > 0.0 => (Some(b), inch), - _ => (window(bytes, start).filter(|w| w.width() > 0.0 && w.height() > 0.0), DEFAULT_INCH), - }; - let k = 72.0 / inch; - let origin = frame.map_or(Point::ZERO, |f| f.origin()); - let artboard = frame.map_or(Rect::new(0.0, 0.0, 1.0, 1.0), |f| Rect::new(0.0, 0.0, f.width() * k, f.height() * k)); - let mut p = Player::new(Kind::Wmf, Affine::scale(k) * Affine::translate(-origin.to_vec2()), 25.4 / inch, artboard); - // With a placeable header, the window records map the window onto the box. - let placed = placeable.map(|(b, _)| b).filter(|b| b.width() > 0.0 && b.height() > 0.0); - let mut win = (placed.map_or(Point::ZERO, |b| b.origin()), placed.map_or(Point::ZERO, |b| Point::new(b.width(), b.height()))); - let mut at = start; - let mut ended = false; - while let Some(head) = bytes.get(at..at + 6) { - let words = u32::from_le_bytes([head[0], head[1], head[2], head[3]]) as usize; - let function = u16::from_le_bytes([head[4], head[5]]); - if function == 0 { - ended = true; - break; - } - let Some(rec) = words.checked_mul(2).and_then(|n| bytes.get(at..at.checked_add(n)?)).filter(|_| words >= 3) else { break }; - if !matches!(function, 0x0213 | 0x0214) { - p.flush(); - } - if let (Some(b), 0x020B | 0x020C) = (placed, function) { - if let Some(v) = point_yx(&mut Reader::new(rec.get(6..).unwrap_or_default())) { - if function == 0x020B { - win.0 = v; - } else { - win.1 = v; - } - p.dev_to_doc = window_map(b, k, win.0, win.1); - } - } else if record(&mut p, function, rec.get(6..).unwrap_or_default()).is_none() { - p.skip(); - } - at += rec.len(); - } - if !ended { - p.warn("the file ends early: what it held was imported"); - } - let mut out = p.finish(); - // No box given: the art's bounds. - if frame.is_none() - && let Some(b) = out.document.layers.first().and_then(|l| vectorcraft_doc::live::nodes_bounds(l.children().map_or(&[][..], |c| c.as_slice()))) - && let Some(a) = out.document.artboards.first_mut() - { - a.rect = Rect::from_origin_size(b.origin(), (b.width().max(1.0), b.height().max(1.0))); - } - Ok(out) -} - -/// An image record's source rectangle (from the bottom for a bottom-up DIB) and destination. -fn blit(p: &mut Player, rop: u32, src: (i16, i16, i16, i16), dest: (i16, i16, i16, i16), packed: Option<&[u8]>) { - let (xd, yd, wd, hd) = (f64::from(dest.0), f64::from(dest.1), f64::from(dest.2), f64::from(dest.3)); - let d = Rect::new(xd, yd, xd + wd, yd + hd); - let Some(packed) = packed else { return p.pattern_blit(d, rop) }; - let parts = dib::split(packed); - let img = parts.ok_or_else(|| "damaged".to_string()).and_then(|(bmi, bits)| dib::decode(bmi, bits, Alpha::Ignore, &mut p.pixels_left)); - let bottom_up = parts.and_then(|(bmi, _)| bmi.get(8..12)).is_some_and(|h| i32::from_le_bytes([h[0], h[1], h[2], h[3]]) > 0); - let ih = img.as_ref().map_or(0.0, |i| f64::from(i.height)); - let (xs, ys, ws, hs) = (f64::from(src.0), f64::from(src.1), f64::from(src.2), f64::from(src.3)); - let top = if bottom_up { ih - ys - hs } else { ys }; - p.image(img, Rect::new(xs, top, xs + ws, top + hs), d, rop, 1.0); -} - -/// PolyPolygon (0x0538). -fn rec_poly_polygon(p: &mut Player, r: &mut Reader) -> Option<()> { - let n = usize::from(r.u16()?); - if r.rest().len() < n * 2 { - return None; - } - let counts: Vec = (0..n).map(|_| r.u16().map(usize::from)).collect::>()?; - let mut polys = Vec::with_capacity(n); - for c in counts { - polys.push(points(r, c)?); - } - p.poly_poly(&polys, true); - Some(()) -} - -/// Arc (0x0817), Pie (0x081A) and Chord (0x0830). -fn rec_arc(p: &mut Player, function: u16, r: &mut Reader) -> Option<()> { - let (end, start) = (point_yx(r)?, point_yx(r)?); - let b = rect_brtl(r)?; - let kind = match function { - 0x0817 => ArcKind::Open, - 0x081A => ArcKind::Pie, - _ => ArcKind::Chord, - }; - p.arc(b, start, end, kind); - Some(()) -} - -/// CreateFontIndirect (0x02FB). -fn rec_create_font(p: &mut Player, r: &mut Reader) -> Option<()> { - let height = f64::from(r.i16()?); - r.skip(2)?; - let escapement = f64::from(r.i16()?); - r.skip(2)?; - let weight = i32::from(r.i16()?); - let italic = r.bytes(1)?.first().is_some_and(|b| *b != 0); - r.skip(7)?; - let face: Vec = r.rest().iter().take(32).take_while(|b| **b != 0).copied().collect(); - p.create(Obj::Font(FontObj { height, escapement, weight, italic, face: ansi(&face) })); - Some(()) -} - -/// TextOut (0x0521). -fn rec_text_out(p: &mut Player, r: &mut Reader) -> Option<()> { - let n = usize::try_from(r.i16()?).ok()?; - let s = ansi(r.bytes(n)?); - r.skip(n % 2)?; - let at = point_yx(r)?; - p.text(at, &s); - Some(()) -} - -/// ExtTextOut (0x0A32). -fn rec_ext_text_out(p: &mut Player, r: &mut Reader) -> Option<()> { - let at = point_yx(r)?; - let n = usize::try_from(r.i16()?).ok()?; - let opts = r.u16()?; - // ETO_OPAQUE or ETO_CLIPPED: a rectangle comes first. - if opts & 0x6 != 0 { - r.skip(8)?; - } - let s = ansi(r.bytes(n)?); - p.text(at, &s); - Some(()) -} - -/// StretchDIB (0x0F43). -fn rec_stretch_dib(p: &mut Player, r: &mut Reader) -> Option<()> { - let rop = r.u32()?; - r.skip(2)?; - let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), Some(r.rest())); - Some(()) -} - -/// StretchBlt (0x0B41). -fn rec_stretch_blt(p: &mut Player, body: &[u8], r: &mut Reader) -> Option<()> { - let rop = r.u32()?; - let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - // Without a bitmap, a reserved word comes before the destination. - let with_bitmap = body.len() > 22; - if !with_bitmap { - r.skip(2)?; - } - let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), with_bitmap.then(|| r.rest())); - Some(()) -} - -/// BitBlt (0x0940). -fn rec_bit_blt(p: &mut Player, body: &[u8], r: &mut Reader) -> Option<()> { - let rop = r.u32()?; - let (ys, xs) = (r.i16()?, r.i16()?); - let with_bitmap = body.len() > 20; - if !with_bitmap { - r.skip(2)?; - } - let (h, w, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); - blit(p, rop, (xs, ys, w, h), (xd, yd, w, h), with_bitmap.then(|| r.rest())); - Some(()) -} - -/// Play one record (`r`: its parameters). `None`: unknown or damaged. -fn record(p: &mut Player, function: u16, body: &[u8]) -> Option<()> { - let mut r = Reader::new(body); - match function { - 0x001E => p.save(), - 0x0127 => p.restore(i32::from(r.i16()?)), - 0x0106 => p.set_fill_mode(r.u16()? == 1), - 0x0209 => p.set_text_color(color(&mut r)?), - 0x012E => p.set_text_align(u32::from(r.u16()?)), - 0x0214 => p.move_to(point_yx(&mut r)?), - 0x0213 => { - let pt = point_yx(&mut r)?; - p.poly_to(&[pt], false); - } - 0x0324 | 0x0325 => { - let n = usize::try_from(r.i16()?).ok()?; - let pts = points(&mut r, n)?; - p.poly(&pts, function == 0x0324, false); - } - 0x0538 => rec_poly_polygon(p, &mut r)?, - 0x041B => p.figure(shapes::rectangle(rect_brtl(&mut r)?.abs()).to_bezpath(), true), - 0x0418 => p.figure(shapes::ellipse(rect_brtl(&mut r)?.abs()).to_bezpath(), true), - 0x061C => { - let (h, w) = (f64::from(r.i16()?), f64::from(r.i16()?)); - p.figure(super::round_rect(rect_brtl(&mut r)?, w, h), true); - } - 0x0817 | 0x081A | 0x0830 => rec_arc(p, function, &mut r)?, - 0x0416 => p.clip_rect(rect_brtl(&mut r)?), - 0x0415 => p.exclude_clip(), - 0x012D => p.select(u32::from(r.u16()?)), - 0x01F0 => p.delete(u32::from(r.u16()?)), - 0x02FA => { - let style = u32::from(r.u16()?); - let width = f64::from(r.i16()?); - r.skip(2)?; - p.create(Obj::Pen(PenObj { style, width, color: color(&mut r)?, entries: vec![] })); - } - 0x02FC => { - let style = u32::from(r.u16()?); - p.create(Obj::Brush(BrushObj::from_log(style, color(&mut r)?))); - } - 0x02FB => rec_create_font(p, &mut r)?, - 0x00F7 | 0x06FF => p.create(Obj::Other), - 0x01F9 | 0x0142 => p.create(Obj::Brush(BrushObj::Pattern)), - 0x0521 => rec_text_out(p, &mut r)?, - 0x0A32 => rec_ext_text_out(p, &mut r)?, - 0x0F43 => rec_stretch_dib(p, &mut r)?, - 0x0B41 => rec_stretch_blt(p, body, &mut r)?, - 0x0940 => rec_bit_blt(p, body, &mut r)?, - 0x061D => { - let rop = r.u32()?; - let (h, w, y, x) = (f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?)); - p.pattern_blit(Rect::new(x, y, x + w, y + h), rop); - } - f if IGNORED.contains(&f) => {} - _ => return None, - } - Some(()) -} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index 0e699236d7..86d268cee1 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -13,13 +13,14 @@ //! lines or gradients, so clipped art is written whole, images over white, dashes as separate //! lines and gradients and patterns as one colour (each loss comes back as a warning). //! -//! [`import`] reads either format back into a document of one artboard (the picture's frame) and -//! one layer: paths with fills and strokes, clipping, images and text. Records it doesn't read are -//! skipped, with one warning. [`import_pict`] reads QuickDraw pictures (PICT, versions 1 and 2) -//! into the same kind of document. +//! [`import`] reads either format (an EMF with its EMF+ records too, through `craft_metafile`) +//! into a document of one artboard (the picture's frame) and one layer: paths with fills and +//! strokes, clipping, images and text. Records it doesn't read are skipped, with one warning. +//! [`import_pict`] reads QuickDraw pictures (PICT, versions 1 and 2) into the same kind of +//! document. //! -//! - `bytes`: little-endian writing and bounds-checked reading. -//! - `dib`: device-independent bitmaps, both ways. +//! - `bytes`: little-endian writing. +//! - `dib`: device-independent bitmaps for the writers. //! - `scene`: the document walk both writers share. //! - `emf`, `wmf`: the writers. //! - `import`: the readers. diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index 1e0ec5671b..689037d277 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -151,13 +151,14 @@ fn dashes_gradients_and_images_round_trip_through_emf() { // ---------- hand-built records ---------- /// An EMF of `records` (type, body) with a frame of `w` × `h` hundredths of a millimetre on a -/// device of 100 pixels a millimetre. +/// device of 100 pixels a millimetre. Bodies are padded to a multiple of 4 bytes. fn hand_emf(w: i32, h: i32, records: &[(u32, Vec)]) -> Vec { let mut o = Out::default(); let rec = |o: &mut Out, kind: u32, body: &[u8]| { o.u32(kind); - o.u32(8 + body.len() as u32); + o.u32(8 + body.len().next_multiple_of(4) as u32); o.bytes(body); + o.pad(4); }; let mut head = Out::default(); head.bytes(&[0; 16]); @@ -336,6 +337,18 @@ fn wmf_window_is_mapped_onto_the_placeable_box() { } } +#[test] +fn wmf_without_a_placeable_header_is_read_at_1440_units_an_inch() { + // The window is 2880 × 1440 units: 2 × 1 inches. The rectangle is its left half. + let records = vec![(0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 2880])), (0x041B, le16(&[1440, 1440, 0, 0]))]; + let plain = hand_wmf(2880, 1440, &records)[22..].to_vec(); + let back = import(&plain).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 144.0, 72.0), 1e-6), "{:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 72.0), 1e-6), "{:?}", p[0].geometric_bounds()); +} + #[test] fn damaged_files_fail_or_import_partly() { let good = emf(&sample()).bytes; @@ -371,19 +384,29 @@ fn png_stretch_dibits(png: &[u8]) -> Vec { } #[test] -fn embedded_bitmaps_share_one_pixel_budget() { - // #3600: each bitmap was bounded but not their total, so many records of the largest one a record - // may hold cost minutes. Those past the file's budget are left out before they're decoded. +fn oversized_embedded_bitmaps_are_left_out_before_decoding() { + // #3600: many records of the largest bitmap a record may hold cost minutes. Each of these claims + // more pixels than one image may have, so the reader leaves it out from its header. (The reader's + // budget for all the images of one file is tested where the reader is.) let records = vec![(81, png_stretch_dibits(&png_claiming(1 << 13, 1 << 13))); 5]; let back = import(&hand_emf(5000, 3000, &records)).unwrap(); - assert!(back.warnings.iter().any(|w| w == "images past the most pixels one file may hold were left out"), "{:?}", back.warnings); + assert!(!all(&back.document).iter().any(|n| matches!(n.kind, NodeKind::Image(_)))); } #[test] fn clips_nest_only_so_deep() { - // Each clip inside the last one used to nest a clipping group one level deeper, past the stack. - let mut records = vec![(30, le(&[0, 0, 1500, 1500])); 200_000]; - records.push((43, le(&[100, 100, 200, 200]))); + // Each clip inside the last one nests a clipping group one level deeper; past the stack they + // are left out with a warning. + let mut records = vec![]; + // As many as the reader keeps: one more than the stack. + for i in 0..32 { + // BEGINPATH, a rectangle a little smaller each time, ENDPATH, SELECTCLIPPATH (RGN_AND). + records.push((59, vec![])); + records.push((43, le(&[100 + i, 100 + i, 2000 - i, 2000 - i]))); + records.push((60, vec![])); + records.push((67, le(&[1]))); + } + records.push((43, le(&[500, 500, 600, 600]))); let back = import(&hand_emf(5000, 3000, &records)).unwrap(); let mut depth = 0; let mut n = back.document.layers[0].children().unwrap()[0].clone(); @@ -393,4 +416,267 @@ fn clips_nest_only_so_deep() { } assert_eq!(depth, vectorcraft_doc::clipnest::MAX_NEST); assert!(back.warnings.iter().any(|w| w.contains("clips nested more than")), "{:?}", back.warnings); + // A hostile count of clip records: each one used to nest a clipping group one level deeper, + // past the stack. They make one clip. + let mut records = vec![(30, le(&[0, 0, 1500, 1500])); 200_000]; + records.push((43, le(&[100, 100, 200, 200]))); + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let layer = back.document.layers[0].children().unwrap(); + assert_eq!(layer.len(), 1); + let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) }; + assert!(children.iter().all(|c| !matches!(c.kind, NodeKind::Group { .. })), "one level"); + assert_eq!(paths(&back.document).len(), 1); +} + +/// An EMR_COMMENT holding EMF+ `records` (type, flags, data). +fn emf_plus(records: &[(u16, u16, Vec)]) -> Vec { + let mut plus = Out::default(); + for (kind, flags, data) in records { + plus.u16(*kind); + plus.u16(*flags); + plus.u32(12 + data.len() as u32); + plus.u32(data.len() as u32); + plus.bytes(data); + } + let mut body = Out::default(); + body.u32(4 + plus.len() as u32); + // "EMF+" + body.u32(0x2B46_4D45); + body.bytes(&plus.0); + body.0 +} + +fn le_f32(values: &[f32]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() +} + +#[test] +fn emf_plus_only_pictures_import_their_drawing() { + // An EMF+ only file: its EMF records draw nothing, so it was imported empty with a warning. + // EmfPlusHeader (version, flags, 96 dpi), FillRects with an inline green color (flag S) and one + // rectangle of 10 × 5 mm at (10, 10) mm in device pixels, EmfPlusEndOfFile. + let header = le(&[0xDBC0_1002u32 as i32, 0, 96, 96]); + let rects = [le(&[0xFF00_FF00u32 as i32, 1]), le_f32(&[1000.0, 1000.0, 1000.0, 500.0])].concat(); + let comment = emf_plus(&[(0x4001, 0, header), (0x400A, 0x8000, rects), (0x4002, 0, vec![])]); + let back = import(&hand_emf(5000, 3000, &[(70, comment)])).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert_eq!(fill_rgb(&p[0]), Some([0, 255, 0])); + let mm = 72.0 / 25.4; + let b = p[0].geometric_bounds().unwrap(); + assert!(near(b, Rect::new(10.0 * mm, 10.0 * mm, 20.0 * mm, 15.0 * mm), 0.01), "{b:?}"); +} + +#[test] +fn hatched_brushes_fill_with_their_average_color() { + // CREATEBRUSHINDIRECT: a hatched (style 2) blue brush, selected; a null pen; a rectangle. The + // hatch is drawn over the white background, so the average is a light blue. + let records = vec![(39, le(&[1, 2, 0x00ff_0000, 0])), (37, le(&[1])), (37, le(&[0x8000_0008u32 as i32])), (43, le(&[1000, 1000, 2000, 1500]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + assert_eq!(back.warnings, vec!["hatched and pattern brushes are filled with their average color".to_string()]); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + let [r, g, b] = fill_rgb(&p[0]).unwrap(); + assert!(b == 255 && r == g && r > 0 && r < 255, "{:?}", [r, g, b]); + // With a transparent background (SETBKMODE 1) the hatch is blue with gaps: a translucent fill. + let mut records = records; + records.insert(0, (18, le(&[1]))); + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let p = paths(&back.document); + assert_eq!(fill_rgb(&p[0]), Some([0, 0, 255])); + let AppearanceItem::Fill(f) = &p[0].appearance.items[0] else { panic!("a fill") }; + assert!(f.opacity > 0.0 && f.opacity < 1.0, "{}", f.opacity); +} + +#[test] +fn text_keeps_a_stretch_across() { + // A world transform twice as wide as it is high, a 10 mm font and "Hi". + let mut text = vec![0; 16]; + text.extend(le(&[1, 0, 0])); + text.extend(le(&[1000, 1500, 2, 76, 0, 0, 0, 0, 0, 0])); + text.extend("Hi".encode_utf16().flat_map(u16::to_le_bytes)); + let mut font = le(&[2, -1000, 0, 0, 0, 400]); + font.extend([0u8; 8]); + font.extend("Arial".encode_utf16().chain(std::iter::repeat_n(0, 27)).flat_map(u16::to_le_bytes)); + let world = [2.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect(); + let records = vec![(35, world), (82, font), (37, le(&[2])), (84, text)]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let t = all(&back.document) + .into_iter() + .find_map(|n| match n.kind { + NodeKind::Text(t) => Some(t), + _ => None, + }) + .unwrap(); + let mm = 72.0 / 25.4; + assert!((t.runs[0].style.size - 10.0 * mm).abs() < 0.01, "{}", t.runs[0].style.size); + let [a, b, c, d, ..] = t.xf.as_coeffs(); + assert!((a - 2.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6, "{:?}", t.xf); +} + +#[test] +fn a_clip_whose_box_holds_the_picture_still_clips() { + // A diamond around the middle of the picture, whose box holds the whole frame but which cuts + // off its corners: BEGINPATH, POLYGON, ENDPATH, SELECTCLIPPATH (RGN_COPY), then a rectangle. + let mut diamond = le(&[0, 0, 0, 0, 4]); + diamond.extend(le(&[2500, -500, 5500, 1500, 2500, 3500, -500, 1500])); + // A bow tie through the four corners of a box around the picture. + let mut bow_tie = le(&[0, 0, 0, 0, 4]); + bow_tie.extend(le(&[-500, -500, 5500, 3500, 5500, -500, -500, 3500])); + for clip in [diamond, bow_tie] { + let records = vec![(59, vec![]), (3, clip), (60, vec![]), (67, le(&[5])), (43, le(&[0, 0, 5000, 3000]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let layer = back.document.layers[0].children().unwrap(); + assert_eq!(layer.len(), 1); + assert!(matches!(layer[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", layer[0].kind); + } +} + +#[test] +fn dual_emf_plus_files_draw_their_emf_plus_records() { + // A dual file (header flag 1): a translucent red EMF+ fill, and a plain rectangle that is only + // the fallback for readers of plain records. + let header = le(&[0xDBC0_1002u32 as i32, 0, 96, 96]); + let rects = [le(&[0x80FF_0000u32 as i32, 1]), le_f32(&[1000.0, 1000.0, 1000.0, 500.0])].concat(); + let comment = emf_plus(&[(0x4001, 1, header), (0x400A, 0x8000, rects), (0x4002, 0, vec![])]); + let back = import(&hand_emf(5000, 3000, &[(70, comment), (43, le(&[3000, 1000, 4000, 2000]))])).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert_eq!(fill_rgb(&p[0]), Some([255, 0, 0])); + let AppearanceItem::Fill(f) = &p[0].appearance.items[0] else { panic!("a fill") }; + assert!((f.opacity - 128.0 / 255.0).abs() < 1e-6, "{}", f.opacity); +} + +#[test] +fn pattern_brushes_fill_with_their_average_color() { + // CREATEDIBPATTERNBRUSHPT: a 2 × 2 tile of 24-bit pixels, two red and two blue (bottom-up rows + // padded to 4 bytes); selected, a null pen, a rectangle. + let mut brush = le(&[1, 0, 32, 40, 72, 16]); + brush.extend(le(&[40, 2, 2, 0x0018_0001, 0, 0, 0, 0, 0, 0])); + brush.extend([0, 0, 255, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0]); + let records = vec![(94, brush), (37, le(&[1])), (37, le(&[0x8000_0008u32 as i32])), (43, le(&[1000, 1000, 2000, 1500]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + assert_eq!(back.warnings, vec!["hatched and pattern brushes are filled with their average color".to_string()]); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + let [r, g, b] = fill_rgb(&p[0]).unwrap(); + assert!((126..=128).contains(&r) && g == 0 && (126..=128).contains(&b), "{:?}", [r, g, b]); +} + +#[test] +fn clipped_text_is_clipped_to_its_rectangle() { + // EXTTEXTOUTW with ETO_CLIPPED and a rectangle of 9–12 × 10–16 mm, in an underlined and struck + // through font. + let mut text = vec![0; 16]; + text.extend(le(&[1, 0, 0])); + text.extend(le(&[1000, 1500, 2, 76, 4, 900, 1000, 1200, 1600, 0])); + text.extend("Hi".encode_utf16().flat_map(u16::to_le_bytes)); + let mut font = le(&[2, -1000, 0, 0, 0, 400]); + font.extend([0, 1, 1, 0, 0, 0, 0, 0]); + font.extend("Arial".encode_utf16().chain(std::iter::repeat_n(0, 27)).flat_map(u16::to_le_bytes)); + let mut records = vec![(82, font), (37, le(&[2])), (84, text)]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let layer = back.document.layers[0].children().unwrap(); + assert_eq!(layer.len(), 1); + let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) }; + let mm = 72.0 / 25.4; + let own = Rect::new(9.0 * mm, 10.0 * mm, 12.0 * mm, 16.0 * mm); + assert!(near(children[0].geometric_bounds().unwrap(), own, 0.01)); + let NodeKind::Text(t) = &children[1].kind else { panic!("text") }; + assert!(t.runs[0].style.underline && t.runs[0].style.strikethrough); + // Under clip paths nested as deep as the stack, the text's own clip is still the innermost. + for i in 0..32 { + records.insert(0, (67, le(&[1]))); + records.insert(0, (60, vec![])); + records.insert(0, (43, le(&[100 + i, 100 + i, 4000 - i, 2500 - i]))); + records.insert(0, (59, vec![])); + } + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let mut n = back.document.layers[0].children().unwrap()[0].clone(); + while let NodeKind::Group { children, clip: true } = &n.kind + && matches!(children.last().map(|c| &c.kind), Some(NodeKind::Group { .. })) + { + n = children.last().unwrap().clone(); + } + let NodeKind::Group { children, clip: true } = &n.kind else { panic!("a clipping group: {:?}", n.kind) }; + assert!(near(children[0].geometric_bounds().unwrap(), own, 0.01), "{:?}", children[0].geometric_bounds()); + assert!(matches!(children[1].kind, NodeKind::Text(_))); +} + +#[test] +fn excluded_clip_rectangles_clip() { + // EXCLUDECLIPRECT cuts the top-left of the picture out of the clip; the rectangle crosses it. + // The clip it cuts from is the picture's bounds (5000 × 3000 device pixels). + let records = vec![(29, le(&[0, 0, 1500, 1500])), (43, le(&[1000, 1000, 2000, 1500]))]; + let mut bytes = hand_emf(5000, 3000, &records); + for (i, v) in [0i32, 0, 5000, 3000].iter().enumerate() { + bytes[8 + i * 4..12 + i * 4].copy_from_slice(&v.to_le_bytes()); + } + let back = import(&bytes).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + let layer = back.document.layers[0].children().unwrap(); + assert_eq!(layer.len(), 1); + assert!(matches!(layer[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", layer[0].kind); +} + +#[test] +fn shapes_under_the_same_clip_share_one_clipping_group() { + let records = vec![(30, le(&[0, 0, 1500, 1500])), (43, le(&[100, 100, 200, 200])), (43, le(&[300, 300, 400, 400]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let layer = back.document.layers[0].children().unwrap(); + assert_eq!(layer.len(), 1); + let NodeKind::Group { children, clip: true } = &layer[0].kind else { panic!("a clipping group: {:?}", layer[0].kind) }; + assert_eq!(children.len(), 3, "the clip and both rectangles"); +} + +#[test] +fn a_record_whose_size_is_not_a_multiple_of_4_ends_the_file() { + let records = vec![(43, le(&[100, 100, 200, 200])), (43, le(&[300, 300, 400, 400])), (43, le(&[500, 500, 600, 600]))]; + let mut bytes = hand_emf(5000, 3000, &records); + // The second rectangle's size, after the header and the first rectangle. + let header = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize; + let first = u32::from_le_bytes(bytes[header + 4..header + 8].try_into().unwrap()) as usize; + let at = header + first + 4; + let size = u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()); + bytes[at..at + 4].copy_from_slice(&(size + 2).to_le_bytes()); + let back = import(&bytes).unwrap(); + assert_eq!(paths(&back.document).len(), 1); + assert_eq!(back.warnings, vec!["the file ends early: what it held was imported".to_string()]); +} + +#[test] +fn wmf_with_an_inverted_placeable_box_is_read_at_its_units_an_inch() { + // The box runs from right to left, so it gives no size: the window (1440 units square, one inch + // at the header's 1440 units an inch) does. The rectangle is its top-left quarter. + let records = vec![(0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 1440])), (0x041B, le16(&[720, 720, 0, 0]))]; + let mut bytes = hand_wmf(1440, 1440, &records); + bytes[6..8].copy_from_slice(&1440i16.to_le_bytes()); + bytes[10..12].copy_from_slice(&0i16.to_le_bytes()); + let back = import(&bytes).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 72.0, 72.0), 1e-6), "{:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 36.0, 36.0), 1e-6), "{:?}", p[0].geometric_bounds()); +} + +#[test] +fn emf_without_a_frame_is_read_at_its_device_resolution() { + // An empty frame, and bounds of 5000 × 3000 device pixels at 100 pixels a millimetre. + let mut bytes = hand_emf(0, 0, &[(43, le(&[1000, 1000, 2000, 1500]))]); + for (i, v) in [0i32, 0, 5000, 3000].iter().enumerate() { + bytes[8 + i * 4..12 + i * 4].copy_from_slice(&v.to_le_bytes()); + } + let back = import(&bytes).unwrap(); + let mm = 72.0 / 25.4; + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 50.0 * mm, 30.0 * mm), 0.01), "{:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(10.0 * mm, 10.0 * mm, 20.0 * mm, 15.0 * mm), 0.01), "{:?}", p[0].geometric_bounds()); + // A device 50 mm high: 200 pixels a millimetre down, so everything is half as tall. + bytes[84..88].copy_from_slice(&50i32.to_le_bytes()); + let back = import(&bytes).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 50.0 * mm, 15.0 * mm), 0.01), "{:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(10.0 * mm, 5.0 * mm, 20.0 * mm, 7.5 * mm), 0.01), "{:?}", p[0].geometric_bounds()); } diff --git a/docs/mcp.md b/docs/mcp.md index eb60457600..17028d3952 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7700,10 +7700,11 @@ and is missing from the picture" once for all such images. `document.open` and `file.place` read `.emf` and `.wmf` (placeable or not) into one artboard, the picture's frame, and one layer: paths with their fills and strokes (pens become strokes with caps, joins and dashes), clipping groups, -images and point type in the font the file names. Records VectorCraft doesn't read (EMF+ drawing among them) are -skipped with one warning. `clipboard.importEmf {dataBase64, center?}` loads an EMF or WMF picture into the clipboard -for `edit.paste`; the app pastes `image/emf` from the system clipboard ahead of text and bitmaps when the platform's -clipboard service offers it (the Windows desktop service doesn't read metafiles yet). +images and point type in the font the file names. An EMF that draws with EMF+ records is read from them. Hatched and +pattern brushes fill with their average color. Records VectorCraft doesn't read are skipped with one warning. +`clipboard.importEmf {dataBase64, center?}` loads an EMF or WMF picture into the clipboard for `edit.paste`; the app +pastes `image/emf` from the system clipboard ahead of text and bitmaps when the platform's clipboard service offers it +(the Windows desktop service doesn't read metafiles yet). ```json {"name":"export","arguments":{"path":"/tmp/logo.emf"}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ad7e9f4eab..7359d135d8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1749,6 +1749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers both ways of bringing HEIF files in): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - HP-GL/2 plot files (`.plt`, `.hpgl`, `.hgl`, `.hpg`, or a name no other format reads when the file starts with plotter instructions) open and place as paths on one layer, the artboard round the art: each line drawn with the pen down is a stroked path in its pen's color and width, line types become dash patterns, circles, arcs, wedges and Bézier curves are curves, filled and edged rectangles, wedges and polygons (polygon mode) are filled or stroked paths, and encoded polylines, scaling points, user units and rotation are read. Labels, symbol mode's symbols, raster images and hatching aren't drawn and are listed in the warnings (`crates/cad/src/hpgl/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - PICT pictures (`.pct`, `.pic`, and `.pict` as VectorCraft's own name) open and place, as [the help page](https://helpx.adobe.com/archive/illustrator/illustrator-cs5-5-troubleshooting.pdf) lists Macintosh PICT under both opening and placing: versions 1 and 2, with or without the file's 512 bytes of application data, read by the opcodes of Apple's [Opcodes in Pictures](https://dev.os9.ca/techpubs/mac/QuickDraw/QuickDraw-461.html). Lines, rectangles, rounded rectangles, ovals, arcs, polygons and regions become paths in their pen and pattern colors, text becomes point type in the font the picture names, bitmaps, pixel maps and QuickTime JPEG images become images, and the clip region clips what follows it; File › Place places the picture as one group clipped to its frame. Inverted shapes, other transfer modes and other QuickTime formats come back as warnings, and a picture cut short keeps what came before it (`crates/metafile/src/tests/pict.rs`, `crates/engine/src/cmd/fileio/tests_pict.rs`). + - EMF and WMF files are read by the shared `craft-metafile` reader and open, place and paste as editable paths, clipping groups, images and point type: an EMF drawn with EMF+ records (on their own, or beside plain records) is read from them instead of opening empty, and hatched and pattern brushes fill with their average color (`crates/metafile/src/tests/import.rs`). - Export As › HP-GL (`document.export {format: "hpgl"}`) writes a `.plt` plot of an artboard, each artboard or the art for pen plotters and cutters: each visible path once along its line in plotter units from the artboard's bottom-left corner or center, a pen per color or a pen table, curves flattened to a tolerance, type as outlines, clipping masks and the artboard's edges applied, and HP-GL or HP-GL/2 (pen colors and encoded polylines); plots read back by the HP-GL reader give the same lines (`crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - HP-GL export's `insideFirst` cuts each pen's paths inside before outside: a path is drawn only after the pen's paths that lie inside it (a letter's counter, a part's holes, rings within rings), touching outlines counted as inside and crossing ones not, in paint order or nearest first with `sortPaths`, within the plot's work budget; these order rules are VectorCraft's own (`crates/cad/src/hpgl/write/nest.rs`, `crates/cad/src/hpgl/write/tests.rs`, `crates/engine/src/cmd/fileio/tests_hpgl.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). From f8e70c141599449d6cbab41ea41bbb3bffc85e02 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:35:25 -0800 Subject: [PATCH 2/7] Metafile import: translucent strokes stay apart, and each pattern tile is averaged once Strokes drawn one after another with the same pen join into one path only when the pen is opaque. A translucent stroke keeps its own opacity, so where two of them overlap both show. Items filled with the same pattern brush share its tile, and the tile's average color is now worked out once per tile instead of once per item. --- crates/metafile/src/import/picture.rs | 18 ++++++++++++++---- crates/metafile/src/tests/import.rs | 23 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs index d269fd27ca..8373253ae4 100644 --- a/crates/metafile/src/import/picture.rs +++ b/crates/metafile/src/import/picture.rs @@ -2,8 +2,8 @@ //! picture, and the picture's items become document objects here. //! //! - A filled or stroked path becomes a path with a fill and a stroke (caps, joins, miter limit -//! and dashes from the pen). Strokes drawn one after another, each starting where the last one -//! ended, with the same pen and clip, join into one path. +//! and dashes from the pen). Opaque strokes drawn one after another, each starting where the +//! last one ended, with the same pen and clip, join into one path. //! - A path filled with a pattern or hatched brush is filled with the average color of the //! brush's tile. //! - A bitmap becomes an image (its pixels as PNG) placed where the picture draws it, turned or @@ -18,7 +18,7 @@ //! at its device's pixels a millimetre (96 pixels an inch when the header doesn't say). use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use craft_metafile::{Cap, ClipPath, HAlign, Item, Join, LineStyle, Seg, VAlign}; use vectorcraft_color::Paint; @@ -106,6 +106,10 @@ struct Pending { type MadeClips = HashMap<*const ClipPath, (Arc, Option>)>; /// The last clip region and the clip made for it. type MadeRegion = (Vec<[f32; 4]>, Option>); +/// Brush tiles already averaged, by address: items filled with the same brush share its tile. The +/// `Weak` keeps the address from being reused, and the tile's pixels are freed once the last item +/// that uses it has been read. +type Averaged = HashMap<*const craft_metafile::Tile, (Weak, Option<[u8; 4]>)>; struct Mapper { /// Picture units → points. @@ -120,6 +124,7 @@ struct Mapper { paths: MadeClips, region: Option, pending: Option, + averaged: Averaged, } fn color(c: [u8; 4]) -> Paint { @@ -161,6 +166,7 @@ impl Mapper { paths: HashMap::new(), region: None, pending: None, + averaged: HashMap::new(), } } @@ -317,8 +323,10 @@ impl Mapper { PathEl::MoveTo(p) => Some(*p), _ => None, }); + // A translucent stroke stays apart, so where strokes overlap each one shows. if let Some(p) = &mut self.pending && p.pen == pen + && pen.color[3] == u8::MAX && start.is_some() && p.path.elements().last().and_then(|e| e.end_point()) == start { @@ -338,7 +346,9 @@ impl Mapper { Item::Pattern { segs, even_odd, tile, region, clips } => { self.flush(); let Some(chain) = self.chain(region, &clips) else { return }; - let Some(c) = average(&tile.rgba) else { return }; + let Some(c) = self.averaged.entry(Arc::as_ptr(&tile)).or_insert_with(|| (Arc::downgrade(&tile), average(&tile.rgba))).1 else { + return; + }; self.warn(PATTERNS); let bp = self.path(&segs); if let Some(n) = self.path_node(bp, Some(c), None, even_odd) { diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index 689037d277..69abf02e86 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -548,6 +548,29 @@ fn dual_emf_plus_files_draw_their_emf_plus_records() { assert!((f.opacity - 128.0 / 255.0).abs() < 1e-6, "{}", f.opacity); } +#[test] +fn translucent_strokes_drawn_one_after_another_stay_apart() { + // EmfPlusObject: pen 0, 1 pixel wide, with a solid brush of `argb`; then two EmfPlusDrawLines + // with it, from A to B and from B back to A. + let header = le(&[0xDBC0_1002u32 as i32, 0, 96, 96]); + let lines = |argb: u32| { + let pen = [le(&[0xDBC0_1002u32 as i32, 0, 0, 2]), le_f32(&[1.0]), le(&[0xDBC0_1002u32 as i32, 0, argb as i32])].concat(); + let a_to_b = [le(&[2]), le_f32(&[1000.0, 1000.0, 3000.0, 1000.0])].concat(); + let b_to_a = [le(&[2]), le_f32(&[3000.0, 1000.0, 1000.0, 1000.0])].concat(); + let comment = emf_plus(&[(0x4001, 0, header.clone()), (0x4008, 0x0200, pen), (0x400D, 0, a_to_b), (0x400D, 0, b_to_a), (0x4002, 0, vec![])]); + paths(&import(&hand_emf(5000, 3000, &[(70, comment)])).unwrap().document) + }; + // Each translucent stroke keeps its own opacity, so where they overlap both show. + let p = lines(0x80FF_0000); + assert_eq!(p.len(), 2, "{p:?}"); + for n in &p { + let AppearanceItem::Stroke(st) = &n.appearance.items[0] else { panic!("a stroke") }; + assert!((st.opacity - 128.0 / 255.0).abs() < 1e-6, "{}", st.opacity); + } + // Opaque strokes still join into one path. + assert_eq!(lines(0xFFFF_0000).len(), 1); +} + #[test] fn pattern_brushes_fill_with_their_average_color() { // CREATEDIBPATTERNBRUSHPT: a 2 × 2 tile of 24-bit pixels, two red and two blue (bottom-up rows From d5e58b0e4bb6962218f3933ed168b95f5f605efa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:46:17 -0800 Subject: [PATCH 3/7] Metafile import: a clip of several figures along a box's sides still clips A clip path is left out only when it is one rectangle around the whole picture. Two open figures along the top and bottom of a box enclose nothing, and the objects under such a clip are no longer imported unclipped. --- crates/metafile/src/import.rs | 9 ++++----- crates/metafile/src/tests/import.rs | 7 +++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index 43023e0183..dce77b2d7a 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -44,8 +44,9 @@ fn sane(r: Rect) -> bool { [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) } -/// Is `path` an upright rectangle around the whole of `frame`? Writers clip to one first; it clips -/// nothing in the picture. +/// Is `path` one upright rectangle around the whole of `frame`? Writers clip to one first; it clips +/// nothing in the picture. A path of more than one figure is not one rectangle, even when its +/// figures run along the sides of a box. fn around(path: &BezPath, frame: Rect) -> bool { if path.elements().len() > 6 { return false; @@ -67,9 +68,7 @@ fn around(path: &BezPath, frame: Rect) -> bool { }; let Some(i) = corners.iter().position(|c| near(*c, p)) else { return false }; if start { - if let (Some(f), Some(l)) = (first, last) - && !along(f, l) - { + if first.is_some() { return false; } first = Some(i); diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index 69abf02e86..4dcb3020c4 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -524,8 +524,11 @@ fn a_clip_whose_box_holds_the_picture_still_clips() { // A bow tie through the four corners of a box around the picture. let mut bow_tie = le(&[0, 0, 0, 0, 4]); bow_tie.extend(le(&[-500, -500, 5500, 3500, 5500, -500, -500, 3500])); - for clip in [diamond, bow_tie] { - let records = vec![(59, vec![]), (3, clip), (60, vec![]), (67, le(&[5])), (43, le(&[0, 0, 5000, 3000]))]; + // Two open figures through the four corners, the top edge and the bottom edge, which enclose + // nothing: MOVETOEX, LINETO, MOVETOEX, LINETO. + let edges = vec![(27, le(&[-500, -500])), (54, le(&[5500, -500])), (27, le(&[5500, 3500])), (54, le(&[-500, 3500]))]; + for clip in [vec![(3, diamond)], vec![(3, bow_tie)], edges] { + let records = [vec![(59, vec![])], clip, vec![(60, vec![]), (67, le(&[5])), (43, le(&[0, 0, 5000, 3000]))]].concat(); let back = import(&hand_emf(5000, 3000, &records)).unwrap(); let layer = back.document.layers[0].children().unwrap(); assert_eq!(layer.len(), 1); From 9e85368a444309e2385f639b6c413bbbd62d673c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:55:40 -0800 Subject: [PATCH 4/7] Metafile import: a file that reaches the most objects one file may hold says so The reader keeps at most 200,000 objects from one file and stops reading there. A picture that reaches that count now carries a warning that any objects after them were not read, and objects dropped past the import's own object limit carry a warning too. --- crates/metafile/src/import/picture.rs | 12 +++++++++++- crates/metafile/src/tests/import.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs index 8373253ae4..4689572a62 100644 --- a/crates/metafile/src/import/picture.rs +++ b/crates/metafile/src/import/picture.rs @@ -36,6 +36,13 @@ use crate::{Imported, Kind}; const PATTERNS: &str = "hatched and pattern brushes are filled with their average color"; const ENDS_EARLY: &str = "the file ends early: what it held was imported"; +const TOO_MANY: &str = "objects past the most one file may hold were left out"; +const AT_LIMIT: &str = "the file reached the most objects one file may hold: any after them were not read"; + +/// The most items `craft_metafile` keeps from one file (its `MAX_ITEMS`). It stops reading at the +/// limit and does not say whether more items followed, so a picture holding this many may have +/// been cut short. +const READER_ITEMS: usize = 200_000; /// Points a unit of a WMF without a placeable header (1440 units an inch). const WMF_UNIT: f64 = 72.0 / 1440.0; @@ -243,7 +250,7 @@ impl Mapper { fn push(&mut self, clips: Vec>, mut node: Node) { if self.out.len() >= MAX_NODES { - return; + return self.warn(TOO_MANY); } if !node.visual_bounds().is_none_or(sane) { return self.warn(FAR_AWAY); @@ -526,6 +533,9 @@ pub(crate) fn import(bytes: &[u8]) -> Result { if ends_early(bytes, kind) { m.warn(ENDS_EARLY); } + if pic.items.len() >= READER_ITEMS { + m.warn(AT_LIMIT); + } for item in pic.items { m.item(item); } diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index 4dcb3020c4..a1ddcc335e 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -370,6 +370,18 @@ fn damaged_files_fail_or_import_partly() { assert!(import(&huge).is_ok()); } +#[test] +fn a_file_with_more_objects_than_are_read_says_so() { + // RECTANGLE records: 10 more than the reader keeps, then a few under that. + let rect = (43, le(&[0, 0, 100, 100])); + let many = hand_emf(5000, 3000, &vec![rect.clone(); 200_010]); + let back = import(&many).unwrap(); + assert_eq!(paths(&back.document).len(), 200_000); + assert!(back.warnings.iter().any(|w| w.contains("reached the most objects")), "{:?}", back.warnings); + let few = import(&hand_emf(5000, 3000, &vec![rect; 3])).unwrap(); + assert!(few.warnings.is_empty(), "{:?}", few.warnings); +} + /// An EMR_STRETCHDIBITS copying `png` (a BI_PNG bitmap) to a 10 × 10 box. fn png_stretch_dibits(png: &[u8]) -> Vec { // Bounds; destination, source and its size; the BITMAPINFO and the bits (from the record start); From 6be8f3095b807366e104a4e24c2927c63465c619 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:14:18 -0800 Subject: [PATCH 5/7] Metafile import: a clip that runs back over its own edges still clips A clip path is left out only when it is one rectangle around the whole picture. One figure through a box's four corners that runs back over its edges encloses less than the box, and the objects under such a clip are no longer imported unclipped. --- crates/metafile/src/import.rs | 11 ++++++++++- crates/metafile/src/tests/import.rs | 6 +++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index dce77b2d7a..c87dd83d72 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -46,7 +46,8 @@ fn sane(r: Rect) -> bool { /// Is `path` one upright rectangle around the whole of `frame`? Writers clip to one first; it clips /// nothing in the picture. A path of more than one figure is not one rectangle, even when its -/// figures run along the sides of a box. +/// figures run along the sides of a box. Nor is one figure that runs back over its own edges, so +/// that it encloses less than the whole box. fn around(path: &BezPath, frame: Rect) -> bool { if path.elements().len() > 6 { return false; @@ -58,6 +59,7 @@ fn around(path: &BezPath, frame: Rect) -> bool { // neighbor (corners are in order around the box), never across it. let along = |i: usize, j: usize| (i + 4 - j) % 4 != 2; let mut seen = [false; 4]; + let mut points = Vec::with_capacity(6); let (mut first, mut last) = (None, None); for e in path.elements() { let (p, start) = match e { @@ -77,12 +79,19 @@ fn around(path: &BezPath, frame: Rect) -> bool { } last = Some(i); seen[i] = true; + points.push(corners[i]); } if let (Some(f), Some(l)) = (first, last) && !along(f, l) { return false; } + // Every edge runs along a side, so the figure winds the same number of times around every + // point inside the box: once, when its area is the box's. + let twice_area: f64 = points.iter().zip(points.iter().cycle().skip(1)).map(|(p, q)| (*p - corners[0]).cross(*q - corners[0])).sum(); + if (twice_area.abs() - 2.0 * b.area()).abs() > 1e-6 * (1.0 + 2.0 * b.area()) { + return false; + } let b = b.inflate(0.5, 0.5); seen.iter().all(|s| *s) && b.contains(frame.origin()) && b.contains(Point::new(frame.x1, frame.y1)) } diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index a1ddcc335e..55714c1f71 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -539,7 +539,11 @@ fn a_clip_whose_box_holds_the_picture_still_clips() { // Two open figures through the four corners, the top edge and the bottom edge, which enclose // nothing: MOVETOEX, LINETO, MOVETOEX, LINETO. let edges = vec![(27, le(&[-500, -500])), (54, le(&[5500, -500])), (27, le(&[5500, 3500])), (54, le(&[-500, 3500]))]; - for clip in [vec![(3, diamond)], vec![(3, bow_tie)], edges] { + // One figure through the four corners that runs back over its edges and encloses nothing: + // POLYLINE top-left, top-right, top-left, bottom-left, bottom-right, bottom-left. + let mut retraced = le(&[0, 0, 0, 0, 6]); + retraced.extend(le(&[-500, -500, 5500, -500, -500, -500, -500, 3500, 5500, 3500, -500, 3500])); + for clip in [vec![(3, diamond)], vec![(3, bow_tie)], edges, vec![(4, retraced)]] { let records = [vec![(59, vec![])], clip, vec![(60, vec![]), (67, le(&[5])), (43, le(&[0, 0, 5000, 3000]))]].concat(); let back = import(&hand_emf(5000, 3000, &records)).unwrap(); let layer = back.document.layers[0].children().unwrap(); From 9d8407b3deb2cca3235886615b43a0cdb38782fb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 09:45:04 -0800 Subject: [PATCH 6/7] Metafile import: metric WMFs are read at 96 pixels an inch, and only round solid strokes join A WMF without a usable placeable box that draws in a metric map mode (MM_LOMETRIC to MM_TWIPS) is read at 96 pixels an inch, the scale craft_metafile plays those modes at, so a one-inch MM_TWIPS picture opens at 72 pt instead of 4.8 pt. The mode that more of the drawing records the reader plays are drawn in decides, with SAVEDC and RESTOREDC followed as the reader follows them; a file that draws nothing goes by its last mode. Opaque strokes drawn one after another now join into one path only when the pen is solid with round caps and round joins. A dashed stroke keeps its own dash phase, and flat or square caps and miter or bevel joins stay as the separate strokes drew them. --- crates/metafile/src/import/picture.rs | 102 +++++++++++++++++++++++--- crates/metafile/src/tests/import.rs | 87 ++++++++++++++++++++++ 2 files changed, 178 insertions(+), 11 deletions(-) diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs index 4689572a62..5385d94153 100644 --- a/crates/metafile/src/import/picture.rs +++ b/crates/metafile/src/import/picture.rs @@ -2,8 +2,9 @@ //! picture, and the picture's items become document objects here. //! //! - A filled or stroked path becomes a path with a fill and a stroke (caps, joins, miter limit -//! and dashes from the pen). Opaque strokes drawn one after another, each starting where the -//! last one ended, with the same pen and clip, join into one path. +//! and dashes from the pen). Opaque solid strokes with round caps and round joins drawn one after +//! another, each starting where the last one ended, with the same pen and clip, join into one +//! path. Other strokes stay apart, each with its own caps and its own dashes. //! - A path filled with a pattern or hatched brush is filled with the average color of the //! brush's tile. //! - A bitmap becomes an image (its pixels as PNG) placed where the picture draws it, turned or @@ -14,8 +15,10 @@ //! the whole picture clips nothing and is left out. //! //! A WMF without a placeable header is read at 1440 units an inch, and one whose placeable box is -//! empty or inverted at the units an inch the header gives. An EMF without a usable frame is read -//! at its device's pixels a millimetre (96 pixels an inch when the header doesn't say). +//! empty or inverted at the units an inch the header gives. Either one that draws in a metric map +//! mode (MM_LOMETRIC to MM_TWIPS) is read at 96 pixels an inch, as `craft_metafile` plays those +//! modes. An EMF without a usable frame is read at its device's pixels a millimetre (96 +//! pixels an inch when the header doesn't say). use std::collections::HashMap; use std::sync::{Arc, Weak}; @@ -46,11 +49,79 @@ const READER_ITEMS: usize = 200_000; /// Points a unit of a WMF without a placeable header (1440 units an inch). const WMF_UNIT: f64 = 72.0 / 1440.0; +/// The SAVEDC levels `craft_metafile` keeps; it stops reading at a SAVEDC past them. +const SAVED_LEVELS: usize = 1024; +/// Points a pixel at 96 pixels an inch. +const PT_PER_PX: f64 = 72.0 / 96.0; /// Points a millimetre. const PT_PER_MM: f64 = 72.0 / 25.4; +/// WMF records that draw and that `craft_metafile` plays: lines, shapes, text, PATBLT and bitmaps. +const WMF_DRAWS: [u16; 16] = + [0x0213, 0x0324, 0x0325, 0x0418, 0x041B, 0x0521, 0x0538, 0x061C, 0x061D, 0x0817, 0x081A, 0x0830, 0x0940, 0x0A32, 0x0B41, 0x0F43]; + +/// Does a WMF draw in a metric map mode (MM_LOMETRIC to MM_TWIPS)? `craft_metafile` plays those +/// at 96 pixels an inch, whatever the window. The mode that more drawing records are played in +/// decides; a file that draws nothing goes by its last mode. `start` is where the standard header +/// begins. +fn wmf_metric(bytes: &[u8], start: usize) -> bool { + let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let Some(words) = start.checked_add(2).and_then(u16_at) else { return false }; + let mut at = start.saturating_add(usize::from(words) * 2); + let mut mode = 1; + let metric = |mode: u16| (2..=6).contains(&mode); + // Drawing records played in a metric mode, and in any other. + let (mut in_metric, mut in_other) = (0usize, 0usize); + // The modes SAVEDC kept, which RESTOREDC brings back as the reader does. + let mut saved: Vec = Vec::new(); + // The records as the reader walks them: it stops at the end record, a record shorter than its + // header, one that runs past the file and a SAVEDC past its 1,024 levels. + while let (Some(words), Some(function)) = (u32_at(at), u16_at(at.saturating_add(4))) { + let size = (words as usize).saturating_mul(2); + let Some(next) = at.checked_add(size) else { break }; + if function == 0 || size < 6 || next > bytes.len() { + break; + } + match function { + // SETMAPMODE, when the record holds its mode. + 0x0103 if size >= 8 => mode = u16_at(at + 6).unwrap_or(mode), + // SAVEDC. + 0x001E => { + if saved.len() >= SAVED_LEVELS { + break; + } + saved.push(mode); + } + // RESTOREDC: a negative count pops that many levels, a positive one goes back to that + // level. One that names no saved level changes nothing. + 0x0127 if size >= 8 => { + let n = u16_at(at + 6).map_or(0, |v| i32::from(i16::from_le_bytes(v.to_le_bytes()))); + let level = if n < 0 { saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) }; + if let Some(level) = level + && let Some(m) = saved.get(level).copied() + { + mode = m; + saved.truncate(level); + } + } + f if WMF_DRAWS.contains(&f) => { + if metric(mode) { + in_metric += 1; + } else { + in_other += 1; + } + } + _ => {} + } + at = next; + } + if in_metric == in_other { metric(mode) } else { in_metric > in_other } +} + /// Points a picture unit across and down. `craft_metafile` gives a placeable WMF with a box and an -/// EMF with a frame in points, and other files in their logical (WMF) or device (EMF) units. +/// EMF with a frame in points, and other files in their logical units (WMF; pixels in a metric map +/// mode) or device units (EMF). fn unit(bytes: &[u8], kind: Kind) -> (f64, f64) { let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); let i16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| i16::from_le_bytes([b[0], b[1]])); @@ -59,15 +130,20 @@ fn unit(bytes: &[u8], kind: Kind) -> (f64, f64) { let positive = |v: Option| v.filter(|v| *v > 0).map(f64::from); match kind { Kind::Wmf => { - if bytes.get(..4) != Some(&PLACEABLE_KEY.to_le_bytes()[..]) { - return (WMF_UNIT, WMF_UNIT); - } - if let (Some(l), Some(t), Some(r), Some(b)) = (i16_at(6), i16_at(8), i16_at(10), i16_at(12)) + let placeable = bytes.get(..4) == Some(&PLACEABLE_KEY.to_le_bytes()[..]); + if placeable + && let (Some(l), Some(t), Some(r), Some(b)) = (i16_at(6), i16_at(8), i16_at(10), i16_at(12)) && r > l && b > t { return (1.0, 1.0); } + if wmf_metric(bytes, if placeable { 22 } else { 0 }) { + return (PT_PER_PX, PT_PER_PX); + } + if !placeable { + return (WMF_UNIT, WMF_UNIT); + } let k = 72.0 / u16_at(14).filter(|v| *v > 0).map_or(1440.0, f64::from); (k, k) } @@ -85,7 +161,7 @@ fn unit(bytes: &[u8], kind: Kind) -> (f64, f64) { match px_mm { Some(_) if frame => (1.0, 1.0), Some((x, y)) if ok(x) && ok(y) => (PT_PER_MM / x, PT_PER_MM / y), - _ => (0.75, 0.75), + _ => (PT_PER_PX, PT_PER_PX), } } } @@ -330,10 +406,14 @@ impl Mapper { PathEl::MoveTo(p) => Some(*p), _ => None, }); - // A translucent stroke stays apart, so where strokes overlap each one shows. + // A translucent stroke stays apart, so where strokes overlap each one shows. So + // does a dashed one, whose dashes start at its own start, and one whose caps + // or joins aren't round: a join where two strokes meet is not their two caps. if let Some(p) = &mut self.pending && p.pen == pen && pen.color[3] == u8::MAX + && pen.line.dash.is_empty() + && matches!((&pen.line.cap, &pen.line.join), (Cap::Round, Join::Round)) && start.is_some() && p.path.elements().last().and_then(|e| e.end_point()) == start { diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index 55714c1f71..a193997c4b 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -349,6 +349,66 @@ fn wmf_without_a_placeable_header_is_read_at_1440_units_an_inch() { assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 72.0), 1e-6), "{:?}", p[0].geometric_bounds()); } +#[test] +fn wmf_without_a_placeable_box_in_a_metric_map_mode_is_read_at_96_pixels_an_inch() { + // MM_TWIPS (y up) and a window of 1440 twips square: one inch. The rectangle is the whole window. + let records = vec![(0x0103, le16(&[6])), (0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 1440])), (0x041B, le16(&[1440, 1440, 0, 0]))]; + let boxed = hand_wmf(1440, 1440, &records); + let plain = boxed[22..].to_vec(); + // The box runs from right to left, so it gives no size. + let mut inverted = boxed; + inverted[6..8].copy_from_slice(&1440i16.to_le_bytes()); + inverted[10..12].copy_from_slice(&0i16.to_le_bytes()); + for bytes in [plain, inverted] { + let back = import(&bytes).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 72.0, 72.0), 1e-4), "{:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 72.0), 1e-4), "{:?}", p[0].geometric_bounds()); + } +} + +#[test] +fn wmf_whose_metric_map_mode_is_restored_away_is_read_at_1440_units_an_inch() { + // SAVEDC, MM_TWIPS, then RESTOREDC back to MM_TEXT, by a count of levels and by a level; the + // drawing is then the one in `wmf_without_a_placeable_header_is_read_at_1440_units_an_inch`. + for restore in [-1i16, 1] { + let records = vec![ + (0x001E, Vec::new()), + (0x0103, le16(&[6])), + (0x0127, le16(&[restore])), + (0x020B, le16(&[0, 0])), + (0x020C, le16(&[1440, 2880])), + (0x041B, le16(&[1440, 1440, 0, 0])), + ]; + let plain = hand_wmf(2880, 1440, &records)[22..].to_vec(); + let back = import(&plain).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 144.0, 72.0), 1e-6), "{restore}: {:?}", back.document.artboards[0].rect); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 72.0), 1e-6), "{restore}: {:?}", p[0].geometric_bounds()); + } +} + +#[test] +fn wmf_that_draws_in_a_metric_map_mode_and_restores_is_read_at_96_pixels_an_inch() { + // SAVEDC, MM_TWIPS, a one-inch rectangle, then RESTOREDC back to MM_TEXT with nothing more drawn. + let records = vec![ + (0x001E, Vec::new()), + (0x0103, le16(&[6])), + (0x020B, le16(&[0, 0])), + (0x020C, le16(&[1440, 1440])), + (0x041B, le16(&[1440, 1440, 0, 0])), + (0x0127, le16(&[-1])), + ]; + let plain = hand_wmf(1440, 1440, &records)[22..].to_vec(); + let back = import(&plain).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1); + let b = p[0].geometric_bounds().unwrap(); + assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "{b:?}"); +} + #[test] fn damaged_files_fail_or_import_partly() { let good = emf(&sample()).bytes; @@ -590,6 +650,33 @@ fn translucent_strokes_drawn_one_after_another_stay_apart() { assert_eq!(lines(0xFFFF_0000).len(), 1); } +#[test] +fn strokes_join_only_with_round_caps_round_joins_and_no_dashes() { + // CREATEPENINDIRECT: a 20-unit black pen of `style`, selected; then two lines one after the + // other that meet at a right angle. + let lines = |style: i16| { + let records = vec![ + (0x02FA, [le16(&[style, 20, 0]), 0u32.to_le_bytes().to_vec()].concat()), + (0x012D, le16(&[0])), + (0x0214, le16(&[100, 100])), + (0x0213, le16(&[100, 700])), + (0x0213, le16(&[700, 700])), + ]; + paths(&import(&hand_wmf(1440, 1440, &records)).unwrap().document) + }; + // Round caps and round joins, a solid line: one path, as a round join is the two round caps. + assert_eq!(lines(0).len(), 1); + // Flat caps and miter joins, flat caps alone and miter joins alone: each line keeps its caps. + for style in [0x2200, 0x0200, 0x2000] { + let p = lines(style); + assert_eq!(p.len(), 2, "style {style:#x}: {p:?}"); + } + // PS_DASH: each line's dashes start at its own start. + let p = lines(1); + assert_eq!(p.len(), 2, "{p:?}"); + assert!(p.iter().all(|n| n.appearance.stroke().is_some_and(|st| st.dash.is_some())), "{p:?}"); +} + #[test] fn pattern_brushes_fill_with_their_average_color() { // CREATEDIBPATTERNBRUSHPT: a 2 × 2 tile of 24-bit pixels, two red and two blue (bottom-up rows From c61d27b825f0eb38ec7a9f74a674551f1167ddf1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:21:29 -0800 Subject: [PATCH 7/7] Metafile import: hidden WMF records leave the scale alone, and a far stroke keeps the strokes before it A WMF without a placeable box picks its scale from the map mode its drawing records are played in. Lines and shapes drawn under R2_NOP or R2_XORPEN, and PATBLT records the reader does not fill, no longer count, with the mix mode saved and restored with SAVEDC and RESTOREDC. An open stroke that reaches far outside the picture is left out before it joins the strokes waiting to be joined, so the strokes drawn before it are still added. --- crates/engine/tests/import_fuzz.rs | 10 ++++- crates/metafile/src/import/picture.rs | 61 +++++++++++++++++++++------ crates/metafile/src/tests/import.rs | 51 ++++++++++++++++++++++ 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index e22f8ac5cd..a768785abf 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2904,9 +2904,9 @@ fn arb_body() -> impl Strategy> { } /// WMF record functions VectorCraft reads, and one it doesn't. -const WMF_FUNCTIONS: [u16; 33] = [ +const WMF_FUNCTIONS: [u16; 35] = [ 0x0324, 0x0325, 0x0538, 0x041B, 0x0418, 0x061C, 0x0817, 0x081A, 0x0830, 0x0416, 0x0415, 0x02FA, 0x02FC, 0x02FB, 0x012D, 0x01F0, 0x0521, 0x0A32, - 0x0F43, 0x0B41, 0x0940, 0x061D, 0x0213, 0x0214, 0x001E, 0x0127, 0x0106, 0x0209, 0x012E, 0x00F7, 0x01F9, 0x0142, 0x0999, + 0x0F43, 0x0B41, 0x0940, 0x061D, 0x0213, 0x0214, 0x001E, 0x0127, 0x0106, 0x0209, 0x012E, 0x00F7, 0x01F9, 0x0142, 0x0103, 0x0104, 0x0999, ]; proptest! { @@ -2933,6 +2933,12 @@ proptest! { survive_metafile("WMF records", "x.wmf", &wmf_of(&records))?; } + #[test] + fn random_headerless_wmf_records_never_panic(records in prop::collection::vec((prop::sample::select(WMF_FUNCTIONS.to_vec()), arb_body()), 0..32)) { + // Without the placeable header, the map and mix modes the records set decide the scale. + survive_metafile("headerless WMF records", "x.wmf", &wmf_of(&records)[22..])?; + } + #[test] fn random_emf_plus_records_never_panic(records in prop::collection::vec((0x4001u16..0x403B, any::(), arb_body()), 0..32)) { // One comment of EMF+ records (type, flags, data) after an EmfPlusHeader. diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs index 5385d94153..03a2e5628e 100644 --- a/crates/metafile/src/import/picture.rs +++ b/crates/metafile/src/import/picture.rs @@ -56,25 +56,39 @@ const PT_PER_PX: f64 = 72.0 / 96.0; /// Points a millimetre. const PT_PER_MM: f64 = 72.0 / 25.4; -/// WMF records that draw and that `craft_metafile` plays: lines, shapes, text, PATBLT and bitmaps. -const WMF_DRAWS: [u16; 16] = - [0x0213, 0x0324, 0x0325, 0x0418, 0x041B, 0x0521, 0x0538, 0x061C, 0x061D, 0x0817, 0x081A, 0x0830, 0x0940, 0x0A32, 0x0B41, 0x0F43]; +/// WMF records that draw lines and shapes, which `craft_metafile` plays only when the mix mode +/// lets them draw. +const WMF_SHAPES: [u16; 10] = [0x0213, 0x0324, 0x0325, 0x0418, 0x041B, 0x0538, 0x061C, 0x0817, 0x081A, 0x0830]; +/// WMF records that draw text and bitmaps, which `craft_metafile` plays whatever the mix mode. +const WMF_DRAWS: [u16; 5] = [0x0521, 0x0940, 0x0A32, 0x0B41, 0x0F43]; +/// SETROP2 mix modes under which `craft_metafile` draws no lines, shapes or brush fills: R2_XORPEN +/// and R2_NOP. +const WMF_MIX_SKIPS: [u16; 2] = [7, 11]; +/// The mix mode a WMF starts in (R2_COPYPEN). +const WMF_MIX_START: u16 = 13; +/// PATBLT raster operations `craft_metafile` draws: PATCOPY (with the brush, as the mix mode +/// allows), BLACKNESS and WHITENESS. +const PATCOPY: u32 = 0x00F0_0021; +const BLACKNESS: u32 = 0x0000_0042; +const WHITENESS: u32 = 0x00FF_0062; /// Does a WMF draw in a metric map mode (MM_LOMETRIC to MM_TWIPS)? `craft_metafile` plays those /// at 96 pixels an inch, whatever the window. The mode that more drawing records are played in -/// decides; a file that draws nothing goes by its last mode. `start` is where the standard header -/// begins. +/// decides, counting only records the reader draws (not lines and shapes under R2_NOP or +/// R2_XORPEN, nor a PATBLT it does not fill); a file that draws nothing goes by its last mode. +/// `start` is where the standard header begins. fn wmf_metric(bytes: &[u8], start: usize) -> bool { let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); let Some(words) = start.checked_add(2).and_then(u16_at) else { return false }; let mut at = start.saturating_add(usize::from(words) * 2); let mut mode = 1; + let mut mix = WMF_MIX_START; let metric = |mode: u16| (2..=6).contains(&mode); // Drawing records played in a metric mode, and in any other. let (mut in_metric, mut in_other) = (0usize, 0usize); - // The modes SAVEDC kept, which RESTOREDC brings back as the reader does. - let mut saved: Vec = Vec::new(); + // The map and mix modes SAVEDC kept, which RESTOREDC brings back as the reader does. + let mut saved: Vec<(u16, u16)> = Vec::new(); // The records as the reader walks them: it stops at the end record, a record shorter than its // header, one that runs past the file and a SAVEDC past its 1,024 levels. while let (Some(words), Some(function)) = (u32_at(at), u16_at(at.saturating_add(4))) { @@ -86,12 +100,14 @@ fn wmf_metric(bytes: &[u8], start: usize) -> bool { match function { // SETMAPMODE, when the record holds its mode. 0x0103 if size >= 8 => mode = u16_at(at + 6).unwrap_or(mode), + // SETROP2, when the record holds its mix mode. + 0x0104 if size >= 8 => mix = u16_at(at + 6).unwrap_or(mix), // SAVEDC. 0x001E => { if saved.len() >= SAVED_LEVELS { break; } - saved.push(mode); + saved.push((mode, mix)); } // RESTOREDC: a negative count pops that many levels, a positive one goes back to that // level. One that names no saved level changes nothing. @@ -99,20 +115,31 @@ fn wmf_metric(bytes: &[u8], start: usize) -> bool { let n = u16_at(at + 6).map_or(0, |v| i32::from(i16::from_le_bytes(v.to_le_bytes()))); let level = if n < 0 { saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) }; if let Some(level) = level - && let Some(m) = saved.get(level).copied() + && let Some((m, x)) = saved.get(level).copied() { mode = m; + mix = x; saved.truncate(level); } } - f if WMF_DRAWS.contains(&f) => { - if metric(mode) { + f => { + let draws = match f { + _ if WMF_DRAWS.contains(&f) => true, + _ if WMF_SHAPES.contains(&f) => !WMF_MIX_SKIPS.contains(&mix), + // PATBLT: its raster operation, when the record holds one. + 0x061D => match u32_at(at + 6).filter(|_| size >= 10) { + Some(PATCOPY) => !WMF_MIX_SKIPS.contains(&mix), + Some(rop) => rop == BLACKNESS || rop == WHITENESS, + None => false, + }, + _ => false, + }; + if draws && metric(mode) { in_metric += 1; - } else { + } else if draws { in_other += 1; } } - _ => {} } at = next; } @@ -401,6 +428,14 @@ impl Mapper { if let (None, Some((c, w))) = (fill, stroke) && !segs.iter().any(|s| matches!(s, Seg::Close)) { + // A stroke that reaches too far is left out here, before it joins the strokes + // waiting, so they are still added. + let half = f64::from(w) * self.k; + let half = if half > 0.0 && half.is_finite() { half / 2.0 } else { HAIRLINE / 2.0 }; + if !sane(bp.bounding_box().inflate(half, half)) { + self.flush(); + return self.warn(FAR_AWAY); + } let pen = Pen { color: c, width: w, line, even_odd, chain: chain.iter().map(|c| c.id).collect() }; let start = bp.elements().first().and_then(|e| match e { PathEl::MoveTo(p) => Some(*p), diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index a193997c4b..f5870c5f95 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -409,6 +409,57 @@ fn wmf_that_draws_in_a_metric_map_mode_and_restores_is_read_at_96_pixels_an_inch assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "{b:?}"); } +#[test] +fn wmf_records_the_mix_mode_keeps_from_drawing_do_not_set_its_scale() { + // A rectangle of 1440 units in MM_TEXT; then SAVEDC, MM_TWIPS, a mix mode and two records that + // draw nothing under it, and RESTOREDC. + let rect = (0x041B, le16(&[1440, 1440, 0, 0])); + let patblt = |rop: u32| (0x061D, [rop.to_le_bytes().to_vec(), le16(&[100, 100, 0, 0])].concat()); + let cases = [ + // R2_NOP and R2_XORPEN: rectangles draw nothing. + (11i16, [rect.clone(), rect.clone()]), + (7, [rect.clone(), rect.clone()]), + // R2_NOP: PATBLT with PATCOPY fills nothing. + (11, [patblt(0x00F0_0021), patblt(0x00F0_0021)]), + // R2_COPYPEN: PATBLT with DSTINVERT is not drawn. + (13, [patblt(0x0055_0009), patblt(0x0055_0009)]), + ]; + for (mix, hidden) in cases { + let mut records = vec![(0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 1440])), rect.clone()]; + records.extend([(0x001E, Vec::new()), (0x0103, le16(&[6])), (0x0104, le16(&[mix]))]); + records.extend(hidden); + records.push((0x0127, le16(&[-1]))); + let plain = hand_wmf(1440, 1440, &records)[22..].to_vec(); + let back = import(&plain).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1, "mix {mix}: {p:?}"); + let b = p[0].geometric_bounds().unwrap(); + assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "mix {mix}: {b:?}"); + } + // R2_NOP, then SAVEDC, R2_COPYPEN and RESTOREDC bring R2_NOP back: the MM_TWIPS rectangle after + // them draws nothing. + let mut records = vec![(0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 1440])), rect.clone(), (0x0104, le16(&[11]))]; + records.extend([(0x001E, Vec::new()), (0x0104, le16(&[13])), (0x0127, le16(&[-1])), (0x0103, le16(&[6])), rect]); + let back = import(&hand_wmf(1440, 1440, &records)[22..]).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1, "{p:?}"); + let b = p[0].geometric_bounds().unwrap(); + assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "restored: {b:?}"); +} + +#[test] +fn a_stroke_that_reaches_too_far_leaves_the_strokes_before_it() { + // The black pen draws from (0, 500) to (100, 500), then on to a point far outside the picture. + let records = vec![(37, le(&[0x8000_0007u32 as i32])), (27, le(&[0, 500])), (54, le(&[100, 500])), (54, le(&[100_000_000, 500]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1, "{p:?}"); + let mm = 72.0 / 25.4; + let b = p[0].geometric_bounds().unwrap(); + assert!((b.x0).abs() < 0.01 && (b.x1 - mm).abs() < 0.01 && (b.y0 - 5.0 * mm).abs() < 0.01, "{b:?}"); + assert!(back.warnings.iter().any(|w| w.contains("far outside")), "{:?}", back.warnings); +} + #[test] fn damaged_files_fail_or_import_partly() { let good = emf(&sample()).bytes;