metafile: pen width cap, shared byte budget, negative crops, code pages (#843)

* metafile: hold a placed pen width to what covers the frame

Picture::place scaled a pen width by the fit with no upper bound, so a
pen of 1e30 units made a renderer flatten round caps of that size. A
placed pen is now at most twice the diagonal of the frame and the path
together, which already covers the whole frame.

Ported from storytold/wordcraft#5427.

* metafile: one byte budget for items, EMF+ objects and clips; count clip regions when placing

- Canvas::shade_shape returns when the canvas is full or the clip is
  empty, instead of sampling a tile that is then dropped.
- EMF+ objects, the pieces of a continued object and the current and
  saved EMF+ clips count against the canvas's 256 MiB budget, in both
  directions: an object is not stored past what the canvas holds, and
  the canvas stops at what the objects hold.
- A nested metafile held by an EMF+ image object is charged for what its
  items own (segments, pixels, text, clip rectangles; a shared tile or
  clip path once) rather than 128 bytes an item.
- Picture::place charges each clip region it expands to the 500,000
  element budget, for every kind of item, and expands a region once for
  the consecutive items that share it.

Ported from storytold/deckcraft#1777, with its review comments addressed
(the canvas checks include the held bytes; text and bitmap items are
checked too; regions are matched by content, not by address).

* metafile: a negative crop side pads the picture

crop_window took each side as 0..1, so a negative side (a negative
a:srcRect side in an Office file, or a picture fitted whole into a frame
of another shape) was dropped and the picture was stretched over its
frame. A side is now taken down to -100: the window starts before the
source or ends past it, and Picture::fit places the source inside the
frame at its own proportions.

Ported from storytold/wordcraft#2027 (the -100 bound and the invariant
test) and storytold/wordcraft#5206 (the padding test).

* metafile: decode 8-bit text in the font's code page

8-bit text (WMF TEXTOUT and EXTTEXTOUT, EMF EXTTEXTOUTA and SMALLTEXTOUT)
was decoded in Windows-1250, 1251 or 1252 only, and a double-byte
character became a blank.

- Greek, Turkish, Vietnamese, Hebrew, Arabic, Baltic and Thai character
  sets decode in their code pages (encoding_rs, already a workspace
  dependency).
- Shift_JIS, UHC, GBK and Big5 pairs decode to their character, with the
  lead and trail ranges of each set; the character keeps the advances of
  both bytes. A lead byte without a trail byte is one undefined byte.
- A WMF face name is read in the font's code page.
- The Symbol font selected with DEFAULT_CHARSET keeps its bytes as symbol
  codes (U+F000 + code), as SYMBOL_CHARSET fonts do.

Ported from storytold/wordcraft#5187 (the code pages, the double-byte
ranges, the face name and the Symbol font) and storytold/deckcraft#792
(the Hebrew and Arabic tests).

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: David Lin <woei66@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-11-11 14:25:35 -05:00
1 parent ce31f42e76
commit 75895c0321
9 files changed
+492 -53

No files matched your search

Generated
+1
View File
@@ -1065,6 +1065,7 @@ dependencies = [
name = "craft-metafile"
version = "0.1.0"
dependencies = [
"encoding_rs",
"image",
"kurbo 0.13.1",
"thiserror 2.0.21",
+2
View File
@@ -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"] }
+10 -7
View File
@@ -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<Kind>` 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. |
+33 -4
View File
@@ -352,6 +352,8 @@ pub(crate) struct Canvas {
saved: Vec<Saved>,
/// 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<Option<Obj>>,
/// 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<Op> = 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::<Op>());
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;
}
+123 -10
View File
@@ -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::<P>()));
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<Obj>) {
/// `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<Obj>, 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::<Op>()),
@@ -1290,6 +1301,33 @@ fn metafile(r: Bytes) -> Option<Picture> {
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<Seg>| s.len().saturating_mul(std::mem::size_of::<Seg>());
let region = |r: &Option<Vec<[f32; 4]>>| 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::<f32>())).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<Rgba> {
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<Vec<u16>> {
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<Item> = (0..257).map(|_| item()).collect();
let shared = (1 << 20) + (1 << 16) * std::mem::size_of::<Seg>();
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::<P>());
// 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);
}
}
+44 -15
View File
@@ -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<Vec<[f32; 4]>>, PlacedRegion)> = None;
let mut get_region = |reg: &Option<Vec<[f32; 4]>>| -> 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);
+207 -6
View File
@@ -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<Seg>, region: Option<Vec<[f32; 4]>>| 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<i32> = (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());
}
+69 -9
View File
@@ -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<f32>) {
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<char> {
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<char> {
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<f32>) {
@@ -136,7 +192,7 @@ pub(crate) fn decode_utf16(units: &[u16], dx: &[f32]) -> (String, Vec<f32>) {
(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)
}
+3 -2
View File
@@ -241,8 +241,9 @@ fn font(r: Bytes) -> Option<LogFont> {
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(),