diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 0ac4790f6..03a8f3453 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -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, 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, + 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, + 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, + text_color: [u8; 4], + /// SETTEXTALIGN flags (TA_*). + text_align: u32, pos: P, in_path: bool, path: Vec, @@ -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::(), Raw::Bitmap { img, .. } => img.px.len(), + Raw::Text(t) => t.text.len() + t.font.len() + t.dx.len() * std::mem::size_of::(), }; 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::(), 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) { diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 904d126b7..4ffe9cd1b 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -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 = 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 = 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<()> { diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index cc2f64e5c..473d69baf 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -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 }, + /// 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, +} + +/// 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, frame: Option) -> 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 { + 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::>>()?; + 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 { 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 diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index 68f079553..73ac10cc3 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -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 { + 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 = 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 diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 8a93e1411..e6ffc8c34 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -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, 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"), } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 1d44554bb..098964694 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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 = 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); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 5be10b2da..29b5aa6e3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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> { + 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 = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + name.resize(64, 0); + font.extend(name); + let units: Vec = 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] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a5b450f04..4991ad574 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -283,6 +283,7 @@ impl Vector { .map(|it| match it { Item::Path { segs, .. } => segs.len() * std::mem::size_of::(), 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 = "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();