diff --git a/Cargo.lock b/Cargo.lock index 266217a1a..0556e4d31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1065,6 +1065,7 @@ dependencies = [ name = "craft-metafile" version = "0.1.0" dependencies = [ + "encoding_rs", "image", "kurbo 0.13.1", "thiserror 2.0.21", diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml index aebd7178f..d3deb2419 100644 --- a/crates/metafile/Cargo.toml +++ b/crates/metafile/Cargo.toml @@ -17,5 +17,7 @@ write = [] [dependencies] kurbo = { workspace = true } thiserror = { workspace = true } +# The Windows code pages of 8-bit text. +encoding_rs = { workspace = true } # JPEG and PNG bitmaps (BI_JPEG, BI_PNG) and the image files EMF+ carries. image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "tiff", "bmp"] } diff --git a/crates/metafile/README.md b/crates/metafile/README.md index 6a0c4f468..edd9750bf 100644 --- a/crates/metafile/README.md +++ b/crates/metafile/README.md @@ -7,11 +7,13 @@ Windows metafiles for the Craft apps: one reader for WMF, EMF and EMF+, and writ - `size(bytes)` gives the picture's size from the header alone when the header decides it. - `sniff(bytes) -> Option` and `is_metafile(bytes)` recognise the formats. - `Picture::fit` and `Picture::place` fit a picture (with crop fractions) into a rectangle for - callers that draw it; `crop_window` is the crop rule they share with bitmaps. + callers that draw it; `crop_window` is the crop rule they share with bitmaps (a negative crop + side pads the picture). - `write::emf` and `write::wmf` (feature `write`) write a list of drawing operations. -The crate depends on `kurbo`, `thiserror` and `image` (PNG, JPEG, GIF, TIFF and BMP decoders, for -the image files that EMF+ and `BI_JPEG`/`BI_PNG` bitmaps carry). It knows no fonts, no file system +The crate depends on `kurbo`, `thiserror`, `encoding_rs` (the code pages of 8-bit text) and `image` +(PNG, JPEG, GIF, TIFF and BMP decoders, for the image files that EMF+ and `BI_JPEG`/`BI_PNG` +bitmaps carry). It knows no fonts, no file system and no application. ## The model @@ -56,7 +58,7 @@ decoder is where the size, pixel and work limits have to be enforced. | Objects | `CREATEPENINDIRECT` (style, cap and join bits), `CREATEBRUSHINDIRECT` (solid, null, hatched), `DIBCREATEPATTERNBRUSH`, `CREATEFONTINDIRECT`, `SELECTOBJECT`, `DELETEOBJECT`, stock objects. `CREATEPALETTE`, `CREATEPATTERNBRUSH` and `CREATEREGION` take their handle slot. | | Drawing | `MOVETO`, `LINETO`, `POLYLINE`, `POLYGON`, `POLYPOLYGON`, `RECTANGLE`, `ROUNDRECT`, `ELLIPSE`, `ARC`, `PIE`, `CHORD`, `PATBLT`. | | Bitmaps | `STRETCHDIB`, `DIBSTRETCHBLT`, `DIBBITBLT` (with or without a source). | -| Text | `EXTTEXTOUT` (rectangle, opaque, clipped, dx), `TEXTOUT`; code pages 1250, 1251, 1252, the symbol character set, double-byte lead bytes as blanks. | +| Text | `EXTTEXTOUT` (rectangle, opaque, clipped, dx), `TEXTOUT`, decoded in the selected font's character set: code pages 1250 to 1258 and 874, Shift_JIS, UHC, GBK and Big5 (a double-byte character takes the advances of both its bytes), and the symbol character set (also the Symbol font under `DEFAULT_CHARSET`), whose codes stay in the private-use area at U+F000. Face names are read in the font's code page. Johab pairs are blanks. | | Clipping | `INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`. | | Not read | `OFFSETWINDOWORG`, `OFFSETVIEWPORTORG`, `SCALEWINDOWEXT`, `SCALEVIEWPORTEXT`, flood fills, `SETPIXEL`, region records (`FILLREGION`, `FRAMEREGION`, `PAINTREGION`, `INVERTREGION`, `SELECTCLIPREGION`, `OFFSETCLIPRGN`), the device-dependent `BITBLT`, `STRETCHBLT` and `SETDIBTODEV`. They are counted in `skipped_records`. | @@ -71,7 +73,7 @@ decoder is where the size, pixel and work limits have to be enforced. | Drawing | `MOVETOEX`, `LINETO`, `POLYLINE(16)`, `POLYLINETO(16)`, `POLYGON(16)`, `POLYBEZIER(16)`, `POLYBEZIERTO(16)`, `POLYPOLYLINE(16)`, `POLYPOLYGON(16)`, `RECTANGLE`, `ROUNDRECT`, `ELLIPSE`, `ARC`, `CHORD`, `PIE`. | | Paths | `BEGINPATH`, `ENDPATH`, `CLOSEFIGURE`, `FILLPATH`, `STROKEPATH`, `STROKEANDFILLPATH`, `ABORTPATH`, `SELECTCLIPPATH` (`RGN_AND`, `RGN_COPY`). | | Bitmaps | `BITBLT`, `STRETCHBLT`, `STRETCHDIBITS`, `SETDIBITSTODEVICE`, `ALPHABLEND` (constant and per-pixel alpha); source-less raster operations fill with the brush, black or white; `SRCAND`, `SRCPAINT` and `SRCINVERT` are drawn as they look over white. | -| Text | `EXTTEXTOUTA`, `EXTTEXTOUTW`, `SMALLTEXTOUT`; glyph-index strings are left out. | +| Text | `EXTTEXTOUTA`, `EXTTEXTOUTW`, `SMALLTEXTOUT`; 8-bit strings are decoded as WMF text is; glyph-index strings are left out. | | Clipping | `INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN` (all five modes, on rectangles), `SELECTCLIPPATH`. | | Not read | `ANGLEARC`, `ARCTO`, `POLYDRAW(16)`, `SETPIXELV`, flood fills, region paints, `MASKBLT`, `PLGBLT`, `TRANSPARENTBLT`, `GRADIENTFILL`, `POLYTEXTOUT`, `WIDENPATH`, `SETMETARGN`, `OFFSETCLIPRGN`, `SETLAYOUT`, `SELECTCLIPPATH` with `RGN_OR`, `RGN_XOR` or `RGN_DIFF` (the clip stays as it was). Unknown types are counted in `skipped_records`. | @@ -121,7 +123,7 @@ it never panics and never allocates from an unchecked count. Each limit has a te | Limit | Value | Past it | |---|---|---| | Items in a picture | 200,000 | The parse stops. | -| Bytes of path points and pixels kept (pattern brushes and clip paths included) | 256 MiB | The parse stops. | +| Bytes of path points and pixels kept (pattern brushes, clip paths, EMF+ objects with the nested pictures they hold, and EMF+ clips included) | 256 MiB | The parse stops; an EMF+ object past it is not stored. | | Points in one path bracket; points in one record | 2,000,000 | The bracket stops growing; the record is left out. | | Object table slots | 4,096 | The object is not stored. | | `SAVEDC` depth | 1,024 | The parse stops. | @@ -142,8 +144,9 @@ it never panics and never allocates from an unchecked count. Each limit has a te | Nested metafiles | 3 levels | The inner picture is not read. | | Clipping work per item, and per clip area | 1,000,000 and 4,000,000 steps | The item is left out; the clip keeps its previous area. | | Masking a bitmap to a clip that is not a rectangle | 5,000,000 point tests | The bitmap is left out. | -| Placed path elements (`Picture::place`) | 500,000 | Placing stops. | +| Placed path elements, clip regions included (`Picture::place`) | 500,000 | Placing stops. | | Placed coordinates (`Picture::place`) | ±1,000,000 | The item is left out. | +| Placed pen width (`Picture::place`) | Twice the diagonal of the frame and the path together | The width is held there. | | Writers: dashes cut from one stroke | 100,000 | The stroke is written solid. | | Writers: segments from one curve | about 4,096 | The tolerance is raised. | diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 2b59bb933..b4ac445fc 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -352,6 +352,8 @@ pub(crate) struct Canvas { saved: Vec, /// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`]. used: usize, + /// Bytes retained outside the canvas (EMF+ objects and clips) that count against the same cap. + held: usize, objects: Vec>, /// Indices below `objects.len()` that hold no object, so the WMF handle table finds its lowest /// free slot without scanning. @@ -400,6 +402,7 @@ impl Canvas { viewport_set: false, saved: Vec::new(), used: 0, + held: 0, objects: Vec::new(), free: std::collections::BTreeSet::new(), #[cfg(test)] @@ -436,6 +439,28 @@ impl Canvas { self.full } + /// Bytes the canvas has retained so far. + pub fn used(&self) -> usize { + self.used + } + + /// Records the bytes another part of the player retains, so the items and they stay within + /// [`MAX_BYTES`] together. + pub fn hold(&mut self, bytes: usize) { + self.held = bytes; + } + + /// Bytes retained outside the canvas that share its budget (see [`Canvas::hold`]). + #[cfg(test)] + pub(crate) fn held(&self) -> usize { + self.held + } + + /// Whether `cost` more bytes would take the canvas and what is held beside it past [`MAX_BYTES`]. + fn over_budget(&self, cost: usize) -> bool { + self.used.saturating_add(self.held).saturating_add(cost) > MAX_BYTES + } + fn push(&mut self, raw: Raw) { match self.plus_clip.take() { None => self.push_raw(raw), @@ -471,7 +496,7 @@ impl Canvas { } } .saturating_add(region_cost); - if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES { + if self.items.len() >= MAX_ITEMS || self.over_budget(cost) { self.full = true; } else { self.used += cost; @@ -551,7 +576,7 @@ impl Canvas { /// The character set of the selected font (ANSI without one). pub fn font_charset(&self) -> u8 { - self.text.font.as_ref().map_or(0, |f| f.font.charset) + self.text.font.as_ref().map_or(0, |f| crate::text::text_charset(&f.font)) } /// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set). @@ -620,6 +645,10 @@ impl Canvas { /// of at most [`SHADE_RES`] cells on the longer side, laid out so that its one repeat covers /// the shape. pub fn shade_shape(&mut self, ops: &[Op], shade: &dyn Fn(P) -> [u8; 4]) { + // A full canvas or an empty clip keeps nothing more, so do not sample a tile for it. + if self.full || self.clip.as_ref().is_some_and(Vec::is_empty) { + return; + } let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); let Some([l, t, r, b]) = crate::cliprect::ops_bbox(&dev) else { return }; let side = (r - l).max(b - t); @@ -761,7 +790,7 @@ impl Canvas { return; } let cost = ops.capacity().saturating_mul(std::mem::size_of::()); - if self.clip_paths.len() >= MAX_CLIP_PATHS || self.used.saturating_add(cost) > MAX_BYTES { + if self.clip_paths.len() >= MAX_CLIP_PATHS || self.over_budget(cost) { self.full = true; return; } @@ -808,7 +837,7 @@ impl Canvas { // A pattern brush's pixels count against the byte budget like drawn items. if let Obj::Brush(Brush { pattern: Some(p), .. }) = &obj { let cost = p.px.len(); - if self.used.saturating_add(cost) > MAX_BYTES { + if self.over_budget(cost) { self.full = true; return; } diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 0c46644d2..1e145ffd1 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -288,13 +288,23 @@ impl Plus { if !self.active { return None; } - match typ { - 0x4008 => self.object(flags, r), + let done = match typ { + 0x4008 => self.object(flags, r, cv.used().saturating_add(self.clip_bytes())), 0x4009 | 0x4013 | 0x401A..=0x401C | 0x4036 => self.record_image_text_region(cv, typ, flags, r), 0x4025..=0x4035 => self.record_state(cv, typ, flags, r), 0x4010..=0x4012 | 0x4016..=0x4019 => self.record_curve(cv, typ, flags, r), _ => self.record_shape(cv, typ, flags, r), - } + }; + // What this player retains (objects, a continued object's pieces, the current and saved + // clips) shares the canvas's byte budget. + cv.hold(self.object_bytes.saturating_add(self.partial.as_ref().map_or(0, |p| p.data.len())).saturating_add(self.clip_bytes())); + done + } + + /// Bytes of the current clip and of every saved one. + fn clip_bytes(&self) -> usize { + let area = |a: &Area| a.as_ref().map_or(0, |v| v.len().saturating_mul(std::mem::size_of::

())); + self.stack.iter().map(|s| area(&s.clip)).fold(area(&self.clip), usize::saturating_add) } /// Rectangle, polygon, ellipse and path records. @@ -559,7 +569,7 @@ impl Plus { Some(()) } - fn object(&mut self, flags: u16, r: Bytes) -> Option<()> { + fn object(&mut self, flags: u16, r: Bytes, canvas_used: usize) -> Option<()> { let id = usize::from(flags & 0xFF); let kind = (flags >> 8) & 0x7F; if flags & 0x8000 != 0 { @@ -575,7 +585,7 @@ impl Plus { p.data.extend_from_slice(&chunk[..chunk.len().min(room)]); if p.data.len() >= p.total { let obj = parse_object(p.kind, Bytes(&p.data)); - self.store(p.id, obj); + self.store(p.id, obj, canvas_used); } else { self.partial = Some(p); } @@ -589,22 +599,23 @@ impl Plus { let room = p.total.saturating_sub(p.data.len()); p.data.extend_from_slice(&r.0[..r.0.len().min(room)]); let obj = parse_object(kind, Bytes(&p.data)); - self.store(id, obj); + self.store(id, obj, canvas_used); return Some(()); } let obj = parse_object(kind, r); - self.store(id, obj); + self.store(id, obj, canvas_used); Some(()) } - fn store(&mut self, id: usize, obj: Option) { + /// `canvas_used` is what the canvas and the clips already retain: objects and items share one budget. + fn store(&mut self, id: usize, obj: Option, canvas_used: usize) { let Some(slot) = self.objects.get_mut(id) else { return }; if let Some(old) = slot.take() { self.object_bytes = self.object_bytes.saturating_sub(Self::obj_cost(&old)); } if let Some(new_obj) = obj { let cost = Self::obj_cost(&new_obj); - if self.object_bytes.saturating_add(cost) <= crate::MAX_BYTES { + if self.object_bytes.saturating_add(cost).saturating_add(canvas_used) <= crate::MAX_BYTES { self.object_bytes += cost; *slot = Some(new_obj); } @@ -615,7 +626,7 @@ impl Plus { match obj { Obj::Image(img) => match img.as_ref() { ImageData::Bitmap(rgba) => rgba.px.len(), - ImageData::Meta(pic) => pic.items.len().saturating_mul(128), + ImageData::Meta(pic) => picture_cost(&pic.items), }, Obj::Brush(Fill::Texture { img, .. }) => img.px.len(), Obj::Path(ops) => ops.len().saturating_mul(std::mem::size_of::()), @@ -1290,6 +1301,33 @@ fn metafile(r: Bytes) -> Option { pic.filter(|p| p.width > 0.0 && p.height > 0.0) } +/// Bytes the items of a nested picture own: path segments, dashes, clip rectangles, pixels and +/// text, with each shared pattern tile and clip path counted once. +fn picture_cost(items: &[Item]) -> usize { + let segs = |s: &Vec| s.len().saturating_mul(std::mem::size_of::()); + let region = |r: &Option>| r.as_ref().map_or(0, |v| v.len().saturating_mul(16)); + let mut tiles = std::collections::HashSet::new(); + let mut clip_paths = std::collections::HashSet::new(); + let mut total = 0usize; + for item in items { + let (own, r, clips) = match item { + Item::Path { segs: s, line, region: r, clips, .. } => (segs(s).saturating_add(line.dash.len().saturating_mul(4)), r, clips), + Item::Bitmap { rgba, region: r, clips, .. } => (rgba.len(), r, clips), + Item::Pattern { segs: s, tile, region: r, clips, .. } => { + let tile_bytes = if tiles.insert(Arc::as_ptr(tile)) { tile.rgba.len() } else { 0 }; + (segs(s).saturating_add(tile_bytes), r, clips) + } + Item::Text(t) => { + let own = t.text.len().saturating_add(t.dx.len().saturating_mul(std::mem::size_of::())).saturating_add(t.font.family.len()); + (own, &t.region, &t.clips) + } + }; + let clip_bytes = clips.iter().filter(|c| clip_paths.insert(Arc::as_ptr(c))).map(|c| segs(&c.segs)).fold(0usize, usize::saturating_add); + total = total.saturating_add(128).saturating_add(own).saturating_add(region(r)).saturating_add(clip_bytes); + } + total +} + fn bitmap(r: Bytes) -> Option { let (w, h, stride) = (r.u32(8)? as i32, r.u32(12)? as i32, r.u32(16)? as i32); let format = r.u32(20)?; @@ -1541,3 +1579,78 @@ fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec> { out.push(cur); out } + +#[cfg(test)] +mod cost_tests { + use super::*; + use crate::{ClipPath, Tile}; + + #[test] + fn a_shared_pattern_tile_and_clip_path_are_charged_once() { + let tile = Arc::new(Tile { width: 512, height: 512, rgba: vec![0; 1 << 20], cell: [1.0, 1.0], origin: [0.0, 0.0] }); + let clip = Arc::new(ClipPath { segs: vec![Seg::Close; 1 << 16], even_odd: false }); + let item = || Item::Pattern { + segs: vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)], + even_odd: false, + tile: tile.clone(), + region: None, + clips: vec![clip.clone()], + }; + let items: Vec = (0..257).map(|_| item()).collect(); + let shared = (1 << 20) + (1 << 16) * std::mem::size_of::(); + let cost = picture_cost(&items); + assert!(cost >= shared && cost < shared + (1 << 20), "{cost}"); + } + + #[test] + fn a_nested_picture_is_charged_for_its_pixels() { + let bitmap = Item::Bitmap { + rect: [0.0; 4], + transform: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + width: 1024, + height: 1024, + rgba: vec![0; 4 << 20], + region: None, + clips: Vec::new(), + }; + assert!(picture_cost(&[bitmap]) >= 4 << 20); + } + + #[test] + fn an_object_is_not_stored_past_what_the_canvas_already_holds() { + let mut plus = Plus::new(); + let path = || Some(Obj::Path(vec![Op::Z; 1000])); + plus.store(0, path(), crate::MAX_BYTES - 100); + assert!(plus.objects[0].is_none() && plus.object_bytes == 0); + plus.store(0, path(), 0); + assert!(plus.objects[0].is_some()); + } + + #[test] + fn pieces_of_a_continued_object_count_against_the_shared_budget() { + let (mut plus, mut cv) = (Plus::new(), Canvas::new()); + plus.active = true; + // Image object (kind 5), id 0, continued: 1000 bytes announced, 100 delivered. + let mut rec = 1000u32.to_le_bytes().to_vec(); + rec.extend([0u8; 100]); + plus.record(&mut cv, 0x4008, 0x8000 | (5 << 8), Bytes(&rec)); + assert_eq!(cv.held(), 100); + } + + #[test] + fn current_and_saved_clips_count_against_the_shared_budget() { + let (mut plus, mut cv) = (Plus::new(), Canvas::new()); + plus.active = true; + let pts = vec![(0.0, 0.0); 1000]; + plus.clip = Some(pts.clone()); + plus.stack.push(Saved { id: 1, world: IDENTITY, page: 1.0, clip: Some(pts) }); + // Any record refreshes the held count (an unknown record draws nothing). + plus.record(&mut cv, 0x40FF, 0, Bytes(&[])); + assert_eq!(cv.held(), 2 * 1000 * std::mem::size_of::

