mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-30 05:27:06 -06:00
Pictures: WMF/EMF text, pattern and hatch brushes draw; ActiveX previews show (#336)
* ActiveX controls show their pictures A slide's p:controls are read: each control with a p:pic (the image PowerPoint shows for it, often a WMF) becomes a picture above the slide's shapes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Metafiles draw their text, pattern and hatch brushes deckcraft-metafile (still kurbo + thiserror only, so it builds for wasm): - Text: WMF EXTTEXTOUT/TEXTOUT and EMF EXTTEXTOUTW/EXTTEXTOUTA/SMALLTEXTOUT with the selected font (CREATEFONTINDIRECT, EXTCREATEFONTINDIRECTW: face, signed LOGFONT height, width, weight, italic, underline, strike-out, escapement, charset), text and background colour, background mode, TA_* alignment and TA_UPDATECP, the dx array (ETO_PDY pairs too), ETO_OPAQUE fills and ETO_CLIPPED clips. 8-bit strings decode through their charset (Windows-1252/1250/1251; symbol fonts keep their codes at U+F000; double-byte sets keep their advances). The neutral Item::Text carries a text-space-to-picture transform, so the mapping and escapement are folded in and glyphs are never mirrored. - Brushes: WMF DIBCREATEPATTERNBRUSH, EMF CREATEDIBPATTERNBRUSHPT and CREATEMONOBRUSH, and BS_HATCHED, as Item::Pattern tiles in 96-dpi pixels; WMF PATBLT. The XOR / AND-pattern / XOR see-through shadow of old PowerPoint WMFs draws its pattern in the XOR colour. - MFCOMMENT escapes with an embedded EMF are ignored: PowerPoint draws the WMF's records. - Pattern pixels and text count against the byte budget; strings are capped. deckcraft-render draws text through deckcraft-fonts with the face slide text would use (document_face), symbol fonts mapped as bullets map them (deckcraft_text::symbol_char, now public, with Webdings), per-character fallback, the file's advances, GDI cell metrics for top/bottom alignment, underline/strike-out and synthetic bold/italic. Tiles below 1.5 output pixels per cell are drawn as their average colour. On the 24 corpus slides that show a metafile, mean abs diff against PowerPoint goes from 28.31 to 5.06 (with the OLE/ActiveX preview commits). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d6ab3d10da
commit
e28851d2f1
@@ -1,17 +1,30 @@
|
||||
//! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by
|
||||
//! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go
|
||||
//! through the same crop, tile and recolour path as any other picture.
|
||||
//!
|
||||
//! Text items name the font the file asks for; they are drawn with the face slide text would use
|
||||
//! for that family (`FontDb::document_face`, so a missing font falls back the same way), symbol
|
||||
//! fonts mapped as bullets map them, and the file's own advances (the dx array) placing each
|
||||
//! character.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_metafile::PlacedItem;
|
||||
use kurbo::{Affine, Rect, Shape as _};
|
||||
use deckcraft_fonts::{FontDb, FontFace};
|
||||
use deckcraft_metafile::{HAlign, Item, PlacedItem, Text, Tile, VAlign};
|
||||
use kurbo::{Affine, BezPath, Rect, Shape as _};
|
||||
use skrifa::MetadataProvider as _;
|
||||
use vello_cpu::{Pixmap, RenderContext, Resources, peniko};
|
||||
|
||||
/// Longest side of a metafile drawn without a target size (export, thumbnails of the picture).
|
||||
pub const DEFAULT_SIDE: u32 = 1024;
|
||||
/// Largest side drawn.
|
||||
const MAX_SIDE: u32 = 8192;
|
||||
/// Pattern cells smaller than this (output pixels) are drawn as the tile's average colour: a
|
||||
/// sub-pixel pattern would only alias.
|
||||
const MIN_CELL_PX: f64 = 1.5;
|
||||
/// Size of text whose font gives no height (LOGFONT height 0), in text units.
|
||||
const DEFAULT_EM: f64 = 12.0;
|
||||
|
||||
/// Is `bytes` a WMF or EMF file?
|
||||
pub fn is_metafile(bytes: &[u8]) -> bool {
|
||||
@@ -31,6 +44,8 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16;
|
||||
let pixmaps: Vec<Option<Arc<Pixmap>>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect();
|
||||
// Pattern tiles shared by several fills are converted once.
|
||||
let mut tiles: HashMap<*const Tile, (Option<Arc<Pixmap>>, peniko::Color)> = HashMap::new();
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
let r = Rect::new(0.0, 0.0, w as f64, h as f64);
|
||||
ctx.push_clip_layer(&r.to_path(0.1));
|
||||
@@ -60,6 +75,43 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
ctx.fill_rect(&rect);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
PlacedItem::Pattern { index, path, even_odd, tile } => {
|
||||
let Some(Item::Pattern { tile: t, .. }) = pic.items.get(index) else { continue };
|
||||
let key = Arc::as_ptr(t);
|
||||
let (pm, avg) = tiles.entry(key).or_insert_with(|| (premul(t.width, t.height, &t.rgba), average(&t.rgba))).clone();
|
||||
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
let cell = tile.determinant().abs().sqrt();
|
||||
match pm {
|
||||
Some(pm) if cell >= MIN_CELL_PX => {
|
||||
ctx.set_paint(vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm),
|
||||
sampler: peniko::ImageSampler {
|
||||
x_extend: peniko::Extend::Repeat,
|
||||
y_extend: peniko::Extend::Repeat,
|
||||
quality: peniko::ImageQuality::Low,
|
||||
alpha: 1.0,
|
||||
},
|
||||
});
|
||||
ctx.set_paint_transform(tile);
|
||||
ctx.fill_path(&path);
|
||||
ctx.reset_paint_transform();
|
||||
}
|
||||
_ => {
|
||||
ctx.set_paint(avg);
|
||||
ctx.fill_path(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
PlacedItem::Text { index, transform, clip } => {
|
||||
let Some(Item::Text(t)) = pic.items.get(index) else { continue };
|
||||
if let Some(c) = clip {
|
||||
ctx.push_clip_layer(&c.to_path(0.1));
|
||||
}
|
||||
draw_text(&mut ctx, t, transform);
|
||||
if clip.is_some() {
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
@@ -81,10 +133,213 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
|
||||
image::RgbaImage::from_raw(w as u32, h as u32, px)
|
||||
}
|
||||
|
||||
/// One glyph placed in text space (x along the baseline from the string's start, font units at
|
||||
/// `scale` text units each).
|
||||
struct Glyph {
|
||||
face: Arc<FontFace>,
|
||||
gid: u32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
/// Draws a text item; `m` maps its text space to the output.
|
||||
fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) {
|
||||
let db = FontDb::global();
|
||||
let font = &t.font;
|
||||
let family = if font.family.trim().is_empty() { "Arial" } else { font.family.trim() };
|
||||
let (bold, italic) = (font.weight >= 600, font.italic);
|
||||
let style = match (bold, italic) {
|
||||
(true, true) => "Bold Italic",
|
||||
(true, false) => "Bold",
|
||||
(false, true) => "Italic",
|
||||
(false, false) => "Regular",
|
||||
};
|
||||
let face = db.document_face(family, style);
|
||||
let upem = face.upem.max(1.0);
|
||||
let (asc, desc) = win_metrics(&face);
|
||||
// LOGFONT height: negative is the em, positive the cell (ascent plus descent).
|
||||
let h = f64::from(font.height);
|
||||
let em = if h < 0.0 {
|
||||
-h
|
||||
} else if h > 0.0 {
|
||||
h * upem / (asc + desc).max(1.0)
|
||||
} else {
|
||||
DEFAULT_EM
|
||||
};
|
||||
let k = em / upem;
|
||||
if !k.is_finite() || k <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// Characters as drawn: symbol fonts (Wingdings…) map like bullets; each character takes the
|
||||
// face that has it.
|
||||
let chars: Vec<char> = t.text.chars().map(|c| deckcraft_text::symbol_char(&face, family, c).unwrap_or(c)).collect();
|
||||
let faces: Vec<Arc<FontFace>> = chars
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if c.is_whitespace() || c.is_control() || face.covers(*c) { face.clone() } else { db.cascade(&[*c], std::slice::from_ref(&face), style) }
|
||||
})
|
||||
.collect();
|
||||
// Shape runs of one face; record each glyph's pen position and its character.
|
||||
let mut shaped: Vec<(Glyph, usize)> = Vec::with_capacity(chars.len());
|
||||
let mut char_pen: Vec<f64> = vec![0.0; chars.len() + 1];
|
||||
let mut pen = 0.0;
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let Some(f) = faces.get(i).cloned() else { break };
|
||||
let mut j = i + 1;
|
||||
while faces.get(j).is_some_and(|g| g.id() == f.id()) {
|
||||
j += 1;
|
||||
}
|
||||
let run: String = chars.get(i..j).unwrap_or(&[]).iter().collect();
|
||||
// Byte offset in the run → character index in the string.
|
||||
let byte_char: Vec<usize> = run.char_indices().map(|(b, _)| b).collect();
|
||||
let ku = f.upem.max(1.0);
|
||||
for g in deckcraft_fonts::shape(&f, &run, &[], |c| c) {
|
||||
let ci = i + byte_char.partition_point(|b| *b < g.cluster).min(j - i - 1);
|
||||
let s = em / ku;
|
||||
shaped.push((Glyph { face: f.clone(), gid: g.gid, x: pen + f64::from(g.x_offset) * s, y: -f64::from(g.y_offset) * s }, ci));
|
||||
pen += f64::from(g.x_advance) * s;
|
||||
if let Some(p) = char_pen.get_mut(ci + 1) {
|
||||
*p = pen;
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
// Character start pens: a character's start is the previous one's end.
|
||||
for ci in 1..char_pen.len() {
|
||||
let prev = char_pen.get(ci - 1).copied().unwrap_or(0.0);
|
||||
if let Some(p) = char_pen.get_mut(ci)
|
||||
&& *p < prev
|
||||
{
|
||||
*p = prev;
|
||||
}
|
||||
}
|
||||
// The file's advances place each character; glyphs keep their offset within their character.
|
||||
let use_dx = !t.dx.is_empty() && t.dx.len() == chars.len();
|
||||
let mut width = pen;
|
||||
let mut x_scale = 1.0;
|
||||
if use_dx {
|
||||
let mut starts = Vec::with_capacity(chars.len() + 1);
|
||||
let mut x = 0.0;
|
||||
for d in &t.dx {
|
||||
starts.push(x);
|
||||
x += f64::from(*d);
|
||||
}
|
||||
width = x;
|
||||
for (g, ci) in &mut shaped {
|
||||
let (start, pen_start) = (starts.get(*ci).copied().unwrap_or(0.0), char_pen.get(*ci).copied().unwrap_or(0.0));
|
||||
g.x = start + (g.x - pen_start);
|
||||
}
|
||||
} else if font.width > 0.0 {
|
||||
// A LOGFONT width narrows or widens the font against its average character width.
|
||||
let avg = avg_char_width(&face) * k;
|
||||
if avg > 0.0 {
|
||||
x_scale = (f64::from(font.width) / avg).clamp(0.05, 20.0);
|
||||
width *= x_scale;
|
||||
for (g, _) in &mut shaped {
|
||||
g.x *= x_scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
let x0 = match t.h_align {
|
||||
HAlign::Left => 0.0,
|
||||
HAlign::Center => -width / 2.0,
|
||||
HAlign::Right => -width,
|
||||
};
|
||||
let (a, d) = (asc * k, desc * k);
|
||||
let base = match t.v_align {
|
||||
VAlign::Baseline => 0.0,
|
||||
VAlign::Top => a,
|
||||
VAlign::Bottom => -d,
|
||||
};
|
||||
let at = m * Affine::translate((x0, base));
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
if let Some(bg) = t.background
|
||||
&& width > 0.0
|
||||
{
|
||||
ctx.set_paint(rgba(bg));
|
||||
ctx.fill_path(&(at * Rect::new(0.0, -a, width, d).to_path(0.1)));
|
||||
}
|
||||
let color = rgba(t.color);
|
||||
ctx.set_paint(color);
|
||||
for (g, _) in &shaped {
|
||||
let path = db.outline(&g.face, g.gid);
|
||||
if path.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let s = em / g.face.upem.max(1.0);
|
||||
let skew = if italic && !g.face.italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let gm = at * Affine::translate((g.x, g.y)) * Affine::scale_non_uniform(x_scale, 1.0) * skew * Affine::scale(s);
|
||||
let p: BezPath = gm * path.as_ref().clone();
|
||||
ctx.fill_path(&p);
|
||||
if bold && g.face.weight < 600.0 {
|
||||
ctx.set_stroke(kurbo::Stroke::new(em * 0.025 * scale_of(at)).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&p);
|
||||
}
|
||||
}
|
||||
// Underline and strike-out: the font's own positions and thickness.
|
||||
let metrics = face.skrifa().map(|f| f.metrics(skrifa::instance::Size::unscaled(), face.location()));
|
||||
let deco = |on: bool, d: Option<skrifa::metrics::Decoration>, fallback: (f64, f64)| -> Option<Rect> {
|
||||
if !on || width <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let (off, th) = d.map_or(fallback, |d| (f64::from(d.offset) * k, f64::from(d.thickness) * k));
|
||||
let th = if th > 0.0 { th } else { em / 20.0 };
|
||||
// Offsets are y-up from the baseline; text space is y-down.
|
||||
Some(Rect::new(0.0, -off - th / 2.0, width, -off + th / 2.0))
|
||||
};
|
||||
let lines = [
|
||||
deco(font.underline, metrics.as_ref().and_then(|m| m.underline), (-em * 0.1, em / 20.0)),
|
||||
deco(font.strike, metrics.as_ref().and_then(|m| m.strikeout), (em * 0.26, em / 20.0)),
|
||||
];
|
||||
for r in lines.into_iter().flatten() {
|
||||
ctx.fill_path(&(at * r.to_path(0.1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean scale of an affine transform's linear part.
|
||||
fn scale_of(m: Affine) -> f64 {
|
||||
m.determinant().abs().sqrt()
|
||||
}
|
||||
|
||||
/// The face's Windows ascent and descent (OS/2 `usWinAscent`/`usWinDescent`, as GDI measures the
|
||||
/// cell), in font units; the typographic ones when the font has no OS/2 table.
|
||||
fn win_metrics(face: &FontFace) -> (f64, f64) {
|
||||
use skrifa::raw::TableProvider as _;
|
||||
let os2 = face.skrifa().and_then(|f| f.os2().ok()).map(|o| (f64::from(o.us_win_ascent()), f64::from(o.us_win_descent())));
|
||||
match os2 {
|
||||
Some((a, d)) if a + d > 0.0 => (a, d),
|
||||
_ => (face.ascent.max(0.0), face.descent.max(0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// OS/2 `xAvgCharWidth` in font units (half an em without it).
|
||||
fn avg_char_width(face: &FontFace) -> f64 {
|
||||
use skrifa::raw::TableProvider as _;
|
||||
face.skrifa().and_then(|f| f.os2().ok()).map(|o| f64::from(o.x_avg_char_width())).filter(|w| *w > 0.0).unwrap_or(face.upem * 0.5)
|
||||
}
|
||||
|
||||
fn rgba(c: [u8; 4]) -> peniko::Color {
|
||||
peniko::Color::from_rgba8(c[0], c[1], c[2], c[3])
|
||||
}
|
||||
|
||||
/// The average colour of straight RGBA pixels (alpha-weighted).
|
||||
fn average(px: &[u8]) -> peniko::Color {
|
||||
let (mut r, mut g, mut b, mut a, mut n) = (0u64, 0u64, 0u64, 0u64, 0u64);
|
||||
for p in px.as_chunks::<4>().0 {
|
||||
let w = u64::from(p[3]);
|
||||
r += u64::from(p[0]) * w;
|
||||
g += u64::from(p[1]) * w;
|
||||
b += u64::from(p[2]) * w;
|
||||
a += w;
|
||||
n += 1;
|
||||
}
|
||||
if a == 0 || n == 0 {
|
||||
return peniko::Color::from_rgba8(0, 0, 0, 0);
|
||||
}
|
||||
peniko::Color::from_rgba8((r / a) as u8, (g / a) as u8, (b / a) as u8, (a / n) as u8)
|
||||
}
|
||||
|
||||
/// Straight RGBA rows to a premultiplied pixmap.
|
||||
fn premul(w: u32, h: u32, px: &[u8]) -> Option<Arc<Pixmap>> {
|
||||
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 {
|
||||
|
||||
@@ -1268,3 +1268,178 @@ fn text_spilling_out_of_its_box_below_the_slide_is_reported() {
|
||||
let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() });
|
||||
assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0));
|
||||
}
|
||||
|
||||
/// A placeable WMF (1440 units per inch, bounds `w` × `h`) holding `records` (function, parameter
|
||||
/// bytes).
|
||||
fn wmf_with(w: i16, h: i16, records: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
for (func, params) in records {
|
||||
let mut p = params.clone();
|
||||
if p.len() % 2 == 1 {
|
||||
p.push(0);
|
||||
}
|
||||
body.extend(((6 + p.len()) as u32 / 2).to_le_bytes());
|
||||
body.extend(func.to_le_bytes());
|
||||
body.extend(p);
|
||||
}
|
||||
body.extend([3, 0, 0, 0, 0, 0]);
|
||||
let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec();
|
||||
f.extend(0u16.to_le_bytes());
|
||||
for v in [0i16, 0, w, h] {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
f.extend(1440u16.to_le_bytes());
|
||||
f.extend(0u32.to_le_bytes());
|
||||
f.extend(0u16.to_le_bytes());
|
||||
let total = (f.len() + 18 + body.len()) / 2;
|
||||
for v in [1u16, 9, 0x300] {
|
||||
f.extend(v.to_le_bytes());
|
||||
}
|
||||
f.extend((total as u32).to_le_bytes());
|
||||
f.extend(16u16.to_le_bytes());
|
||||
f.extend(0u32.to_le_bytes());
|
||||
f.extend(0u16.to_le_bytes());
|
||||
f.extend(body);
|
||||
f
|
||||
}
|
||||
|
||||
fn i16s(v: &[i32]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| (*x as i16).to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// CREATEFONTINDIRECT (height, escapement, face) then SELECTOBJECT of it (handle 0), transparent
|
||||
/// background, and the alignment `ta`.
|
||||
fn font_records(height: i32, esc: i32, face: &str, ta: i32) -> Vec<(u16, Vec<u8>)> {
|
||||
let mut f = i16s(&[height, 0, esc, esc, 400]);
|
||||
f.extend([0, 0, 0, if face.contains("dings") { 2 } else { 0 }, 0, 0, 0, 0]);
|
||||
f.extend(face.as_bytes());
|
||||
f.push(0);
|
||||
vec![(0x02FB, f), (0x012D, i16s(&[0])), (0x0102, i16s(&[1])), (0x012E, i16s(&[ta]))]
|
||||
}
|
||||
|
||||
/// EXTTEXTOUT at (x, y) with an optional dx array.
|
||||
fn text_record(x: i32, y: i32, s: &str, dx: &[i32]) -> (u16, Vec<u8>) {
|
||||
let mut v = i16s(&[y, x, s.len() as i32, 0]);
|
||||
v.extend(s.as_bytes());
|
||||
if s.len() % 2 == 1 {
|
||||
v.push(0);
|
||||
}
|
||||
v.extend(i16s(dx));
|
||||
(0x0A32, v)
|
||||
}
|
||||
|
||||
/// Bounding box (x0, y0, x1, y1) of the pixels at least half opaque and darker than mid-grey.
|
||||
fn mf_ink(img: &image::RgbaImage) -> Option<(u32, u32, u32, u32)> {
|
||||
let mut b: Option<(u32, u32, u32, u32)> = None;
|
||||
for (x, y, p) in img.enumerate_pixels() {
|
||||
if p[3] > 128 && (p[0] as u32 + p[1] as u32 + p[2] as u32) < 384 {
|
||||
b = Some(match b {
|
||||
None => (x, y, x, y),
|
||||
Some((a, c, d, e)) => (a.min(x), c.min(y), d.max(x), e.max(y)),
|
||||
});
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_text_ink_lands_at_its_reference_point() {
|
||||
// 2 × 1 inch (144 × 72 pt) drawn 288 px wide: 2 px per point. A 36-point "HH", top-left
|
||||
// aligned at (72, 18) pt.
|
||||
let mut recs = font_records(-720, 0, "Arial", 0);
|
||||
recs.push(text_record(1440, 360, "HH", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, y0, x1, y1) = mf_ink(&img).expect("text ink");
|
||||
assert!((144..156).contains(&x0), "left edge at the reference point: {x0}");
|
||||
assert!(x1 > x0 + 40 && x1 < 288, "two capitals wide: {x1}");
|
||||
// The cell's top is at 36 px; a capital starts below the cell top and sits on the baseline.
|
||||
assert!((38..72).contains(&y0), "cap top below the cell top: {y0}");
|
||||
assert!((88..112).contains(&y1), "baseline one ascent below the top: {y1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_text_centre_baseline_and_escapement() {
|
||||
// TA_CENTER | TA_BASELINE at (72, 36) pt: the ink is centred on 144 px and sits on 72 px.
|
||||
let mut recs = font_records(-720, 0, "Arial", 24 | 6);
|
||||
recs.push(text_record(1440, 720, "HH", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, _, x1, y1) = mf_ink(&img).expect("ink");
|
||||
assert!(((x0 + x1) as i32 / 2 - 144).abs() <= 4, "centred: {x0}..{x1}");
|
||||
assert!((69..=74).contains(&y1), "on the baseline: {y1}");
|
||||
// Escapement 90°: the string runs up the picture.
|
||||
let mut recs = font_records(-360, 900, "Arial", 24);
|
||||
recs.push(text_record(1440, 1300, "HHHH", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
|
||||
assert!(y1 - y0 > 2 * (x1 - x0), "a vertical run: {x0} {y0} {x1} {y1}");
|
||||
assert!(x1 <= 146 && y1 <= 132, "drawn up and left of the baseline origin (144, 130): {x1} {y1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_text_follows_its_dx_array() {
|
||||
// "HH" with the second H a full inch (144 px) after the first: two clumps of ink, a gap between.
|
||||
let mut recs = font_records(-360, 0, "Arial", 24);
|
||||
recs.push(text_record(360, 720, "HH", &[1440, 100]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let col_has_ink = |x: u32| (0..img.height()).any(|y| img.get_pixel(x, y)[3] > 128);
|
||||
assert!((36..56).any(col_has_ink), "first H at 36 px");
|
||||
assert!((180..200).any(col_has_ink), "second H one inch later");
|
||||
assert!(!(70..170).any(col_has_ink), "nothing between them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_symbol_text_draws_a_bullet_not_a_box() {
|
||||
// Webdings "n" is a filled circle (installed, or mapped to a black circle where it is not), as
|
||||
// is Wingdings "l" in a symbol font that is not installed anywhere.
|
||||
for (face, s) in [("Webdings", "n"), ("Wingdings Not Installed", "l")] {
|
||||
let mut recs = font_records(-1080, 0, face, 0);
|
||||
recs.push(text_record(360, 0, s, &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let (x0, y0, x1, y1) = mf_ink(&img).expect("ink");
|
||||
assert!(x1 - x0 > 10 && y1 - y0 > 10, "{face}: a sizeable mark: {x0} {y0} {x1} {y1}");
|
||||
let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);
|
||||
assert!(img.get_pixel(cx, cy)[3] > 200, "{face}: filled in the middle");
|
||||
for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] {
|
||||
assert!(img.get_pixel(x, y)[3] < 128, "{face}: round, not a box: corner ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_opaque_text_fills_its_cell() {
|
||||
// OPAQUE background in red behind a space.
|
||||
let mut recs = font_records(-720, 0, "Arial", 0);
|
||||
recs.push((0x0102, i16s(&[2])));
|
||||
recs.push((0x0201, 0x0000_00FFu32.to_le_bytes().to_vec()));
|
||||
recs.push(text_record(1440, 360, " ", &[]));
|
||||
let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster");
|
||||
let p = img.get_pixel(148, 60);
|
||||
assert!(p[0] > 200 && p[1] < 60 && p[3] > 200, "cell filled red: {p:?}");
|
||||
assert!(img.get_pixel(140, 60)[3] < 50, "nothing left of the cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafile_pattern_brush_tiles_and_averages_when_tiny() {
|
||||
// A 1-bit 8 × 8 checker (palette white, black), a null pen and a one-inch square.
|
||||
let mut dib: Vec<u8> = [40u32, 8, 8].iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
dib.extend(1u16.to_le_bytes());
|
||||
dib.extend(1u16.to_le_bytes());
|
||||
dib.extend([0u8; 24]);
|
||||
dib.extend([255, 255, 255, 0, 0, 0, 0, 0]);
|
||||
for r in 0..8 {
|
||||
dib.extend([if r % 2 == 0 { 0x55 } else { 0xAA }, 0, 0, 0]);
|
||||
}
|
||||
let mut brush = i16s(&[5, 0]);
|
||||
brush.extend(dib);
|
||||
let recs = vec![(0x0142, brush), (0x012D, i16s(&[0])), (0x012D, i16s(&[0x8008])), (0x041B, i16s(&[1440, 1440, 0, 0]))];
|
||||
let bytes = wmf_with(1440, 1440, &recs);
|
||||
// 4 px per point: a 0.75 pt cell is 3 px.
|
||||
let img = crate::metafile::rasterize(&bytes, 288).expect("raster");
|
||||
let (a, b) = (img.get_pixel(1, 1), img.get_pixel(4, 1));
|
||||
assert!(a[3] > 200 && b[3] > 200);
|
||||
assert!((a[0] as i32 - b[0] as i32).abs() > 200, "neighbouring cells alternate: {a:?} {b:?}");
|
||||
// Far below one pixel per cell: the average grey.
|
||||
let img = crate::metafile::rasterize(&bytes, 32).expect("raster");
|
||||
let p = img.get_pixel(16, 16);
|
||||
assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user