use kurbo::{Affine, BezPath, Vec2};
use crate::options::{RenderOptions, TextAa};
use pdfrum_font::{CharItem, Font, GlyphCache, GlyphKey, cid_transform_to_float};
use pdfrum_page::{TextObject, TextRenderMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TextPaintKinds {
pub fill: bool,
pub stroke: bool,
pub clip: bool,
}
#[must_use]
pub fn paint_kinds(mode: TextRenderMode, has_face: bool) -> Option<TextPaintKinds> {
let none = TextPaintKinds {
fill: false,
stroke: false,
clip: false,
};
match mode {
TextRenderMode::Invisible => None,
TextRenderMode::Clip => Some(TextPaintKinds { clip: true, ..none }),
TextRenderMode::Fill => Some(TextPaintKinds { fill: true, ..none }),
TextRenderMode::FillClip => Some(TextPaintKinds {
fill: true,
clip: true,
..none
}),
TextRenderMode::Stroke => Some(if has_face {
TextPaintKinds {
stroke: true,
..none
}
} else {
TextPaintKinds { fill: true, ..none }
}),
TextRenderMode::StrokeClip => Some(if has_face {
TextPaintKinds {
stroke: true,
clip: true,
..none
}
} else {
TextPaintKinds {
fill: true,
clip: true,
..none
}
}),
TextRenderMode::FillStroke => Some(TextPaintKinds {
fill: true,
stroke: has_face,
..none
}),
TextRenderMode::FillStrokeClip => Some(TextPaintKinds {
fill: true,
stroke: has_face,
clip: true,
}),
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BitmapPlacement {
pub origin: kurbo::Point,
pub phase: crate::glyph::SubpixelPhase,
}
#[derive(Debug, Clone)]
pub struct PlacedGlyph {
pub outline: std::sync::Arc<BezPath>,
pub matrix: Affine,
pub key: GlyphKey,
pub bitmap: Option<BitmapPlacement>,
}
impl PlacedGlyph {
#[must_use]
pub fn device_path(&self) -> BezPath {
self.matrix * (*self.outline).clone()
}
}
#[must_use]
pub fn glyph_matrix(font_size: f32, pen: kurbo::Point, text_to_device: Affine) -> Affine {
let s = f64::from(font_size) / 1000.0;
text_to_device * Affine::translate((pen.x, pen.y)) * Affine::scale(s)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GlyphAdjust {
pub origin: Vec2,
pub matrix: Affine,
}
impl GlyphAdjust {
pub const NONE: Self = Self {
origin: Vec2::new(0.0, 0.0),
matrix: Affine::IDENTITY,
};
}
impl Default for GlyphAdjust {
fn default() -> Self {
Self::NONE
}
}
#[must_use]
pub fn japan1_adjust(font: &Font, item: &CharItem, font_size: f32) -> GlyphAdjust {
if item.vertical_glyph {
return GlyphAdjust::NONE;
}
let Some(t) = font.japan1_transform(item.code) else {
return GlyphAdjust::NONE;
};
let f = |b: u8| f64::from(cid_transform_to_float(b));
GlyphAdjust {
origin: Vec2::new(f(t.e) * f64::from(font_size), f(t.f) * f64::from(font_size)),
matrix: Affine::new([f(t.a), f(t.b), f(t.c), f(t.d), 0.0, 0.0]),
}
}
#[must_use]
pub fn glyph_spacing_adjust(declared: i32, face: i32, font_size: f32) -> GlyphAdjust {
if face != 0 && declared > face.saturating_add(1) {
return GlyphAdjust {
origin: Vec2::new(
f64::from(declared.saturating_sub(face)) * f64::from(font_size) / 2000.0,
0.0,
),
matrix: Affine::IDENTITY,
};
}
if declared != 0 && face != 0 && declared < face {
return GlyphAdjust {
origin: Vec2::ZERO,
matrix: Affine::scale_non_uniform(f64::from(declared) / f64::from(face), 1.0),
};
}
GlyphAdjust::NONE
}
pub const BITMAP_PATH_MAX_EM: f64 = 50.0;
#[must_use]
pub fn takes_bitmap_path(font_size: f32, text_to_device: Affine) -> bool {
let [a, b, ..] = text_to_device.as_coeffs();
let size = f64::from(font_size);
(a * size).abs() + (b * size).abs() <= BITMAP_PATH_MAX_EM
}
#[must_use]
pub fn snap_origin(origin: kurbo::Point, text_aa: TextAa) -> kurbo::Point {
let x = match text_aa {
TextAa::Grayscale | TextAa::LcdSubpixel => {
let whole = origin.x.floor();
#[expect(
clippy::cast_possible_truncation,
reason = "the C++ is `static_cast<int>(x * 3) % 3`; a device \
origin beyond i32 has already been clamped by the \
±32000 coordinate rule"
)]
let subpixel = f64::from((origin.x * 3.0) as i32 % 3);
whole + subpixel / 3.0
}
TextAa::None => origin.x.round(),
};
kurbo::Point::new(x, origin.y.round())
}
#[expect(
clippy::float_cmp,
reason = "the C++ compares snapped origins, which are whole pixels there \
and exact integers in this f64 after `snap_origin` — an epsilon \
would admit a run the oracle rejects as non-axis-aligned"
)]
pub fn adjust_glyph_space(origins: &mut [kurbo::Point], device: &[kurbo::Point]) {
debug_assert_eq!(origins.len(), device.len());
let (Some(first), Some(last)) = (origins.first().copied(), origins.last().copied()) else {
return;
};
if origins.len() <= 1 {
return;
}
let vertical = last.x == first.x;
if !vertical && last.y != first.y {
return;
}
let axis = |p: kurbo::Point| if vertical { p.y } else { p.x };
for i in (2..origins.len()).rev() {
let (Some(next_origin), Some(next_f)) = (origins.get(i), device.get(i)) else {
continue;
};
let (Some(cur_origin), Some(cur_f)) = (origins.get(i - 1), device.get(i - 1)) else {
continue;
};
let space = axis(*next_origin) - axis(*cur_origin);
let space_f = axis(*next_f) - axis(*cur_f);
if space_f.abs() - space.abs() <= 0.5 {
continue;
}
let nudge = if space > 0.0 { -1.0 } else { 1.0 };
if let Some(target) = origins.get_mut(i - 1) {
if vertical {
target.y += nudge;
} else {
target.x += nudge;
}
}
}
}
#[must_use]
#[expect(
clippy::float_cmp,
reason = "the exact `a == 1 && d == 1` is upstream's unit-scale short \
circuit; with a tolerance a slightly-off-unit CTM would skip \
the split and stroke at the wrong width, which is the whole \
point of the function"
)]
pub fn stroke_ctm_split(text_matrix: Affine, to_device: Affine, ctm: [f64; 4]) -> (Affine, Affine) {
let [a, b, c, d] = ctm;
if a == 1.0 && d == 1.0 {
return (text_matrix, to_device);
}
let scale = Affine::new([a, b, c, d, 0.0, 0.0]);
let det = scale.determinant();
if det == 0.0 || !det.is_finite() {
return (text_matrix, to_device);
}
(scale.inverse() * text_matrix, to_device * scale)
}
#[must_use]
pub(crate) fn stroke_text_matrices(
object: &TextObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
) -> (Affine, Affine) {
let [a, b, c, d] = state.text.stroke_ctm;
stroke_ctm_split(
object.matrix,
to_device,
[f64::from(a), f64::from(b), f64::from(c), f64::from(d)],
)
}
#[must_use]
pub fn place_glyphs(
object: &TextObject,
state: &pdfrum_page::GraphicsState,
cache: &mut GlyphCache,
to_device: Affine,
opts: &RenderOptions,
kinds: TextPaintKinds,
) -> Vec<PlacedGlyph> {
let mut out = Vec::new();
place_glyphs_into(&mut out, object, state, cache, to_device, opts, kinds);
out
}
struct Placement {
pen: kurbo::Point,
size: f32,
text_to_device: Affine,
vertical: bool,
spacing: bool,
}
fn place_one_glyph(
outline: std::sync::Arc<BezPath>,
key: GlyphKey,
font: &Font,
item: &CharItem,
gid: pdfrum_font::Gid,
at: &Placement,
) -> PlacedGlyph {
let japan1 = japan1_adjust(font, item, at.size);
let vert_origin = if at.vertical {
let (vx, vy) = font.vert_origin(item.code).unwrap_or((0.0, 880.0));
let scale = f64::from(at.size) / 1000.0;
Vec2::new(-f64::from(vx) * scale, -f64::from(vy) * scale)
} else {
Vec2::ZERO
};
let space = if at.spacing {
#[expect(
clippy::cast_possible_truncation,
reason = "a /Widths entry is a small integer on the C++ side too; \
the f32 is this crate's own carrier"
)]
let declared = item.width as i32;
glyph_spacing_adjust(declared, font.glyph_advance(gid), at.size)
} else {
GlyphAdjust::NONE
};
PlacedGlyph {
outline,
matrix: glyph_matrix(
at.size,
at.pen + japan1.origin + space.origin + vert_origin,
at.text_to_device,
) * japan1.matrix
* space.matrix,
key,
bitmap: None,
}
}
pub fn place_glyphs_into(
out: &mut Vec<PlacedGlyph>,
object: &TextObject,
state: &pdfrum_page::GraphicsState,
cache: &mut GlyphCache,
to_device: Affine,
opts: &RenderOptions,
kinds: TextPaintKinds,
) {
let arrived_with = out.capacity();
out.clear();
let Some((font, size)) = &object.font else {
return;
};
let text_to_device = if kinds.stroke {
stroke_text_matrices(object, state, to_device).0
} else {
to_device * object.matrix
};
let det = object.matrix.determinant();
if det == 0.0 || !det.is_finite() {
return;
}
let mut pen = object.matrix.inverse() * object.position;
let vertical = font.is_vertical();
let subst_weight = font.subst().map_or(0, pdfrum_font::SubstFont::raw_weight);
let subst_italic = font.subst().map_or(0, |s| s.italic_angle);
let widths_drive_the_design = width_drives_the_design_space(font);
let spacing = font.applies_glyph_spacing();
for segment in &object.segments {
for item in font.decode(&segment.codes) {
let advance = f64::from(item.width) / 1000.0 * f64::from(*size)
+ f64::from(state.text.char_space);
let word = if item.code.0 == 0x20 && item.cid.is_none() {
f64::from(state.text.word_space)
} else {
0.0
};
if let Some(gid) = item.gid {
let key = GlyphKey {
font: font.id(),
gid,
dest_width: if widths_drive_the_design {
#[expect(
clippy::cast_possible_truncation,
reason = "`GetCharWidth` is an int on the C++ side \
and a /Widths entry is a small number; \
the f32 is this crate's own carrier"
)]
{
item.width as i32
}
} else {
0
},
weight: subst_weight,
italic_angle: subst_italic,
vertical: item.vertical_glyph,
};
if let Some(outline) = cache.shared(font, key) {
out.push(place_one_glyph(
outline,
key,
font,
&item,
gid,
&Placement {
pen,
size: *size,
text_to_device,
vertical,
spacing,
},
));
}
}
if vertical {
pen.y += advance + word;
} else {
pen.x += advance + word;
}
}
let kern = f64::from(segment.kerning) / 1000.0 * f64::from(*size);
if vertical {
pen.y -= kern;
} else {
pen.x -= kern;
}
}
if snaps_origins(opts, kinds, *size, text_to_device) {
snap_run(out, opts.text_aa);
}
if out.capacity() > arrived_with {
crate::walkprofile::alloc_items(
crate::walkprofile::Site::GlyphVec,
out.capacity(),
core::mem::size_of::<PlacedGlyph>(),
);
}
}
#[must_use]
pub fn width_drives_the_design_space(font: &Font) -> bool {
!font.is_embedded() && !matches!(font, Font::Type0(_))
}
#[must_use]
pub fn snaps_origins(
opts: &RenderOptions,
kinds: TextPaintKinds,
font_size: f32,
text_to_device: Affine,
) -> bool {
!opts.subpixel_text_positioning && !kinds.stroke && takes_bitmap_path(font_size, text_to_device)
}
fn snap_run(glyphs: &mut [PlacedGlyph], text_aa: TextAa) {
if glyphs.is_empty() {
return;
}
let device: Vec<kurbo::Point> = glyphs
.iter()
.map(|g| g.matrix * kurbo::Point::ZERO)
.collect();
let mut snapped: Vec<kurbo::Point> = device.iter().map(|p| snap_origin(*p, text_aa)).collect();
if text_aa == TextAa::None {
adjust_glyph_space(&mut snapped, &device);
}
for ((glyph, from), to) in glyphs.iter_mut().zip(&device).zip(&snapped) {
let delta = Vec2::new(to.x - from.x, to.y - from.y);
glyph.matrix = Affine::translate(delta) * glyph.matrix;
glyph.bitmap = bitmap_placement(*from, *to, text_aa);
}
}
fn bitmap_placement(
device: kurbo::Point,
snapped: kurbo::Point,
text_aa: TextAa,
) -> Option<BitmapPlacement> {
if text_aa != TextAa::Grayscale {
return None;
}
Some(BitmapPlacement {
origin: kurbo::Point::new(snapped.x.floor(), snapped.y),
phase: crate::glyph::SubpixelPhase::of(device.x),
})
}
#[must_use]
pub fn has_face(font: &Font) -> bool {
!matches!(font, Font::Type3(_))
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlacedType3Char {
pub code: u32,
pub matrix: Affine,
}
#[must_use]
pub fn place_type3_chars(
object: &TextObject,
state: &pdfrum_page::GraphicsState,
to_device: Affine,
) -> Vec<PlacedType3Char> {
let Some((font, size)) = &object.font else {
return Vec::new();
};
let Some(type3) = font.type3() else {
return Vec::new();
};
let text_to_device = to_device * object.matrix;
let det = object.matrix.determinant();
if det == 0.0 || !det.is_finite() {
return Vec::new();
}
let char_matrix = type3.font_matrix * Affine::scale(f64::from(*size));
let mut pen = object.matrix.inverse() * object.position;
let mut out = Vec::new();
for segment in &object.segments {
for item in font.decode(&segment.codes) {
let advance = f64::from(item.width) / 1000.0 * f64::from(*size)
+ f64::from(state.text.char_space);
let word = if item.code.0 == 0x20 && item.cid.is_none() {
f64::from(state.text.word_space)
} else {
0.0
};
out.push(PlacedType3Char {
code: item.code.0,
matrix: text_to_device * Affine::translate((pen.x, pen.y)) * char_matrix,
});
pen.x += advance + word;
}
pen.x -= f64::from(segment.kerning) / 1000.0 * f64::from(*size);
}
out
}
#[must_use]
pub fn run_rect(object: &TextObject, state: &pdfrum_page::GraphicsState) -> Option<kurbo::Rect> {
let (font, size) = object.font.as_ref()?;
let vertical = font.is_vertical();
let (mut min_x, mut max_x) = (f64::MAX, f64::MIN);
let (mut min_y, mut max_y) = (f64::MAX, f64::MIN);
let det = object.matrix.determinant();
if det == 0.0 || !det.is_finite() {
return None;
}
let start = object.matrix.inverse() * object.position;
let mut pen = start.x;
let size = f64::from(*size);
for segment in &object.segments {
for item in font.decode(&segment.codes) {
let bbox = font.char_bbox(item.code);
if vertical {
let (ox, oy) = font.vert_origin(item.code).unwrap_or((0.0, 880.0));
let (left, right) = (bbox.x0 - f64::from(ox), bbox.x1 - f64::from(ox));
let (top, bottom) = (bbox.y1 - f64::from(oy), bbox.y0 - f64::from(oy));
min_x = min_x.min(left).min(right);
max_x = max_x.max(left).max(right);
for edge in [pen + top * size / 1000.0, pen + bottom * size / 1000.0] {
min_y = min_y.min(edge);
max_y = max_y.max(edge);
}
} else {
min_y = min_y.min(bbox.y0).min(bbox.y1);
max_y = max_y.max(bbox.y0).max(bbox.y1);
for edge in [pen + bbox.x0 * size / 1000.0, pen + bbox.x1 * size / 1000.0] {
min_x = min_x.min(edge);
max_x = max_x.max(edge);
}
}
pen += f64::from(item.width) / 1000.0 * size;
if item.code.0 == 0x20 && item.cid.is_none() {
pen += f64::from(state.text.word_space);
}
pen += f64::from(state.text.char_space);
}
pen -= f64::from(segment.kerning) / 1000.0 * size;
}
if min_x > max_x || min_y > max_y {
return None;
}
let (min_x, max_x, min_y, max_y) = if vertical {
(
start.x + min_x * size / 1000.0,
start.x + max_x * size / 1000.0,
min_y,
max_y,
)
} else {
(
min_x,
max_x,
start.y + min_y * size / 1000.0,
start.y + max_y * size / 1000.0,
)
};
let rect = kurbo::Rect::new(min_x, min_y, max_x, max_y);
let corners = [
(rect.x0, rect.y0),
(rect.x1, rect.y0),
(rect.x1, rect.y1),
(rect.x0, rect.y1),
]
.map(|(x, y)| object.matrix * kurbo::Point::new(x, y));
let mut out = kurbo::Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in corners {
out.x0 = out.x0.min(p.x);
out.y0 = out.y0.min(p.y);
out.x1 = out.x1.max(p.x);
out.y1 = out.y1.max(p.y);
}
Some(out)
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
clippy::indexing_slicing,
reason = "a snapped origin is an exact value, and a tolerance here \
would let a wrong rounding pass"
)]
use kurbo::Point;
use super::*;
#[test]
fn invisible_paints_nothing() {
assert_eq!(paint_kinds(TextRenderMode::Invisible, true), None);
}
fn run(text: &[u8], x: f64, y: f64) -> TextObject {
let font = std::sync::Arc::new(pdfrum_font::Font::load_standard(
pdfrum_font::StandardFont::Helvetica,
&pdfrum_font::FontCache::default(),
));
TextObject {
segments: Box::new([pdfrum_page::TextSegment {
codes: text.to_vec().into_boxed_slice(),
kerning: 0.0,
}]),
position: Point::new(x, y),
matrix: Affine::IDENTITY,
font: Some((font, 20.0)),
font_source: None,
render_mode: TextRenderMode::Fill,
type3_metrics: std::collections::BTreeMap::new(),
}
}
#[test]
fn a_runs_rect_starts_at_its_origin_and_spans_its_advances() {
let state = pdfrum_page::GraphicsState::default();
let short = run_rect(&run(b"H", 100.0, 50.0), &state).expect("a box");
let long = run_rect(&run(b"HHHH", 100.0, 50.0), &state).expect("a box");
assert!(
(short.x0 - 100.0).abs() < 2.0,
"starts at the origin: {short:?}"
);
assert!(short.y0 > 49.0 && short.y0 < 51.0, "sits on the baseline");
assert!(short.y1 > 60.0, "rises to the cap height: {short:?}");
assert!(
long.width() > short.width() * 3.0,
"four glyphs span four advances: {long:?} vs {short:?}"
);
let moved = run_rect(&run(b"H", 200.0, 50.0), &state).expect("a box");
assert!((moved.x0 - short.x0 - 100.0).abs() < 1e-6);
assert!((moved.width() - short.width()).abs() < 1e-6);
}
#[test]
fn character_spacing_widens_the_rect() {
let plain = pdfrum_page::GraphicsState::default();
let spaced = pdfrum_page::GraphicsState {
text: pdfrum_page::TextState {
char_space: 10.0,
..pdfrum_page::TextState::default()
},
..pdfrum_page::GraphicsState::default()
};
let a = run_rect(&run(b"HH", 0.0, 0.0), &plain).expect("a box");
let b = run_rect(&run(b"HH", 0.0, 0.0), &spaced).expect("a box");
assert!(b.width() > a.width() + 9.0, "{a:?} vs {b:?}");
}
#[test]
fn a_run_with_no_characters_has_no_rect() {
let state = pdfrum_page::GraphicsState::default();
assert!(run_rect(&run(b"", 0.0, 0.0), &state).is_none());
}
#[test]
fn clip_only_mode_paints_nothing_but_clips() {
let k = paint_kinds(TextRenderMode::Clip, true).expect("Tr 7 is not invisible");
assert!(!k.fill && !k.stroke && k.clip);
}
#[test]
fn stroke_without_a_face_falls_back_to_fill() {
let with = paint_kinds(TextRenderMode::Stroke, true).expect("some");
assert!(with.stroke && !with.fill);
let without = paint_kinds(TextRenderMode::Stroke, false).expect("some");
assert!(
without.fill && !without.stroke,
"no outlines means fill instead"
);
}
#[test]
fn fill_stroke_keeps_the_fill_when_there_is_no_face() {
let k = paint_kinds(TextRenderMode::FillStroke, false).expect("some");
assert!(k.fill);
assert!(!k.stroke, "only the stroke half is dropped");
}
#[test]
fn every_clip_mode_contributes_to_the_clip() {
for mode in [
TextRenderMode::FillClip,
TextRenderMode::StrokeClip,
TextRenderMode::FillStrokeClip,
TextRenderMode::Clip,
] {
assert!(paint_kinds(mode, true).is_some_and(|k| k.clip), "{mode:?}");
}
for mode in [
TextRenderMode::Fill,
TextRenderMode::Stroke,
TextRenderMode::FillStroke,
] {
assert!(paint_kinds(mode, true).is_some_and(|k| !k.clip), "{mode:?}");
}
}
#[test]
fn glyph_matrix_scales_by_size_over_1000_without_flipping() {
let m = glyph_matrix(1000.0, Point::ZERO, Affine::IDENTITY);
let p = m * Point::new(0.0, 1000.0);
assert!(
(p.y - 1000.0).abs() < 1e-9,
"y is not flipped here: {}",
p.y
);
assert!((p.x - 0.0).abs() < 1e-9);
let half = glyph_matrix(500.0, Point::ZERO, Affine::IDENTITY);
let p = half * Point::new(1000.0, 0.0);
assert!((p.x - 500.0).abs() < 1e-9, "half size halves the advance");
}
#[test]
fn the_pen_translates_in_text_space_before_the_size_scale() {
let m = glyph_matrix(12.0, Point::new(40.0, 0.0), Affine::IDENTITY);
let origin = m * Point::ZERO;
assert!((origin.x - 40.0).abs() < 1e-9, "origin at {}", origin.x);
}
#[test]
fn stroke_ctm_split_is_identity_at_unit_scale() {
let text = Affine::translate((3.0, 4.0));
let device = Affine::scale(2.0);
let (t, d) = stroke_ctm_split(text, device, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(t, text);
assert_eq!(d, device);
}
#[test]
fn stroke_ctm_split_moves_the_scale_into_the_device_matrix() {
let text = Affine::IDENTITY;
let device = Affine::IDENTITY;
let (t, d) = stroke_ctm_split(text, device, [2.0, 0.0, 0.0, 3.0]);
let composed = d * t;
for (a, b) in composed
.as_coeffs()
.iter()
.zip(Affine::IDENTITY.as_coeffs().iter())
{
assert!((a - b).abs() < 1e-9, "{composed:?}");
}
assert!(
(d.as_coeffs()[0] - 2.0).abs() < 1e-9,
"the x scale moved to the device matrix"
);
}
#[test]
fn stroke_ctm_split_preserves_the_composed_transform_under_a_page_flip() {
let text = Affine::translate((10.0, 20.0));
let device = Affine::translate((0.0, 100.0)) * Affine::scale_non_uniform(1.0, -1.0);
let (t, d) = stroke_ctm_split(text, device, [2.0, 0.0, 0.0, 3.0]);
let original = device * text;
let split = d * t;
for (a, b) in original.as_coeffs().iter().zip(split.as_coeffs().iter()) {
assert!(
(a - b).abs() < 1e-9,
"original {original:?} vs split {split:?}"
);
}
}
#[test]
fn a_stroked_run_under_a_scaled_ctm_measures_width_in_user_space() {
let mut object = run(b"I", 10.0, 10.0);
object.render_mode = TextRenderMode::Stroke;
object.matrix = Affine::scale_non_uniform(2.0, 3.0);
let state = pdfrum_page::GraphicsState {
text: pdfrum_page::TextState {
stroke_ctm: [2.0, 0.0, 0.0, 3.0],
render_mode: TextRenderMode::Stroke,
..pdfrum_page::TextState::default()
},
..pdfrum_page::GraphicsState::default()
};
let kinds = paint_kinds(TextRenderMode::Stroke, true).expect("stroke paints");
let mut cache = pdfrum_font::GlyphCache::default();
let glyphs = place_glyphs(
&object,
&state,
&mut cache,
Affine::IDENTITY,
&RenderOptions::default(),
kinds,
);
assert!(!glyphs.is_empty(), "Helvetica has an I");
let [a, ..] = glyphs[0].matrix.as_coeffs();
assert!(
(a.abs() - 20.0 / 1000.0).abs() < 1e-6,
"the CTM scale is not in the glyph matrix: a={a}"
);
let (_, device_m) = stroke_text_matrices(&object, &state, Affine::IDENTITY);
let width = crate::stroke::device_width(1.0, crate::stroke::split_for_stroke(device_m));
assert!(
(width - 2.0).abs() < 1e-6,
"1 user-space unit scaled by the CTM x, got {width}"
);
}
#[test]
fn the_conformance_snap_quantises_x_to_thirds_and_y_to_whole_pixels() {
let near = |p: Point, x: f64, y: f64| {
assert!(
(p.x - x).abs() < 1e-9 && (p.y - y).abs() < 1e-9,
"{p:?} is not ({x}, {y})"
);
};
near(
snap_origin(Point::new(10.9, 100.4), TextAa::Grayscale),
10.0 + 2.0 / 3.0,
100.0,
);
near(
snap_origin(Point::new(10.1, 100.6), TextAa::Grayscale),
10.0,
101.0,
);
near(
snap_origin(Point::new(10.5, 0.0), TextAa::Grayscale),
10.0 + 1.0 / 3.0,
0.0,
);
near(
snap_origin(Point::new(10.99, 0.0), TextAa::Grayscale),
10.0 + 2.0 / 3.0,
0.0,
);
}
#[test]
fn only_y_is_quantised_to_a_whole_pixel_under_lcd() {
for tenth in 0..10 {
let x = 10.0 + f64::from(tenth) / 10.0;
let p = snap_origin(Point::new(x, 100.4), TextAa::Grayscale);
assert!((p.x - x).abs() <= 1.0 / 3.0, "x moved {} at {x}", p.x - x);
assert_eq!(p.y, 100.0);
}
}
#[test]
fn no_smoothtext_rounds_x_instead_of_flooring_it() {
assert_eq!(
snap_origin(Point::new(10.9, 5.0), TextAa::None),
Point::new(11.0, 5.0)
);
assert_eq!(
snap_origin(Point::new(10.1, 5.0), TextAa::None),
Point::new(10.0, 5.0)
);
assert_eq!(
snap_origin(Point::new(10.5, -2.5), TextAa::None),
Point::new(11.0, -3.0)
);
}
#[test]
fn y_always_rounds_whatever_the_mode_is() {
for aa in [TextAa::Grayscale, TextAa::None] {
assert_eq!(snap_origin(Point::new(0.0, 7.6), aa).y, 8.0, "{aa:?}");
assert_eq!(snap_origin(Point::new(0.0, 7.4), aa).y, 7.0, "{aa:?}");
}
}
#[test]
fn the_bitmap_path_is_a_small_text_rule() {
assert!(takes_bitmap_path(12.0, Affine::IDENTITY));
assert!(
takes_bitmap_path(50.0, Affine::IDENTITY),
"the `> 50` is strict"
);
assert!(!takes_bitmap_path(51.0, Affine::IDENTITY));
assert!(!takes_bitmap_path(12.0, Affine::scale(5.0)));
assert!(!takes_bitmap_path(
40.0,
Affine::rotate(std::f64::consts::FRAC_PI_4)
));
}
#[test]
fn a_stroked_run_does_not_snap_but_a_clipping_one_does() {
let opts = RenderOptions::default();
let fill = TextPaintKinds {
fill: true,
stroke: false,
clip: false,
};
assert!(snaps_origins(&opts, fill, 12.0, Affine::IDENTITY));
assert!(!snaps_origins(
&opts,
TextPaintKinds {
stroke: true,
..fill
},
12.0,
Affine::IDENTITY
));
assert!(snaps_origins(
&opts,
TextPaintKinds { clip: true, ..fill },
12.0,
Affine::IDENTITY
));
assert!(!snaps_origins(&opts, fill, 80.0, Affine::IDENTITY));
}
#[test]
fn the_knob_turns_the_snap_off() {
let fill = TextPaintKinds {
fill: true,
stroke: false,
clip: false,
};
let subpixel = RenderOptions {
subpixel_text_positioning: true,
..RenderOptions::default()
};
assert!(!snaps_origins(&subpixel, fill, 12.0, Affine::IDENTITY));
assert!(snaps_origins(
&RenderOptions::default(),
fill,
12.0,
Affine::IDENTITY
));
}
#[test]
fn adjust_glyph_space_never_moves_the_first_or_last_glyph() {
let device: Vec<Point> = (0..4)
.map(|i| Point::new(f64::from(i) * 9.9, 0.0))
.collect();
let mut origins: Vec<Point> = device
.iter()
.map(|p| snap_origin(*p, TextAa::None))
.collect();
let (first, last) = (origins[0], origins[3]);
adjust_glyph_space(&mut origins, &device);
assert_eq!(origins[0], first);
assert_eq!(origins[3], last);
}
#[test]
fn adjust_glyph_space_closes_a_gap_the_snap_stretched() {
let device: Vec<Point> = (0..4)
.map(|i| Point::new(f64::from(i) * 10.6, 0.0))
.collect();
let mut origins: Vec<Point> = device
.iter()
.map(|p| snap_origin(*p, TextAa::None))
.collect();
assert_eq!(
origins.iter().map(|p| p.x).collect::<Vec<_>>(),
vec![0.0, 11.0, 21.0, 32.0]
);
adjust_glyph_space(&mut origins, &device);
assert_eq!(
origins.iter().map(|p| p.x).collect::<Vec<_>>(),
vec![0.0, 10.0, 21.0, 32.0]
);
}
#[test]
fn adjust_glyph_space_declines_a_run_that_is_not_axis_aligned() {
let device = vec![
Point::new(0.0, 0.0),
Point::new(10.0, 5.0),
Point::new(20.0, 10.0),
];
let mut origins = device.clone();
adjust_glyph_space(&mut origins, &device);
assert_eq!(origins, device, "a diagonal run is left entirely alone");
}
#[test]
fn the_japan1_transform_shifts_the_origin_without_touching_the_advance() {
let t = pdfrum_font::CidTransform {
cid: 7888,
a: 127,
b: 0,
c: 0,
d: 127,
e: 79,
f: 94,
};
let unpack = |b: u8| f64::from(pdfrum_font::cid_transform_to_float(b));
let size = 36.0_f64;
let dx = unpack(t.e) * size;
let dy = unpack(t.f) * size;
assert!((dx - 22.394).abs() < 0.01, "e * font_size is {dx}");
assert!((dy - 26.646).abs() < 0.01, "f * font_size is {dy}");
assert_eq!(unpack(t.a), 1.0);
assert_eq!(unpack(t.b), 0.0);
assert_eq!(unpack(t.c), 0.0);
assert_eq!(unpack(t.d), 1.0);
let origin = Vec2::new(dx, dy);
let a = glyph_matrix(36.0, Point::new(0.0, 0.0) + origin, Affine::IDENTITY);
let b = glyph_matrix(36.0, Point::new(50.0, 0.0) + origin, Affine::IDENTITY);
let step = b.translation() - a.translation();
assert!(
(step.x - 50.0).abs() < 1e-9 && step.y.abs() < 1e-9,
"the advance is unchanged by the adjustment, got {step:?}"
);
}
#[test]
fn a_degenerate_ctm_leaves_the_matrices_alone() {
let (t, d) = stroke_ctm_split(Affine::IDENTITY, Affine::IDENTITY, [0.0, 0.0, 0.0, 0.0]);
assert_eq!(t, Affine::IDENTITY);
assert_eq!(d, Affine::IDENTITY);
}
#[test]
fn a_wider_declared_width_centres_the_glyph_without_stretching_it() {
let a = glyph_spacing_adjust(700, 500, 40.0);
assert_eq!(a.origin, Vec2::new(4.0, 0.0));
assert_eq!(a.matrix, Affine::IDENTITY);
}
#[test]
fn a_declared_width_one_unit_wider_is_left_alone() {
assert_eq!(glyph_spacing_adjust(501, 500, 40.0), GlyphAdjust::NONE);
assert_eq!(glyph_spacing_adjust(500, 500, 40.0), GlyphAdjust::NONE);
assert_ne!(glyph_spacing_adjust(502, 500, 40.0), GlyphAdjust::NONE);
}
#[test]
fn a_narrower_declared_width_squeezes_the_glyph_without_moving_it() {
let a = glyph_spacing_adjust(506, 667, 40.0);
assert_eq!(a.origin, Vec2::ZERO);
let ratio = 506.0 / 667.0;
assert_eq!(a.matrix, Affine::scale_non_uniform(ratio, 1.0));
let p = a.matrix * Point::new(667.0, 700.0);
assert!((p.x - 506.0).abs() < 1e-9, "x is {}", p.x);
assert_eq!(p.y, 700.0);
assert_eq!(a.matrix * Point::ZERO, Point::ZERO);
}
#[test]
fn a_zero_width_on_either_side_declines_the_correction() {
assert_eq!(glyph_spacing_adjust(700, 0, 40.0), GlyphAdjust::NONE);
assert_eq!(glyph_spacing_adjust(0, 0, 40.0), GlyphAdjust::NONE);
assert_eq!(glyph_spacing_adjust(0, 500, 40.0), GlyphAdjust::NONE);
}
#[test]
fn neither_branch_disturbs_the_pen() {
for (declared, face) in [(700, 500), (506, 667)] {
let a = glyph_spacing_adjust(declared, face, 40.0);
let first = glyph_matrix(40.0, Point::ZERO + a.origin, Affine::IDENTITY) * a.matrix;
let second =
glyph_matrix(40.0, Point::new(20.0, 0.0) + a.origin, Affine::IDENTITY) * a.matrix;
let step = second.translation() - first.translation();
assert!(
(step.x - 20.0).abs() < 1e-9 && step.y.abs() < 1e-9,
"declared {declared} face {face} moved the pen by {step:?}"
);
}
}
#[test]
fn the_squeeze_composes_into_a_japan1_transform_s_first_column() {
let japan1 = Affine::new([0.5, 0.25, -0.125, 0.75, 0.0, 0.0]);
let squeeze = glyph_spacing_adjust(500, 1000, 40.0).matrix;
let [a, b, c, d, ..] = (japan1 * squeeze).as_coeffs();
assert_eq!([a, b, c, d], [0.25, 0.125, -0.125, 0.75]);
}
#[test]
fn a_standard_font_run_declines_the_correction() {
let object = run(b"Hi", 0.0, 0.0);
let (font, _) = object.font.as_ref().expect("the run has a font");
assert!(!font.applies_glyph_spacing());
}
}