Merge PR #1915: Draw EMF text records (EXTTEXTOUTW) in metafile pictures

This commit is contained in:
aiongg
2026-10-22 01:19:40 +00:00
8 changed files with 494 additions and 11 deletions
+115 -2
View File
@@ -21,6 +21,12 @@ const R2_NOP: u32 = 11;
const PATCOPY: u32 = 0x00F0_0021;
const BLACKNESS: u32 = 0x0000_0042;
const WHITENESS: u32 = 0x00FF_0062;
/// SETTEXTALIGN: draw at and update the current point.
const TA_UPDATECP: u32 = 1;
/// Font height (cell height, logical units) of the default font, used for a height of 0.
const DEFAULT_FONT_HEIGHT: f64 = 16.0;
/// Face used when a font names none.
const DEFAULT_FONT_FACE: &str = "Arial";
/// Device pixels per millimetre assumed for WMF (96 dpi), and for EMF files without a device size.
const DEFAULT_PX_PER_MM: f64 = 96.0 / 25.4;
@@ -54,11 +60,23 @@ pub(crate) struct Brush {
pub color: [u8; 4],
}
/// A logical font (EMF LogFont): height in logical units (negative = character height, positive =
/// cell height, 0 = default), escapement in tenths of a degree, face name as UTF-16 (NUL-padded).
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) struct Font {
pub height: i32,
pub escapement: i32,
pub weight: i32,
pub italic: bool,
pub face: [u16; 32],
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum Obj {
Pen(Pen),
Brush(Brush),
/// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing.
Font(Font),
/// Palettes, pattern brushes and regions: they take a table slot but draw nothing.
Other,
}
@@ -168,6 +186,23 @@ fn ratio(num: f64, den: f64) -> f64 {
pub(crate) enum Raw {
Path { ops: Vec<Op>, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
Bitmap { rect: [f64; 4], img: Rgba },
Text(RawText),
}
/// A text run in device units: the reference point, the font height along the text's up direction,
/// the baseline angle (radians, clockwise in y-down space) and per-character advances.
pub(crate) struct RawText {
pub text: String,
pub origin: P,
pub size: f64,
pub cell: bool,
pub angle: f64,
pub dx: Vec<f64>,
pub font: String,
pub bold: bool,
pub italic: bool,
pub color: [u8; 4],
pub align: u32,
}
#[derive(Clone)]
@@ -177,6 +212,9 @@ struct Saved {
brush: Brush,
even_odd: bool,
nop: bool,
font: Option<Font>,
text_color: [u8; 4],
text_align: u32,
}
pub(crate) struct Canvas {
@@ -201,6 +239,11 @@ pub(crate) struct Canvas {
even_odd: bool,
/// The mix mode is R2_NOP: shapes draw nothing.
nop: bool,
/// The selected font; None is the default font.
font: Option<Font>,
text_color: [u8; 4],
/// SETTEXTALIGN flags (TA_*).
text_align: u32,
pos: P,
in_path: bool,
path: Vec<Op>,
@@ -224,6 +267,9 @@ impl Canvas {
brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] },
even_odd: true,
nop: false,
font: None,
text_color: [0, 0, 0, 255],
text_align: 0,
pos: (0.0, 0.0),
in_path: false,
path: Vec::new(),
@@ -240,6 +286,7 @@ impl Canvas {
let cost = match &raw {
Raw::Path { ops, .. } => ops.len() * std::mem::size_of::<Op>(),
Raw::Bitmap { img, .. } => img.px.len(),
Raw::Text(t) => t.text.len() + t.font.len() + t.dx.len() * std::mem::size_of::<f64>(),
};
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
self.full = true;
@@ -318,7 +365,16 @@ impl Canvas {
/// could no longer match the file.
pub fn save_dc(&mut self) {
if self.saved.len() < MAX_STACK {
self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop });
self.saved.push(Saved {
map: self.map,
pen: self.pen,
brush: self.brush,
even_odd: self.even_odd,
nop: self.nop,
font: self.font,
text_color: self.text_color,
text_align: self.text_align,
});
} else {
self.full = true;
}
@@ -335,6 +391,9 @@ impl Canvas {
self.brush = s.brush;
self.even_odd = s.even_odd;
self.nop = s.nop;
self.font = s.font;
self.text_color = s.text_color;
self.text_align = s.text_align;
}
/// Stores an object in `slot` (EMF index), or in the lowest free slot (WMF handle table).
@@ -384,6 +443,7 @@ impl Canvas {
match self.objects.get(i as usize).copied().flatten() {
Some(Obj::Pen(p)) => self.pen = p,
Some(Obj::Brush(b)) => self.brush = b,
Some(Obj::Font(f)) => self.font = Some(f),
Some(Obj::Other) | None => {}
}
}
@@ -402,6 +462,8 @@ impl Canvas {
6 => self.pen = Pen { style: 0, width: 0.0, color: white },
7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black },
8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black },
// The stock fonts (OEM, ANSI, system, device default...): the default font.
10..=17 => self.font = None,
_ => {}
}
}
@@ -571,6 +633,57 @@ impl Canvas {
}
}
pub fn set_text_color(&mut self, c: [u8; 4]) {
self.text_color = c;
}
pub fn set_text_align(&mut self, a: u32) {
self.text_align = a;
}
/// Draws `text` at the logical point `at` (the current point with TA_UPDATECP) with the selected
/// font, text colour and alignment. `dx` holds per-character logical advances (empty for the
/// font's own); with TA_UPDATECP the current point moves past them.
pub fn text(&mut self, at: P, text: String, dx: &[f64]) {
if text.is_empty() || self.in_path {
return;
}
let font = self.font.unwrap_or(Font { height: 0, escapement: 0, weight: 400, italic: false, face: [0; 32] });
let at = if self.text_align & TA_UPDATECP != 0 { self.pos } else { at };
if self.text_align & TA_UPDATECP != 0 {
self.pos = (at.0 + dx.iter().sum::<f64>(), at.1);
}
let height = if font.height == 0 { DEFAULT_FONT_HEIGHT } else { f64::from(font.height) };
let origin = self.map.device(at);
let len = |v: P| {
let d = self.map.device((at.0 + v.0, at.1 + v.1));
(d.0 - origin.0).hypot(d.1 - origin.1)
};
let base = self.map.device((at.0 + 1.0, at.1));
let escapement = f64::from(font.escapement) / 10.0;
let angle = (base.1 - origin.1).atan2(base.0 - origin.0) - escapement.to_radians();
let size = len((0.0, height.abs()));
let dx = dx.iter().map(|d| len((*d, 0.0)).copysign(*d)).collect();
let name = String::from_utf16_lossy(&font.face);
let name = name.trim_end_matches('\0').trim();
let raw = RawText {
text,
origin,
size,
cell: height > 0.0,
angle: if angle.is_finite() { angle } else { 0.0 },
dx,
font: if name.is_empty() { DEFAULT_FONT_FACE.to_string() } else { name.to_string() },
bold: font.weight >= 600,
italic: font.italic,
color: self.text_color,
align: self.text_align,
};
if raw.origin.0.is_finite() && raw.origin.1.is_finite() && raw.size.is_finite() && raw.size > 0.0 {
self.push(Raw::Text(raw));
}
}
/// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the
/// bitmap, as the mapping does.
pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) {
+57 -1
View File
@@ -2,7 +2,7 @@
//! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice.
use crate::bytes::Bytes;
use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
use crate::canvas::{BS_NULL, Brush, Canvas, Font, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref};
use crate::dib::{self, Blend};
use crate::geom::{self, ArcKind, P};
use crate::{Error, Frame, MAX_PATH_OPS};
@@ -15,6 +15,9 @@ const SRCCOPY: u32 = 0x00CC_0020;
/// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide.
const PS_TYPE_MASK: u32 = 0x000F_0000;
const PS_COSMETIC: u32 = 0;
/// EXTTEXTOUT options: the string holds glyph indices, and the dx array holds (x, y) pairs.
const ETO_GLYPH_INDEX: u32 = 0x10;
const ETO_PDY: u32 = 0x2000;
pub(crate) fn is_emf(b: Bytes) -> bool {
b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE)
@@ -100,6 +103,10 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
38 => create_pen(cv, r),
95 => create_ext_pen(cv, r),
39 => create_brush(cv, r),
82 => create_font(cv, r),
22 => r.u32(8).map(|a| cv.set_text_align(a)),
24 => r.u32(8).map(|c| cv.set_text_color(colorref(c))),
84 => ext_text_out_w(cv, r),
40 => r.u32(8).map(|i| cv.delete_object(i)),
27 => xy32(r, 8).map(|p| cv.move_to(p)),
54 => xy32(r, 8).map(|p| cv.line_to(p)),
@@ -250,6 +257,55 @@ fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> {
Some(())
}
/// EMR_EXTCREATEFONTINDIRECTW: handle, then a LogFont (height, width, escapement, orientation, weight,
/// italic, ..., face name of 32 UTF-16 units at 40).
fn create_font(cv: &mut Canvas, r: Bytes) -> Option<()> {
let slot = r.u32(8)? as usize;
let mut face = [0u16; 32];
for (i, u) in face.iter_mut().enumerate() {
*u = r.u16(40 + i * 2).unwrap_or(0);
}
let font = Font { height: r.i32(12)?, escapement: r.i32(20)?, weight: r.i32(28)?, italic: *r.0.get(32)? != 0, face };
cv.add_object(Some(slot), Obj::Font(font));
Some(())
}
/// EMR_EXTTEXTOUTW: an EmrText at 36 with the reference point, the character count at 44, the string
/// offset at 48, the options at 52 and the dx array offset at 72 (offsets from the record start).
/// Glyph-index strings are skipped: they cannot be mapped back to characters.
fn ext_text_out_w(cv: &mut Canvas, r: Bytes) -> Option<()> {
let at = xy32(r, 36)?;
let n = r.u32(44)? as usize;
let (off, options, off_dx) = (r.u32(48)? as usize, r.u32(52)?, r.u32(72)? as usize);
if options & ETO_GLYPH_INDEX != 0 || n == 0 {
return None;
}
let units: Vec<u16> = r.slice(off, n.checked_mul(2)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect();
// dx has one entry per UTF-16 unit (two with ETO_PDY); a character takes the sum of its units.
let step = if options & ETO_PDY != 0 { 8 } else { 4 };
let raw_dx: Vec<f64> = match r.slice(off_dx, n.checked_mul(step)?) {
Some(b) if off_dx != 0 => b.chunks_exact(step).filter_map(|c| Some(f64::from(i32::from_le_bytes(c.get(..4)?.try_into().ok()?)))).collect(),
_ => Vec::new(),
};
let mut text = String::new();
let mut dx = Vec::new();
let mut i = 0;
for ch in char::decode_utf16(units.iter().copied()) {
let c = ch.unwrap_or(char::REPLACEMENT_CHARACTER);
// A replacement stands for one unpaired unit.
let w = if c == char::REPLACEMENT_CHARACTER { 1 } else { c.len_utf16() };
if !raw_dx.is_empty() {
dx.push(raw_dx.get(i..i + w).map_or(0.0, |s| s.iter().sum()));
}
i += w;
text.push(c);
}
let text = text.trim_end_matches('\0').to_string();
dx.truncate(text.chars().count());
cv.text(at, text, &dx);
Some(())
}
/// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at
/// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead.
fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> {
+78 -3
View File
@@ -1,5 +1,5 @@
//! WordCraft metafiles: reads Windows Metafiles (WMF) and Enhanced Metafiles (EMF) into a neutral
//! vector model of filled and stroked paths and RGBA bitmaps, in a picture frame with y down.
//! vector model of filled and stroked paths, RGBA bitmaps and text runs, in a picture frame with y down.
//!
//! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is
//! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`],
@@ -21,9 +21,9 @@ mod wmf;
mod tests;
use bytes::Bytes;
use canvas::Raw;
use canvas::{Raw, RawText};
use geom::{Op, P};
pub use place::{MAX_BITMAP_SIDE, PlacedItem};
pub use place::{MAX_BITMAP_SIDE, PlacedItem, TextLayout};
/// Items kept from one file. Past the cap the parse stops and keeps what it has.
pub(crate) const MAX_ITEMS: usize = 200_000;
@@ -60,6 +60,46 @@ pub enum Item {
},
/// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down.
Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec<u8> },
/// A run of text; the renderers shape it with the named font (see [`Text::layout`]).
Text(Text),
}
/// A text run, in picture units.
#[derive(Debug, Clone, PartialEq)]
pub struct Text {
pub text: String,
/// The reference point that `halign` and `valign` refer to.
pub x: f32,
pub y: f32,
/// Font height: the em size, or the cell height (ascent + descent) when `cell` is set.
pub size: f32,
pub cell: bool,
pub font: String,
pub bold: bool,
pub italic: bool,
pub color: [u8; 4],
/// Baseline direction in radians, clockwise from +x (y down).
pub angle: f32,
pub halign: HAlign,
pub valign: VAlign,
/// Advance of each character along the baseline; empty when the font's own advances apply.
pub dx: Vec<f32>,
}
/// Where the reference point sits along the run.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HAlign {
Left,
Center,
Right,
}
/// Where the reference point sits across the run.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VAlign {
Top,
Baseline,
Bottom,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -137,11 +177,45 @@ fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?];
Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px })
}
Raw::Text(t) => text_item(t, tf, sx, sy),
});
let items = items.collect();
Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items }
}
/// A device-space text run mapped into the picture: the reference point through `tf`, lengths by the
/// frame scale. TA_* flags become the alignment.
fn text_item(t: RawText, tf: impl Fn(P) -> P, sx: f64, sy: f64) -> Option<Item> {
let (x, y) = tf(t.origin);
let halign = match t.align & 6 {
2 => HAlign::Right,
6 => HAlign::Center,
_ => HAlign::Left,
};
let valign = match t.align & 24 {
8 => VAlign::Bottom,
24 => VAlign::Baseline,
_ => VAlign::Top,
};
let dx = t.dx.iter().map(|d| f(d * sx.abs())).collect::<Option<Vec<f32>>>()?;
let size = f(t.size * sy.abs()).filter(|s| *s > 0.0)?;
Some(Item::Text(Text {
text: t.text,
x: f(x)?,
y: f(y)?,
size,
cell: t.cell,
font: t.font,
bold: t.bold,
italic: t.italic,
color: t.color,
angle: f(t.angle).unwrap_or(0.0),
halign,
valign,
dx,
}))
}
fn f(v: f64) -> Option<f32> {
let v = v as f32;
v.is_finite().then_some(v)
@@ -201,6 +275,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
grow((rect[0], rect[1]));
grow((rect[0] + rect[2], rect[1] + rect[3]));
}
Raw::Text(t) => grow(t.origin),
}
}
b
+75 -1
View File
@@ -3,7 +3,7 @@
use kurbo::{Affine, BezPath, Rect, Shape};
use crate::{Item, Picture, Seg};
use crate::{HAlign, Item, Picture, Seg, Text, VAlign};
/// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides.
pub const MAX_BITMAP_SIDE: u32 = 30_000;
@@ -19,6 +19,72 @@ pub enum PlacedItem {
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
Bitmap { index: usize, rect: Rect },
/// `Picture::items[index]`, a text run. `tf` maps the run's own space (picture units, the
/// reference point at the origin, the baseline along +x, y down) to the target.
Text { index: usize, tf: Affine },
}
/// A text run laid out with a font's metrics: the em size (in the run's own units, see
/// [`PlacedItem::Text`]), the baseline's y and each glyph's (x, y), alignment included.
pub struct TextLayout {
pub em: f64,
pub baseline: f64,
pub glyphs: Vec<(f64, f64)>,
}
impl Text {
/// Lays out shaped glyphs, given as (cluster byte offset, x advance, x offset, y offset) in font
/// units of a font with `upem` units per em, `ascent` above and `descent` below the baseline.
/// The per-character advances `dx` place each cluster when there is one per character.
pub fn layout(&self, upem: f64, ascent: f64, descent: f64, glyphs: &[(usize, f64, f64, f64)]) -> Option<TextLayout> {
let upem = if upem.is_finite() && upem > 0.0 { upem } else { 1000.0 };
let cell = ascent + descent;
let size = f64::from(self.size);
let em = if self.cell && cell.is_finite() && cell > 0.0 { size * upem / cell } else { size };
if !em.is_finite() || em <= 0.0 {
return None;
}
let k = em / upem;
let starts: Vec<usize> = self.text.char_indices().map(|(i, _)| i).collect();
let dx = (self.dx.len() == starts.len() && !starts.is_empty()).then(|| {
let mut cum = Vec::with_capacity(self.dx.len() + 1);
let mut x = 0.0;
cum.push(x);
for d in &self.dx {
x += f64::from(*d);
cum.push(x);
}
cum
});
let mut out = Vec::with_capacity(glyphs.len());
let (mut pen, mut cluster_pen, mut last) = (0.0, 0.0, None);
for &(cluster, adv, xo, yo) in glyphs {
let ci = starts.partition_point(|&b| b <= cluster).saturating_sub(1);
if last != Some(ci) {
cluster_pen = pen;
last = Some(ci);
}
let x = match &dx {
Some(cum) => cum.get(ci).copied().unwrap_or(0.0) + pen - cluster_pen,
None => pen,
};
out.push((x + xo * k, -yo * k));
pen += adv * k;
}
let width = dx.as_ref().and_then(|c| c.last().copied()).unwrap_or(pen);
let hoff = match self.halign {
HAlign::Left => 0.0,
HAlign::Center => -width / 2.0,
HAlign::Right => -width,
};
let baseline = match self.valign {
VAlign::Top => ascent * k,
VAlign::Baseline => 0.0,
VAlign::Bottom => -descent * k,
};
let glyphs = out.into_iter().map(|(x, y)| (x + hoff, y + baseline)).collect();
Some(TextLayout { em, baseline, glyphs })
}
}
impl Picture {
@@ -63,6 +129,14 @@ impl Picture {
out.push(PlacedItem::Bitmap { index, rect });
}
}
Item::Text(t) => {
let (x, y) = (f64::from(t.x), f64::from(t.y));
let at = tf * kurbo::Point::new(x, y);
if in_range(Rect::new(at.x, at.y, at.x, at.y)) {
let tf = tf * Affine::translate((x, y)) * Affine::rotate(f64::from(t.angle));
out.push(PlacedItem::Text { index, tf });
}
}
}
}
out
+1 -1
View File
@@ -120,7 +120,7 @@ const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255
fn path_segs(item: &Item) -> (&Vec<Seg>, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) {
match item {
Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd),
Item::Bitmap { .. } => panic!("expected a path"),
Item::Bitmap { .. } | Item::Text(_) => panic!("expected a path"),
}
}
+61 -3
View File
@@ -1267,7 +1267,7 @@ impl Exporter<'_> {
fn picture(&mut self, s: &mut Surface, rect: &Rect, media: &str, crop: &[f32; 4], alpha: f32) {
let Some(clip) = rect_path(rect) else { return };
if let Some(v) = self.vector(media) {
vector_picture(s, &v, rect, crop, &clip, alpha);
vector_picture(self, s, &v, rect, crop, &clip, alpha);
return;
}
let Some(img) = self.image(media) else {
@@ -1606,6 +1606,58 @@ impl Exporter<'_> {
Some(o)
}
/// Draws a metafile text run as PDF text (outlines when the font cannot be embedded). `tf` maps
/// the run's own space (see [`PlacedItem::Text`]) to the page.
fn vector_text(&mut self, s: &mut Surface, t: &wordcraft_metafile::Text, tf: kurbo::Affine) {
let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face;
let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c);
let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect();
let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return };
let font = self.font(&face);
if font.is_none() && !self.outlined.contains(&face.id()) {
return;
}
let em = lay.em as f32;
let c = tf.as_coeffs().map(|v| v as f32);
if !c.iter().all(|v| v.is_finite()) || !em.is_finite() {
return;
}
let (paint, opacity) = paint_of(t.color);
s.push_transform(&Transform::from_row(c[0], c[1], c[2], c[3], c[4], c[5]));
s.set_stroke(None);
s.set_fill(Some(Fill { paint: paint.into(), opacity, rule: FillRule::NonZero }));
match font {
Some(font) => {
let upem = face.upem.max(1.0) as f32;
let kg: Vec<KrillaGlyph> = shaped
.iter()
.enumerate()
.map(|(i, g)| {
let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|cl| *cl > g.cluster).unwrap_or(t.text.len());
let start = g.cluster.min(end);
let x = lay.glyphs.get(i).map_or(0.0, |p| p.0);
let adv = match lay.glyphs.get(i + 1) {
Some(n) => ((n.0 - x) / lay.em) as f32,
None => g.x_advance as f32 / upem,
};
let dy = lay.glyphs.get(i).map_or(0.0, |p| ((lay.baseline - p.1) / lay.em) as f32);
KrillaGlyph::new(GlyphId::new(g.gid), adv, 0.0, dy, 0.0, start..end, None)
})
.collect();
let x0 = lay.glyphs.first().map_or(0.0, |p| p.0) as f32;
s.draw_glyphs(Point::from_xy(x0, lay.baseline as f32), &kg, font, &t.text, em, false);
}
None => {
let pen: Vec<(u32, f32, f32)> = shaped.iter().zip(&lay.glyphs).map(|(g, (x, y))| (g.gid, *x as f32, *y as f32)).collect();
if let Some(path) = glyph_outlines(&face, em, &pen) {
s.draw_path(&path);
}
}
}
s.set_fill(None);
s.pop();
}
fn watermark(&mut self, s: &mut Surface, wm: &wordcraft_doc::Watermark, w: f32, h: f32) {
let r = wordcraft_fonts::word::resolve(&wm.font, false, false);
let face = r.face;
@@ -1698,8 +1750,9 @@ const HAIRLINE: f64 = 0.25;
/// Draws a metafile picture into the clip (the picture frame): paths are filled and stroked in page
/// space (strokes keep their width under non-uniform scaling), bitmaps stretched, all faded by `alpha`.
/// Every path sets its fill and stroke, so none carries over; strokes have round caps and joins.
fn vector_picture(s: &mut Surface, v: &Vector, r: &Rect, crop: &[f32; 4], clip: &Path, alpha: f32) {
/// Every path sets its fill and stroke, so none carries over; strokes have round caps and joins. Text
/// runs are drawn as text.
fn vector_picture(ex: &mut Exporter, s: &mut Surface, v: &Vector, r: &Rect, crop: &[f32; 4], clip: &Path, alpha: f32) {
let target = kurbo::Rect::new(f64::from(r.x), f64::from(r.y), f64::from(r.x + r.w), f64::from(r.y + r.h));
let fade = alpha < 0.999;
if fade {
@@ -1737,6 +1790,11 @@ fn vector_picture(s: &mut Surface, v: &Vector, r: &Rect, crop: &[f32; 4], clip:
s.draw_image(img.clone(), size);
s.pop();
}
PlacedItem::Text { index, tf } => {
if let Some(wordcraft_metafile::Item::Text(t)) = v.pic.items.get(index) {
ex.vector_text(s, t, tf);
}
}
}
}
s.set_fill(None);
+38
View File
@@ -1077,6 +1077,44 @@ fn cosmetic_metafile_pens_are_thin_but_visible() {
assert!(raw.contains("0.25 w"), "the hairline is a quarter point wide");
}
/// EMF text records: EXTCREATEFONTINDIRECTW (Arial, 20 units high) in slot 1, SELECTOBJECT,
/// SETTEXTCOLOR (black), then EXTTEXTOUTW `text` at (5, 40) with no dx array.
fn emf_text_records(text: &str) -> Vec<Vec<u8>> {
let mut font = [82i32.to_le_bytes(), 104u32.to_le_bytes(), 1i32.to_le_bytes(), (-20i32).to_le_bytes()].concat();
font.extend([0i32, 0, 0, 400, 0, 0].iter().flat_map(|v| v.to_le_bytes()));
let mut name: Vec<u8> = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
name.resize(64, 0);
font.extend(name);
let units: Vec<u8> = text.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
let padded = units.len().div_ceil(4) * 4;
let mut out = [84u32.to_le_bytes(), ((76 + padded) as u32).to_le_bytes()].concat();
out.extend([0i32, 0, 100, 100, 1, 0, 0, 5, 40, text.encode_utf16().count() as i32, 76, 0, 0, 0, 0, 0, 0].iter().flat_map(|v| v.to_le_bytes()));
out.extend(units);
out.resize(76 + padded, 0);
vec![font, emf_rec(37, &[1]), emf_rec(24, &[0]), out]
}
/// #1764: the text of an EMF picture (EXTTEXTOUTW) is drawn, so it is in the PDF's text layer.
#[test]
fn metafile_text_records_are_drawn_as_text() {
let mut recs = emf_text_records("Cell42");
recs.push(emf_rec(14, &[0; 3]));
let mut d = Document::new();
let media = d.add_media(emf_file(&recs), "emf");
let mut p = Paragraph::with_text("Figure: ", CharProps::default());
let n = p.len();
p.insert_object(
n,
InlineObject::Image { media, w: 70.0, h: 70.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None },
&CharProps::default(),
)
.unwrap();
d.body = vec![para_block(p)];
let bytes = export(&d, &PdfOptions { compress: false, tagged: false, ..Default::default() }).unwrap();
let text = extract_text(&bytes).concat();
assert!(text.contains("Cell42"), "the metafile's text is drawn: {text:?}");
}
/// #455: a link to a bookmark after a page break inside its paragraph goes to the page the
/// bookmark is on, not the paragraph's first page.
#[test]
+69
View File
@@ -283,6 +283,7 @@ impl Vector {
.map(|it| match it {
Item::Path { segs, .. } => segs.len() * std::mem::size_of::<Seg>(),
Item::Bitmap { .. } => 0,
Item::Text(t) => t.text.len() + t.dx.len() * 4,
})
.sum();
px + segs
@@ -361,6 +362,12 @@ fn draw_vector(ctx: &mut RenderContext, v: &Vector, r: &kurbo::Rect, crop: &[f32
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
PlacedItem::Text { index, tf } => {
if let Some(Item::Text(t)) = v.pic.items.get(index) {
draw_vector_text(ctx, t, view * tf);
}
ctx.set_transform(view);
}
}
}
ctx.set_fill_rule(peniko::Fill::NonZero);
@@ -370,6 +377,22 @@ fn draw_vector(ctx: &mut RenderContext, v: &Vector, r: &kurbo::Rect, crop: &[f32
}
}
/// Draws a metafile text run: `tf` maps the run's own space (see [`PlacedItem::Text`]) to the device.
fn draw_vector_text(ctx: &mut RenderContext, t: &wordcraft_metafile::Text, tf: Affine) {
let face = wordcraft_fonts::word::resolve(&t.font, t.bold, t.italic).face;
let shaped = wordcraft_fonts::shape(&face, &t.text, &[], |c| c);
let glyphs: Vec<_> = shaped.iter().map(|g| (g.cluster, f64::from(g.x_advance), f64::from(g.x_offset), f64::from(g.y_offset))).collect();
let Some(lay) = t.layout(face.upem, face.ascent, face.descent, &glyphs) else { return };
let k = lay.em / face.upem.max(1.0);
ctx.set_paint(rgba_color(t.color));
let db = FontDb::global();
for (g, (x, y)) in shaped.iter().zip(&lay.glyphs) {
let o = db.outline(&face, g.gid);
ctx.set_transform(tf * Affine::translate((*x, *y)) * Affine::scale(k));
ctx.fill_path(&o);
}
}
/// One device pixel in page points under `view` (its mean axis scale); 1 for a degenerate transform.
fn device_pixel(view: Affine) -> f64 {
let c = view.as_coeffs();
@@ -1924,6 +1947,52 @@ mod tests {
assert!(darkest < 140, "the hairline should be clearly visible: {darkest}");
}
/// #1764: an EMF picture's text (EXTCREATEFONTINDIRECTW, SELECTOBJECT, SETTEXTCOLOR, EXTTEXTOUTW)
/// is painted, not dropped.
#[test]
fn metafile_text_records_are_painted() {
let mut font = emf_rec(82, &le32(&[1, -20, 0, 0, 0, 700, 0, 0]));
let mut name: Vec<u8> = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
name.resize(64, 0);
font.extend(name);
font[4..8].copy_from_slice(&104u32.to_le_bytes());
// "WWWW" at (5, 40), top-aligned, no dx array.
let mut text = emf_rec(84, &le32(&[0, 0, 100, 100, 1, 0, 0, 5, 40, 4, 76, 0, 0, 0, 0, 0, 0]));
text.extend("WWWW".encode_utf16().flat_map(|u| u.to_le_bytes()));
text[4..8].copy_from_slice(&84u32.to_le_bytes());
let mut emf = red_right_half_emf()[..88].to_vec();
emf.extend([font, emf_rec(37, &le32(&[1])), emf_rec(24, &le32(&[0])), text, emf_rec(14, &[0; 12])].concat());
let mut d = Document::new();
let media = d.add_media(emf, "emf");
let mut p = wordcraft_doc::Paragraph::new();
let obj = wordcraft_doc::para::InlineObject::Image {
media,
w: 70.0,
h: 70.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
};
p.insert_object(0, obj, &Default::default()).unwrap();
d.body = vec![wordcraft_doc::para_block(p)];
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let opts = RenderOptions::default();
let rect = page_display(&d, &l.pages[0], &opts.display)
.iter()
.find_map(|i| match i {
Draw::Image { rect, .. } => Some(*rect),
_ => None,
})
.expect("a picture on the page");
let scale = 2.0;
let img = render_page(&d, &l.pages[0], scale, &opts);
let (x0, y0) = ((rect.x * scale) as u32, (rect.y * scale) as u32);
let (x1, y1) = (((rect.x + rect.w) * scale) as u32, ((rect.y + rect.h) * scale) as u32);
let dark = (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 100).count();
assert!(dark > 50, "the text is painted inside the picture: {dark} dark pixels");
}
#[test]
fn unreadable_metafile_keeps_the_grey_box() {
let mut d = Document::new();