mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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:
9 files changed
+492
-53
No files matched your search
Generated
+1
@@ -1065,6 +1065,7 @@ dependencies = [
|
||||
name = "craft-metafile"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user