()); + // Restoring the saved state releases the charge of what it dropped. + plus.stack.clear(); + plus.clip = None; + plus.record(&mut cv, 0x40FF, 0, Bytes(&[])); + assert_eq!(cv.held(), 0); + } +} diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index 2e78284a5..166809de5 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -13,6 +13,8 @@ use crate::{ClipPath, Item, LineStyle, Picture, Seg}; pub const MAX_BITMAP_SIDE: u32 = 30_000; /// Placed coordinates past this (target units) are far off any page; such items are left out. const MAX_COORD: f64 = 1e6; +/// The most a crop side pads the source: a hundred times its size. +const MIN_CROP: f32 = -100.0; /// Smallest placed bitmap side (target units), so the stretch onto it is never singular. const MIN_BITMAP_SIDE: f64 = 1e-6; /// Maximum aggregate path elements retained in placed output. @@ -25,10 +27,13 @@ const MIN_VISIBLE: f64 = 0.005; /// The part of a picture's source that `crop` (left, top, right, bottom fractions of the source) /// keeps, as fractions of the source: [left, top, width, height], bitmap or metafile alike. Each -/// side is taken as 0–1 (junk as 0). When the two sides of an axis leave less than 0.5% of it, -/// the left (top) side stands and 0.5% shows. +/// side is taken as 0–1 (junk as 0). A negative side pads the source instead of cutting it (down +/// to -100), as a negative `a:srcRect` side does in Office files and a picture fitted whole into +/// a frame of another shape needs: the window then starts before the source or ends past it. +/// When the two sides of an axis leave less than 0.5% of it, the left (top) side stands and 0.5% +/// shows. pub fn crop_window(crop: [f32; 4]) -> [f64; 4] { - let [l, t, r, b] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 1.0)) } else { 0.0 }); + let [l, t, r, b] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(MIN_CROP, 1.0)) } else { 0.0 }); let axis = |lo: f64, hi: f64| { let lo = lo.min(1.0 - MIN_VISIBLE); (lo, (1.0 - lo - hi).max(MIN_VISIBLE)) @@ -99,22 +104,24 @@ impl Picture { let k = (c[0].abs() + c[3].abs()) / 2.0; let mut out = Vec::with_capacity(self.items.len()); let mut total_elements = 0usize; - let mut cached_region: Option<(*const [f32; 4], usize, PlacedRegion)> = None; + // The last region placed: consecutive items under the same clip share one placed path. + // Each region expanded counts against the element budget like the paths do. + let region_elements = std::cell::Cell::new(0usize); + let mut cached_region: Option<(Option>, PlacedRegion)> = None; let mut get_region = |reg: &Option>| -> PlacedRegion { - let (ptr, len) = match reg { - None => (std::ptr::null(), 0), - Some(v) => (v.as_ptr(), v.len()), - }; - if let Some((c_ptr, c_len, ref res)) = cached_region - && c_ptr == ptr - && c_len == len + if let Some((key, res)) = &cached_region + && key == reg { return res.clone(); } let res = placed_region(&tf, reg); - cached_region = Some((ptr, len, res.clone())); + let n = res.as_ref().and_then(Option::as_ref).map_or(0, |p| p.elements().len()); + region_elements.set(region_elements.get().saturating_add(n)); + cached_region = Some((reg.clone(), res.clone())); res }; + // Whether the paths and regions placed so far are past the budget. + let over = |paths: usize| paths.saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS; // Clip paths by address: the picture holds them for the whole call. None is a clip that is // not finite or reaches past the coordinate limit; what it clips is left out. @@ -138,24 +145,38 @@ impl Picture { }; for (index, item) in self.items.iter().enumerate() { - if total_elements >= MAX_PLACED_ELEMENTS { + if total_elements.saturating_add(region_elements.get()) >= MAX_PLACED_ELEMENTS { break; } match item { Item::Path { segs, fill, stroke, line, even_odd, region, clips } => { let path = tf * bez_path(segs); let Some(region) = get_region(region) else { continue }; + // This item's own region is expanded by now: it counts before the item is kept. + if over(total_elements) { + break; + } let Some(clips) = get_clips(clips) else { continue }; let line = LineStyle { dash: line.dash.iter().map(|d| (f64::from(*d) * k) as f32).collect(), ..line.clone() }; - if in_range(path.bounding_box()) && line.dash.iter().all(|d| d.is_finite()) { + let bounds = path.bounding_box(); + if in_range(bounds) && line.dash.iter().all(|d| d.is_finite()) { total_elements = total_elements.saturating_add(path.elements().len()); - let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k)); + // A pen wider than twice the distance across the frame and the path + // already covers the whole frame: wider adds nothing the frame shows, only + // work for the renderer flattening its round caps (the width is file data). + let u = bounds.union(r); + let widest = 2.0 * u.width().hypot(u.height()); + let stroke = stroke.map(|(c, w)| (c, (f64::from(w) * k).min(widest))); out.push(PlacedItem::Path { path, fill: *fill, stroke, line, even_odd: *even_odd, region, clips }); } } Item::Pattern { segs, even_odd, tile: t, region, clips } => { let path = tf * bez_path(segs); let Some(region) = get_region(region) else { continue }; + // This item's own region is expanded by now: it counts before the item is kept. + if over(total_elements) { + break; + } let Some(clips) = get_clips(clips) else { continue }; let tile = tf * Affine::translate((f64::from(t.origin[0]), f64::from(t.origin[1]))) @@ -169,6 +190,10 @@ impl Picture { Item::Text(t) => { let transform = tf * Affine::new(t.transform); let Some(region) = get_region(&t.region) else { continue }; + // This item's own region is expanded by now: it counts before the item is kept. + if over(total_elements) { + break; + } let Some(clips) = get_clips(&t.clips) else { continue }; let clip = t.clip.map(|[x0, y0, x1, y1]| tf.transform_rect_bbox(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1)))); @@ -182,6 +207,10 @@ impl Picture { let (x0, y0) = (f64::from(*x), f64::from(*y)); let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h))); let Some(region) = get_region(region) else { continue }; + // This item's own region is expanded by now: it counts before the item is kept. + if over(total_elements) { + break; + } let Some(clips) = get_clips(clips) else { continue }; let transform = tf * Affine::new(*transform); let finite = transform.as_coeffs().iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index dc7f9a26c..67634f1fd 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -914,6 +914,36 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() { assert_eq!(stroke.map(|s| s.1), Some(8.0)); } +#[test] +fn a_huge_pen_is_held_to_what_covers_the_frame() { + // A pen of 1e30 units on a short path: round caps that wide took a renderer forever to + // flatten. Past twice the distance across the frame and the path it covers no more. + let width = |w: f32| { + let pic = crate::Picture { + width: 100.0, + height: 50.0, + items: vec![Item::Path { + segs: vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0)], + fill: None, + stroke: Some(([0, 0, 0, 255], w)), + line: crate::LineStyle::default(), + even_odd: true, + region: None, + clips: Vec::new(), + }], + skipped_records: 0, + }; + match pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]).first() { + Some(crate::PlacedItem::Path { stroke, .. }) => stroke.map(|s| s.1), + _ => None, + } + }; + let cap = 2.0 * 200f64.hypot(100.0); + assert_eq!(width(1e30), Some(cap)); + assert_eq!(width(f32::MAX), Some(cap)); + assert_eq!(width(4.0), Some(8.0), "an ordinary pen is untouched"); +} + /// #1899: a crop keeps what the file says down to a sliver (a header's bar is 2.7% of a page-sized /// metafile), and junk sides give a finite part of the source with some area. #[test] @@ -928,10 +958,33 @@ fn crop_window_keeps_a_sliver_and_stays_finite() { assert!(top.y.abs() < 1e-3 && (bottom.y - 27.44).abs() < 1e-3, "{top:?} {bottom:?}"); for c in [[f32::NAN, f32::INFINITY, -1.0, 2.0], [0.9; 4], [1.0, 1.0, 0.0, 0.0], [f32::MAX, f32::MIN, f32::NEG_INFINITY, 0.5]] { let [x, y, w, h] = crate::crop_window(c); - assert!([x, y, w, h].iter().all(|v| v.is_finite()) && w > 0.0 && h > 0.0 && x + w <= 1.0 + 1e-9 && y + h <= 1.0 + 1e-9, "{c:?}"); + assert!([x, y, w, h].iter().all(|v| v.is_finite()) && w > 0.0 && h > 0.0, "{c:?}"); + // Only a negative side (padding) lets the window reach past the source. + if c.iter().all(|v| v.is_nan() || *v >= 0.0) { + assert!(x >= 0.0 && y >= 0.0 && x + w <= 1.0 + 1e-9 && y + h <= 1.0 + 1e-9, "{c:?}"); + } } } +/// A negative side pads the source: the window starts before the source or ends past it and is +/// larger than it, so the picture keeps its proportions instead of being stretched over the frame. +#[test] +fn crop_window_with_negative_sides_pads_the_source() { + let [x, y, w, h] = crate::crop_window([-0.25, -0.5, 0.0, 0.0]); + assert!((x + 0.25).abs() < 1e-6 && (y + 0.5).abs() < 1e-6 && (w - 1.25).abs() < 1e-6 && (h - 1.5).abs() < 1e-6, "{x} {y} {w} {h}"); + // A picture fitted whole into a taller frame: half its height added above and below. + let [x, y, w, h] = crate::crop_window([0.0, -0.5, 0.0, -0.5]); + assert!(x == 0.0 && w == 1.0 && y == -0.5 && h == 2.0, "{y} {h}"); + // Padding stops at a hundred times the source a side, so the window stays finite. + let [x, _, w, _] = crate::crop_window([f32::MIN, 0.0, -1e9, 0.0]); + assert!(x == -100.0 && w == 201.0, "{x} {w}"); + // The source sits in the middle half of the frame. + let pic = crate::Picture { width: 100.0, height: 50.0, items: Vec::new(), skipped_records: 0 }; + let tf = pic.fit(kurbo::Rect::new(0.0, 0.0, 100.0, 100.0), [0.0, -0.5, 0.0, -0.5]).unwrap(); + let (top, bottom) = (tf * kurbo::Point::new(0.0, 0.0), tf * kurbo::Point::new(100.0, 50.0)); + assert!((top.y - 25.0).abs() < 1e-9 && (bottom.y - 75.0).abs() < 1e-9 && (bottom.x - 100.0).abs() < 1e-9, "{top:?} {bottom:?}"); +} + /// The narrowest crop on the largest frame layout makes (4000 pt, far down a 22-inch page) still /// places the source's content: none of it is left out as far off the page. #[test] @@ -968,6 +1021,68 @@ fn the_narrowest_crop_on_the_largest_frame_keeps_its_content() { } } +#[test] +fn place_counts_the_items_own_clip_region_before_keeping_it() { + // A path of 499,998 elements nearly fills the 500,000 budget; a second item whose new clip + // region adds elements goes past it and is left out, whatever kind of item it is. + let mut segs = vec![Seg::Move(0.0, 0.0)]; + segs.extend((0..499_997).map(|i| Seg::Line(1.0 + (i % 50) as f32, 1.0))); + let path = |segs: Vec, region: Option>| Item::Path { + segs, + fill: None, + stroke: None, + line: crate::LineStyle::default(), + even_odd: false, + region, + clips: Vec::new(), + }; + let region = || Some(vec![[0.0, 0.0, 50.0, 25.0], [10.0, 10.0, 20.0, 20.0]]); + let frame = kurbo::Rect::new(0.0, 0.0, 100.0, 50.0); + let placed = |second: Item| { + let pic = crate::Picture { width: 100.0, height: 50.0, items: vec![path(segs.clone(), None), second], skipped_records: 0 }; + pic.place(frame, [0.0; 4]).len() + }; + let small = vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)]; + assert_eq!(placed(path(small.clone(), None)), 2, "without a region the second path fits"); + assert_eq!(placed(path(small, region())), 1); + let bitmap = Item::Bitmap { + rect: [0.0, 0.0, 10.0, 10.0], + transform: [10.0, 0.0, 0.0, 10.0, 0.0, 0.0], + width: 1, + height: 1, + rgba: vec![0; 4], + region: region(), + clips: Vec::new(), + }; + assert_eq!(placed(bitmap), 1); +} + +#[test] +fn place_expands_a_clip_region_once_for_the_items_that_share_it() { + // 2,000 items under one 64-rectangle region: 320 region elements, not 640,000. + let region: Vec<[f32; 4]> = (0..64).map(|i| [i as f32, 0.0, i as f32 + 0.5, 10.0]).collect(); + let item = || Item::Path { + segs: vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)], + fill: None, + stroke: None, + line: crate::LineStyle::default(), + even_odd: false, + region: Some(region.clone()), + clips: Vec::new(), + }; + let pic = crate::Picture { width: 100.0, height: 50.0, items: (0..2000).map(|_| item()).collect(), skipped_records: 0 }; + let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 100.0, 50.0), [0.0; 4]); + assert_eq!(placed.len(), 2000); + let regions: Vec<_> = placed + .iter() + .filter_map(|p| match p { + crate::PlacedItem::Path { region: Some(r), .. } => Some(std::sync::Arc::as_ptr(r)), + _ => None, + }) + .collect(); + assert!(regions.len() == 2000 && regions.iter().all(|r| *r == regions[0])); +} + #[test] fn place_keeps_only_finite_bounded_bitmaps_with_area() { let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, transform: [0.0; 6], width: 1, height: 1, rgba: vec![0; 4], region: None, clips: Vec::new() }; @@ -1382,11 +1497,76 @@ fn character_sets_decode_bytes() { assert_eq!(one(204, &[0xC0, 0xFF, 0xA8]), "АяЁ"); assert_eq!(one(2, b"n\xA7"), "\u{F06E}\u{F0A7}", "symbol fonts keep their codes in the private-use area"); assert_eq!(one(0, &[0x81]), "\u{FFFD}", "an undefined byte"); - // A double-byte character set: a lead and trail byte become one blank with both advances. - let p = - wmf200(&[wfont(-10, 400, [0, 0, 0], 128, 0, b"F"), wrec(0x012D, &h16(0)), wtext(0, 0, &[0x41, 0x82, 0xA0, 0x42], 0, None, &[1, 2, 3, 4])]); - let t = texts(&p)[0]; - assert_eq!((t.text.as_str(), t.dx.as_slice()), ("A B", [1.0, 5.0, 4.0].as_slice())); + // Hebrew (177): letters, a point, the sheqel sign; an undefined byte. + assert_eq!(one(177, &[0xF9, 0xEC, 0xE5, 0xED, b' ', 0xC8, 0xA4]), "שלום \u{05B8}₪"); + assert_eq!(one(177, &[0xFA, 0xE0, 0xFB]), "תא\u{FFFD}"); + // Arabic (178): "سلام", an Arabic comma and question mark. + assert_eq!(one(178, &[0xD3, 0xE1, 0xC7, 0xE3, 0xA1, 0xBF]), "سلام،؟"); + // Greek, Turkish, Vietnamese, Baltic and Thai. + assert_eq!(one(161, &[0xE1, 0xD9]), "αΩ"); + assert_eq!(one(162, &[0xD0, 0xFD, 0xFE]), "Ğış"); + assert_eq!(one(163, &[0xD5, 0xF0]), "Ơđ"); + assert_eq!(one(186, &[0xE0, 0xFE]), "ąž"); + assert_eq!(one(222, &[0xA1, 0xE4]), "กไ"); + // A string is in the font's code page: EF BB BF is three characters, not a byte order mark. + assert_eq!(one(0, &[0xEF, 0xBB, 0xBF, 0x41]), "A"); + assert_eq!(one(161, &[0xEF, 0xBB, 0xBF, 0x41]), "ο»ΏA"); +} + +#[test] +fn double_byte_character_sets_decode_pairs_with_both_advances() { + let run = |charset: u8, s: &[u8]| { + let dx: Vec = (1..=s.len() as i32).collect(); + let p = wmf200(&[wfont(-10, 400, [0, 0, 0], charset, 0, b"F"), wrec(0x012D, &h16(0)), wtext(0, 0, s, 0, None, &dx)]); + let t = texts(&p)[0]; + (t.text.clone(), t.dx.clone()) + }; + // Shift_JIS: a pair is one character with the sum of its two advances. + assert_eq!(run(128, &[0x41, 0x82, 0xA0, 0x42]), ("AあB".into(), vec![1.0, 5.0, 4.0])); + // Its half-width katakana are single bytes: 0xA6 is no lead byte and the 'A' after it no trail. + assert_eq!(run(128, &[0xA6, 0x41, 0xB1]), ("ヲAア".into(), vec![1.0, 2.0, 3.0])); + // Big5 A1D0 is the fullwidth hyphen-minus equation editors write for a minus sign. + assert_eq!(run(136, &[0xA1, 0xD0]), ("-".into(), vec![3.0])); + // UHC and GBK. + assert_eq!(run(129, &[0xB0, 0xA1]), ("가".into(), vec![3.0])); + assert_eq!(run(134, &[0xB0, 0xA1]), ("啊".into(), vec![3.0])); + // 0x7F is never a GBK trail byte: the lead before it is one undefined byte, 0x7F stands + // alone and the pair after them decodes. + assert_eq!(run(134, &[0xB0, 0x7F, 0xB0, 0xA1]), ("\u{FFFD}\u{7F}啊".into(), vec![1.0, 2.0, 7.0])); + // A lead byte at the end of the string is one undefined byte, as is the lead of a pair + // inside Big5's ranges that Big5 does not define; the byte after it is read on its own. + assert_eq!(run(136, &[0x81]), ("\u{FFFD}".into(), vec![1.0])); + assert_eq!(run(136, &[0x81, 0x40]), ("\u{FFFD}@".into(), vec![1.0, 2.0])); + // Johab is not decoded: a pair is one blank with both advances. + assert_eq!(run(130, &[0x41, 0x88, 0x61, 0x42]), ("A B".into(), vec![1.0, 5.0, 4.0])); +} + +#[test] +fn a_wmf_face_name_is_read_in_the_fonts_code_page() { + // 新細明體 (PMingLiU) in Big5. + let p = wmf200(&[ + wfont(-10, 400, [0, 0, 0], 136, 0, &[0xB7, 0x73, 0xB2, 0xD3, 0xA9, 0xFA, 0xC5, 0xE9]), + wrec(0x012D, &h16(0)), + wtext(0, 0, b"A", 0, None, &[]), + ]); + assert_eq!(texts(&p)[0].font.family, "新細明體"); + // A symbol font's name is plain text, not symbol codes. + let p = wmf200(&[wfont(-10, 400, [0, 0, 0], 2, 0, b"Wingdings"), wrec(0x012D, &h16(0)), wtext(0, 0, b"A", 0, None, &[])]); + assert_eq!(texts(&p)[0].font.family, "Wingdings"); +} + +#[test] +fn the_symbol_font_under_the_default_character_set_keeps_symbol_codes() { + let one = |charset: u8, face: &[u8]| { + let p = wmf200(&[wfont(-10, 400, [0, 0, 0], charset, 0, face), wrec(0x012D, &h16(0)), wtext(0, 0, &[0x61, 0xE6], 0, None, &[])]); + texts(&p)[0].text.clone() + }; + // Equation editors select "Symbol" with DEFAULT_CHARSET: the bytes are the font's own codes. + assert_eq!(one(1, b"Symbol"), "\u{F061}\u{F0E6}"); + assert_eq!(one(1, b"symbol (PCL6)"), "\u{F061}\u{F0E6}"); + // Another font under DEFAULT_CHARSET, and Symbol asked for as an ANSI font, are Western text. + assert_eq!(one(1, b"Arial"), "aæ"); + assert_eq!(one(0, b"Symbol"), "aæ"); } #[test] @@ -2403,3 +2583,24 @@ fn empty_poly_groups_reserve_nothing() { let ops = crate::geom::subpaths(&pts, &[2, 5, 4], false); assert_eq!((ops.len(), ops.capacity()), (3, 3)); } + +#[test] +fn full_canvas_does_not_sample_gradient_tiles() { + let mut cv = Canvas::new(); + let big = || Rgba { w: 1, h: 1, px: vec![0u8; MAX_BYTES / 2] }; + for _ in 0..3 { + cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); + } + assert!(cv.full()); + cv.shade_shape(&crate::geom::rect(0.0, 0.0, 10.0, 10.0), &|_| panic!("sampled a full canvas")); + assert_eq!(cv.items.len(), 2); +} + +#[test] +fn canvas_budget_counts_bytes_held_elsewhere() { + let mut cv = Canvas::new(); + cv.hold(MAX_BYTES - 100); + cv.bitmap([0.0, 0.0, 1.0, 1.0], Rgba { w: 1, h: 1, px: vec![0u8; 200] }); + assert!(cv.full()); + assert!(cv.items.is_empty()); +} diff --git a/crates/metafile/src/text.rs b/crates/metafile/src/text.rs index 53d13e5b4..e70447e90 100644 --- a/crates/metafile/src/text.rs +++ b/crates/metafile/src/text.rs @@ -23,6 +23,8 @@ pub struct Font { /// SYMBOL_CHARSET: byte codes are the font's own symbol codes. pub const SYMBOL_CHARSET: u8 = 2; +/// DEFAULT_CHARSET: the font decides. +const DEFAULT_CHARSET: u8 = 1; /// Where the reference point sits on the string horizontally (TA_LEFT, TA_CENTER, TA_RIGHT). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -98,18 +100,18 @@ pub(crate) fn align(ta: u32) -> (HAlign, VAlign) { (h, v) } -/// Decodes 8-bit text in `charset` to one character per byte, and merges `dx` to match: a -/// double-byte character set's lead and trail bytes become one blank (its glyphs are not -/// decoded) whose advance is the sum of both. `dx` may be empty. +/// Decodes 8-bit text in `charset` and merges `dx` to match, one entry per character. In a +/// double-byte character set (Shift_JIS, UHC, GBK, Big5) a lead and trail byte are one character +/// whose advance is the sum of both; a lead byte without a trail byte it pairs with is one +/// undefined byte (U+FFFD). Johab is not decoded: its pairs become blanks. `dx` may be empty. pub(crate) fn decode_bytes(bytes: &[u8], charset: u8, dx: &[f32]) -> (String, Vec) { let mut s = String::with_capacity(bytes.len()); let mut out_dx = Vec::with_capacity(dx.len().min(bytes.len())); - let dbcs = matches!(charset, 128 | 129 | 130 | 134 | 136); let mut i = 0; while let Some(&b) = bytes.get(i) { - let n = if dbcs && b >= 0x81 && i + 1 < bytes.len() { 2 } else { 1 }; - let c = if n == 2 { ' ' } else { byte_char(b, charset) }; - s.push(c); + let pair = bytes.get(i + 1).and_then(|t| pair_char(b, *t, charset)); + let n = if pair.is_some() { 2 } else { 1 }; + s.push(pair.unwrap_or_else(|| byte_char(b, charset))); if !dx.is_empty() { out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum()); } @@ -118,6 +120,60 @@ pub(crate) fn decode_bytes(bytes: &[u8], charset: u8, dx: &[f32]) -> (String, Ve (s, out_dx) } +/// The character set 8-bit text in `font` is decoded in. DEFAULT_CHARSET leaves the choice to the +/// font, and the Symbol font's bytes are its own codes. +pub(crate) fn text_charset(font: &Font) -> u8 { + let symbol = ["Symbol", "Symbol (PCL6)"].iter().any(|n| font.family.eq_ignore_ascii_case(n)); + if font.charset == DEFAULT_CHARSET && symbol { SYMBOL_CHARSET } else { font.charset } +} + +/// The encoding of a character set this module has no table of its own for. +fn encoding(charset: u8) -> Option<&'static encoding_rs::Encoding> { + Some(match charset { + // SHIFTJIS_CHARSET, HANGUL_CHARSET, GB2312_CHARSET, CHINESEBIG5_CHARSET. + 128 => encoding_rs::SHIFT_JIS, + 129 => encoding_rs::EUC_KR, + 134 => encoding_rs::GBK, + 136 => encoding_rs::BIG5, + // GREEK, TURKISH, VIETNAMESE, HEBREW, ARABIC, BALTIC and THAI_CHARSET. + 161 => encoding_rs::WINDOWS_1253, + 162 => encoding_rs::WINDOWS_1254, + 163 => encoding_rs::WINDOWS_1258, + 177 => encoding_rs::WINDOWS_1255, + 178 => encoding_rs::WINDOWS_1256, + 186 => encoding_rs::WINDOWS_1257, + 222 => encoding_rs::WINDOWS_874, + _ => return None, + }) +} + +/// The one character that `enc` decodes `bytes` to; None when they are not exactly one. +fn one_char(enc: &'static encoding_rs::Encoding, bytes: &[u8]) -> Option { + let (text, bad) = enc.decode_without_bom_handling(bytes); + let mut chars = text.chars(); + let c = chars.next()?; + (!bad && chars.next().is_none()).then_some(c) +} + +/// The character of a lead and trail byte in a double-byte character set; None when the bytes +/// are not a pair there. The ranges differ: Shift_JIS leaves its half-width katakana (0xA1..=0xDF) +/// as single bytes, and GBK has no trail byte 0x7F. +fn pair_char(lead: u8, trail: u8, charset: u8) -> Option { + let (is_lead, is_trail) = match charset { + 128 => (matches!(lead, 0x81..=0x9F | 0xE0..=0xFC), matches!(trail, 0x40..=0x7E | 0x80..=0xFC)), + 129 => (matches!(lead, 0x81..=0xFE), matches!(trail, 0x41..=0xFE)), + 134 => (matches!(lead, 0x81..=0xFE), matches!(trail, 0x40..=0x7E | 0x80..=0xFE)), + 136 => (matches!(lead, 0x81..=0xFE), matches!(trail, 0x40..=0x7E | 0xA1..=0xFE)), + // JOHAB_CHARSET: not decoded. + 130 => return (lead >= 0x81).then_some(' '), + _ => return None, + }; + if !(is_lead && is_trail) { + return None; + } + one_char(encoding(charset)?, &[lead, trail]) +} + /// UTF-16 text to a string with one dx entry per character: a surrogate pair takes the sum of /// its two advances. Unpaired surrogates become U+FFFD. pub(crate) fn decode_utf16(units: &[u16], dx: &[f32]) -> (String, Vec) { @@ -136,7 +192,7 @@ pub(crate) fn decode_utf16(units: &[u16], dx: &[f32]) -> (String, Vec) { (s, out_dx) } -/// One byte in a single-byte Windows code page. Symbol fonts keep their codes in the private-use +/// One byte that is not part of a double-byte pair, in its Windows code page. Symbol fonts keep their codes in the private-use /// range (U+F000 + code), where symbol fonts map them. fn byte_char(b: u8, charset: u8) -> char { if b < 0x80 && charset != SYMBOL_CHARSET { @@ -146,7 +202,11 @@ fn byte_char(b: u8, charset: u8) -> char { SYMBOL_CHARSET => 0xF000 | u32::from(b), 238 => high(b, &CP1250), 204 => high(b, &CP1251), - _ => high(b, &CP1252), + _ => match encoding(charset) { + // An undefined byte, and a lead byte with no trail byte, are U+FFFD. + Some(enc) => return one_char(enc, &[b]).unwrap_or(char::REPLACEMENT_CHARACTER), + None => high(b, &CP1252), + }, }; char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER) } diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs index dc24fa198..9f9db3508 100644 --- a/crates/metafile/src/wmf.rs +++ b/crates/metafile/src/wmf.rs @@ -241,8 +241,9 @@ fn font(r: Bytes) -> Option { let name = r.0.get(24..).unwrap_or(&[]); let name = name.get(..32).unwrap_or(name); let name = name.split(|b| *b == 0).next().unwrap_or(&[]); - // Face names are plain ASCII in practice; other bytes go through the Western code page. - let (family, _) = decode_bytes(name, 0, &[]); + // A face name is in the font's own code page (East Asian fonts are often named in their + // script); a symbol font's name is not in symbol codes. + let (family, _) = decode_bytes(name, if charset == crate::SYMBOL_CHARSET { 0 } else { charset }, &[]); Some(LogFont { font: Font { family: family.trim().to_string(),