mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 11:57:36 -06:00
Merge PR #1915: Draw EMF text records (EXTTEXTOUTW) in metafile pictures
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user