use crate::charinfo::{ObjectIndex, transform_rect};
use kurbo::{Affine, Point, Rect};
use pdfrum_font::{CharCode, Font};
use pdfrum_page::{Content, PageObject, TextObject, TextRenderMode};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Item {
pub code: CharCode,
pub origin: Point,
}
#[derive(Debug, Clone)]
pub struct TextRun {
pub index: ObjectIndex,
pub font: Arc<Font>,
pub font_size: f32,
pub font_size_h: f32,
pub char_space: f32,
pub word_space: f32,
pub items: Vec<Item>,
pub kernings: Vec<f32>,
pub position: Point,
pub text_matrix: Affine,
pub rect: Rect,
pub advance: f64,
pub marks: pdfrum_page::ContentMarks,
pub type3: std::collections::BTreeMap<u32, pdfrum_page::Type3Metrics>,
}
impl TextRun {
#[must_use]
pub fn count(&self) -> usize {
self.items.len()
}
#[must_use]
pub fn item(&self, index: usize) -> Option<Item> {
self.items.get(index).copied()
}
#[must_use]
pub fn kerning(&self, index: usize) -> f32 {
self.kernings.get(index).copied().unwrap_or(0.0)
}
#[must_use]
pub fn scaled_char_width(&self, code: CharCode) -> f32 {
let scale = self.font_size / 1000.0;
if self.font.is_vertical()
&& let Some(width) = self.font.vert_width(code)
{
return width * scale;
}
self.glyph_width(code) * scale
}
#[must_use]
pub fn glyph_width(&self, code: CharCode) -> f32 {
let declared = self.font.char_width(code);
if declared != 0.0 || self.font.type3().is_none() {
return declared;
}
self.type3.get(&code.0).map_or(0.0, |m| m.width)
}
#[must_use]
pub fn glyph_bbox(&self, code: CharCode) -> Rect {
if self.font.type3().is_some()
&& let Some(metrics) = self.type3.get(&code.0)
{
return metrics.bbox;
}
self.font.char_bbox(code)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct GlyphWidth(i32);
impl GlyphWidth {
pub const ZERO: Self = Self(0);
#[must_use]
fn truncating(width: f32) -> Self {
if !width.is_finite() {
return Self::ZERO;
}
#[expect(
clippy::cast_possible_truncation,
reason = "the saturating cast is `FX_Number::GetSigned`; a width outside i32 is nonsense"
)]
let truncated = width.trunc() as i32;
Self(truncated)
}
#[must_use]
pub fn as_f64(self) -> f64 {
f64::from(self.0)
}
#[must_use]
fn is_positive(self) -> bool {
self.0 > 0
}
}
#[must_use]
pub fn ladder_char_width(run: &TextRun, code: Option<CharCode>) -> GlyphWidth {
let Some(code) = code else {
return GlyphWidth::ZERO;
};
let font = &run.font;
let width = GlyphWidth::truncating(run.glyph_width(code));
if width.is_positive() {
return width;
}
let mut bytes = Vec::new();
font.append_char(&mut bytes, code);
let width = GlyphWidth::truncating(font.string_width(&bytes));
if width.is_positive() {
return width;
}
let bbox = run.glyph_bbox(code);
#[expect(
clippy::cast_possible_truncation,
reason = "glyph-unit widths are small integers"
)]
let width = (bbox.x1 - bbox.x0) as f32;
GlyphWidth::truncating(width).max(GlyphWidth::ZERO)
}
#[must_use]
pub(crate) fn build(content: &Content<TextObject>, index: ObjectIndex) -> Option<TextRun> {
let object = &content.object;
let (font, font_size) = object.font.as_ref()?;
let state = &content.state;
let mut items: Vec<CharCode> = Vec::new();
let mut kernings: Vec<f32> = Vec::new();
for segment in &object.segments {
let before = items.len();
for item in font.decode(&segment.codes) {
items.push(item.code);
kernings.push(0.0);
}
if items.len() > before
&& let Some(last) = kernings.last_mut()
{
*last = segment.kerning;
}
}
if let Some(last) = kernings.last_mut() {
*last = 0.0;
}
let [a, b, ..] = object.matrix.as_coeffs();
#[expect(
clippy::cast_possible_truncation,
reason = "matrix coefficients are page-space floats"
)]
let font_size_h = (a.hypot(b) as f32 * font_size).abs();
let mut run = TextRun {
index,
font: Arc::clone(font),
font_size: *font_size,
font_size_h,
char_space: state.text.char_space,
word_space: state.text.word_space,
items: Vec::with_capacity(items.len()),
kernings,
position: object.position,
text_matrix: with_translation(object.matrix, object.position),
rect: Rect::ZERO,
advance: 0.0,
marks: content.marks.clone(),
type3: object.type3_metrics.clone(),
};
layout(
&mut run,
&items,
object.render_mode,
state.stroke_params.width,
);
Some(run)
}
fn with_translation(matrix: Affine, position: Point) -> Affine {
let [a, b, c, d, ..] = matrix.as_coeffs();
Affine::new([a, b, c, d, position.x, position.y])
}
fn layout(run: &mut TextRun, codes: &[CharCode], mode: TextRenderMode, line_width: f32) {
let vertical = run.font.is_vertical();
let font_size = run.font_size;
let (mut min_x, mut max_x) = (10000.0f32, -10000.0f32);
let (mut min_y, mut max_y) = (10000.0f32, -10000.0f32);
let mut pen = 0.0f32;
for (index, &code) in codes.iter().enumerate() {
let bbox = run.glyph_bbox(code);
#[expect(
clippy::cast_possible_truncation,
reason = "glyph boxes are 1000/em integers"
)]
let (bl, bb, br, bt) = (
bbox.x0 as f32,
bbox.y0 as f32,
bbox.x1 as f32,
bbox.y1 as f32,
);
let width = if vertical {
let (ox, oy) = run.font.vert_origin(code).unwrap_or((0.0, 880.0));
let (left, right) = (bl - ox, br - ox);
let (top, bottom) = (bt - oy, bb - oy);
min_x = min_x.min(left).min(right);
max_x = max_x.max(left).max(right);
let char_top = pen + top * font_size / 1000.0;
let char_bottom = pen + bottom * font_size / 1000.0;
min_y = min_y.min(char_top).min(char_bottom);
max_y = max_y.max(char_top).max(char_bottom);
run.items.push(Item {
code,
origin: Point::new(
f64::from(-(font_size * ox / 1000.0)),
f64::from(pen - font_size * oy / 1000.0),
),
});
run.font.vert_width(code).unwrap_or(-1000.0) * font_size / 1000.0
} else {
min_y = min_y.min(bt).min(bb);
max_y = max_y.max(bt).max(bb);
let char_left = pen + bl * font_size / 1000.0;
let char_right = pen + br * font_size / 1000.0;
min_x = min_x.min(char_left).min(char_right);
max_x = max_x.max(char_left).max(char_right);
run.items.push(Item {
code,
origin: Point::new(f64::from(pen), 0.0),
});
run.glyph_width(code) * font_size / 1000.0
};
pen += width;
if code.0 == 0x20 && (!vertical || run.font.cid_from_charcode(code).is_none()) {
let mut encoded = Vec::new();
run.font.append_char(&mut encoded, code);
if encoded.len() == 1 {
pen += run.word_space;
}
}
pen += run.char_space;
pen -= run.kerning(index) * font_size / 1000.0;
}
if vertical {
min_x = min_x * font_size / 1000.0;
max_x = max_x * font_size / 1000.0;
} else {
min_y = min_y * font_size / 1000.0;
max_y = max_y * font_size / 1000.0;
}
let original_rect = Rect::new(
f64::from(min_x),
f64::from(min_y),
f64::from(max_x),
f64::from(max_y),
);
let mut rect = transform_rect(run.text_matrix, original_rect);
if matches!(
mode,
TextRenderMode::Stroke
| TextRenderMode::FillStroke
| TextRenderMode::StrokeClip
| TextRenderMode::FillStrokeClip
) {
let half = f64::from(line_width) / 2.0;
rect = Rect::new(
rect.x0 - half,
rect.y0 - half,
rect.x1 + half,
rect.y1 + half,
);
}
run.rect = rect;
let m = run.text_matrix.as_coeffs();
let (dx, dy) = if vertical {
(m[2] * f64::from(pen), m[3] * f64::from(pen))
} else {
(m[0] * f64::from(pen), m[1] * f64::from(pen))
};
run.advance = dx.hypot(dy);
}
pub(crate) const SIZE_EPSILON: f64 = 0.01;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectGate {
Occupies,
ShowsCharacters,
EmptyBox,
}
impl ObjectGate {
#[must_use]
pub fn keeps(self, rescue: bool) -> bool {
match self {
Self::Occupies | Self::ShowsCharacters => true,
Self::EmptyBox => rescue,
}
}
}
fn shown_char(run: &TextRun, item: &Item) -> u32 {
run.font
.unicode_from_charcode(item.code)
.first()
.map_or(item.code.0, |ch| u32::from(*ch))
}
#[must_use]
pub fn gate(run: &TextRun) -> ObjectGate {
if run.rect.width().abs() >= SIZE_EPSILON {
return ObjectGate::Occupies;
}
let shows_text = run.advance >= SIZE_EPSILON
&& (0..run.count())
.filter_map(|index| run.item(index))
.any(|item| shows_glyph(shown_char(run, &item)));
if shows_text {
ObjectGate::ShowsCharacters
} else {
ObjectGate::EmptyBox
}
}
fn shows_glyph(ch: u32) -> bool {
if ch < 0x20 || (0x7F..=0x9F).contains(&ch) {
return false;
}
char::from_u32(ch).is_none_or(|c| !c.is_whitespace())
}
#[must_use]
pub fn walk(objects: &[PageObject]) -> Vec<TextRun> {
let mut out = Vec::new();
let mut next = 0u32;
collect(objects, &mut next, &mut out);
out
}
fn collect(objects: &[PageObject], next: &mut u32, out: &mut Vec<TextRun>) {
for object in objects {
match object {
PageObject::Text(content) => {
let index = ObjectIndex(*next);
*next += 1;
if let Some(run) = build(content, index) {
out.push(run);
}
}
PageObject::Form(content) => {
*next += 1;
collect(&content.object.objects, next, out);
}
PageObject::Path(_) | PageObject::Image(_) | PageObject::Shading(_) => {
*next += 1;
}
}
}
}
#[must_use]
pub fn top_level_text_indices(objects: &[PageObject]) -> Vec<ObjectIndex> {
let mut out = Vec::new();
let mut next = 0u32;
for object in objects {
let index = ObjectIndex(next);
next = next.saturating_add(1);
match object {
PageObject::Text(_) => out.push(index),
PageObject::Form(content) => next = skip(&content.object.objects, next),
PageObject::Path(_) | PageObject::Image(_) | PageObject::Shading(_) => {}
}
}
out
}
fn skip(objects: &[PageObject], mut next: u32) -> u32 {
for object in objects {
next = next.saturating_add(1);
if let PageObject::Form(content) = object {
next = skip(&content.object.objects, next);
}
}
next
}
#[cfg(test)]
mod tests {
#![allow(clippy::float_cmp, reason = "test fixtures pin exact values")]
use super::GlyphWidth;
#[test]
fn a_width_truncates_toward_zero_as_the_saturated_cast_does() {
assert_eq!(GlyphWidth::truncating(722.5).as_f64(), 722.0);
assert_eq!(GlyphWidth::truncating(722.9).as_f64(), 722.0);
assert_eq!(GlyphWidth::truncating(-722.9).as_f64(), -722.0);
assert_eq!(GlyphWidth::truncating(722.0).as_f64(), 722.0);
assert_eq!(GlyphWidth::truncating(0.0), GlyphWidth::ZERO);
}
#[test]
fn a_nonsense_width_is_zero_rather_than_a_saturated_extreme() {
assert_eq!(GlyphWidth::truncating(f32::NAN), GlyphWidth::ZERO);
assert_eq!(GlyphWidth::truncating(f32::INFINITY), GlyphWidth::ZERO);
assert_eq!(GlyphWidth::truncating(f32::NEG_INFINITY), GlyphWidth::ZERO);
}
#[test]
fn only_a_strictly_positive_width_ends_the_ladder() {
assert!(GlyphWidth::truncating(1.0).is_positive());
assert!(!GlyphWidth::ZERO.is_positive());
assert!(!GlyphWidth::truncating(-1.0).is_positive());
assert!(!GlyphWidth::truncating(0.5).is_positive());
}
#[test]
fn the_max_of_two_widths_is_taken_on_the_integers() {
let a = GlyphWidth::truncating(500.0);
let b = GlyphWidth::truncating(722.0);
assert_eq!(a.max(b), b);
assert_eq!(b.max(a), b);
}
}