Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/pdf-canvas/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub mod pdf_canvas;
pub mod recording_canvas;
mod shading;
pub mod stroke_style;
pub mod text;
mod text_renderer;
mod text_state;
mod truetype_font_renderer;
Expand Down
44 changes: 44 additions & 0 deletions crates/pdf-canvas/src/pdf_canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::{
pdf_path_pen::PdfPathPen,
recording_canvas::RecordingCanvas,
stroke_style::StrokeStyle,
text::{TextGlyph, TextSink, glyph_bounds, glyph_text},
text_state::TextState,
};
use pdf_content_stream::ContentStream;
Expand Down Expand Up @@ -43,6 +44,8 @@ pub struct PdfCanvas<'a, B: CanvasBackend> {
pub(crate) canvas_stack: Vec<CanvasState<'a>>,
/// Content-stream IDs currently being rendered on this canvas stack.
pub(crate) active_content_stream_ids: HashSet<usize>,
/// Optional sink for extracted text glyph positions.
pub(crate) text_sink: Option<&'a mut dyn TextSink>,
}

impl<'a, B: CanvasBackend> PdfCanvas<'a, B> {
Expand Down Expand Up @@ -115,9 +118,22 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> {
page,
canvas_stack,
active_content_stream_ids: HashSet::new(),
text_sink: None,
})
}

/// Creates a new `PdfCanvas` and emits rendered text spans into `text_sink`.
pub fn new_with_text_sink(
backend: &'a mut B,
page: &'a PdfPage,
bb: Option<&Rect>,
text_sink: &'a mut dyn TextSink,
) -> Result<Self, PdfCanvasError> {
let mut canvas = Self::new(backend, page, bb)?;
canvas.text_sink = Some(text_sink);
Ok(canvas)
}

/// Returns whether a form or pattern bbox is safe to materialize as an offscreen recording.
///
/// PDFs in the wild sometimes contain malformed `/BBox` values for Form XObjects or tiling
Expand Down Expand Up @@ -212,6 +228,7 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> {
page: self.page,
canvas_stack,
active_content_stream_ids: self.active_content_stream_ids.clone(),
text_sink: None,
};

// Render the form's content stream into the mask canvas.
Expand Down Expand Up @@ -791,6 +808,33 @@ impl<'a, B: CanvasBackend> PdfCanvas<'a, B> {
TextRenderingMode::Clip => self.add_to_text_clip(path),
}
}

pub(crate) fn record_text_glyph(
&mut self,
char_code: u16,
text_state_before_advance: &TextState<'a>,
ctm: &Transform,
) -> Result<(), PdfCanvasError> {
if self.text_sink.is_none() {
return Ok(());
}

let text_state_after_advance = &self.current_state()?.text_state;
let text = glyph_text(text_state_before_advance.font, char_code);
if text.is_empty() {
return Ok(());
}

let bounds = glyph_bounds(text_state_before_advance, ctm, text_state_after_advance);
if bounds.is_valid() {
let Some(text_sink) = self.text_sink.as_deref_mut() else {
return Ok(());
};
text_sink.push_glyph(TextGlyph { text, bounds });
}

Ok(())
}
}

#[cfg(test)]
Expand Down
148 changes: 148 additions & 0 deletions crates/pdf-canvas/src/text.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
use pdf_font::font::Font;
use pdf_graphics::{rect::Rect, transform::Transform};

use crate::text_state::TextState;

/// One extracted text glyph or character span in rendered device coordinates.
#[derive(Debug, Clone, PartialEq)]
pub struct TextGlyph {
/// Unicode text represented by this glyph span.
pub text: String,
/// Axis-aligned device-space bounds for hit testing and highlighting.
pub bounds: Rect,
}

/// Receives text spans while a page content stream is rendered.
pub trait TextSink {
/// Records one extracted text span.
fn push_glyph(&mut self, glyph: TextGlyph);
}

/// Collects text spans emitted by [`PdfCanvas`](crate::pdf_canvas::PdfCanvas).
#[derive(Default)]
pub struct TextCollector {
glyphs: Vec<TextGlyph>,
}

impl TextCollector {
/// Creates an empty text collector.
pub fn new() -> Self {
Self::default()
}

/// Returns the collected glyph spans.
pub fn glyphs(&self) -> &[TextGlyph] {
&self.glyphs
}

/// Consumes the collector and returns its glyph spans.
pub fn into_glyphs(self) -> Vec<TextGlyph> {
self.glyphs
}
}

impl TextSink for TextCollector {
fn push_glyph(&mut self, glyph: TextGlyph) {
self.glyphs.push(glyph);
}
}

pub(crate) fn glyph_text(font: Option<&Font>, char_code: u16) -> String {
font.map(|current_font| current_font.chars_to_unicode(char_code))
.filter(|chars| !chars.is_empty())
.map(|chars| chars.iter().collect())
.unwrap_or_else(|| {
char::from_u32(u32::from(char_code))
.unwrap_or('\u{fffd}')
.to_string()
})
}

pub(crate) fn glyph_bounds(
text_state_before_advance: &TextState<'_>,
ctm: &Transform,
text_state_after_advance: &TextState<'_>,
) -> Rect {
let mut before = text_state_before_advance.matrix;
before.concat(ctm);

let mut after = text_state_after_advance.matrix;
after.concat(ctm);

let (start_x, start_y) = before.transform_point(0.0, 0.0);
let (end_x, end_y) = after.transform_point(0.0, 0.0);
let font_size = text_state_before_advance.font_size.abs().max(1.0);
let local_rect = Rect {
left: 0.0,
top: font_size * 0.8,
right: font_size * 0.5,
bottom: -font_size * 0.25,
};
let mut rect = before.map_rect(&local_rect);

rect.left = rect.left.min(start_x).min(end_x);
rect.right = rect.right.max(start_x).max(end_x);
rect.top = rect.top.min(start_y).min(end_y);
rect.bottom = rect.bottom.max(start_y).max(end_y);

rect.normalized()
}

#[cfg(test)]
mod tests {
use super::*;
use crate::text_state::TextState;

#[test]
fn glyph_bounds_cover_advanced_text_position() {
let before = TextState {
font_size: 12.0,
..Default::default()
};
let mut after = before.clone();
after.matrix.post_translate(24.0, 0.0);

let bounds = glyph_bounds(&before, &Transform::identity(), &after);

assert!(bounds.left <= 0.0);
assert!(bounds.right >= 24.0);
assert!(bounds.top < bounds.bottom);
}

#[test]
fn glyph_bounds_apply_ctm() {
let before = TextState {
font_size: 10.0,
..Default::default()
};
let mut after = before.clone();
after.matrix.post_translate(10.0, 0.0);
let ctm = Transform::from_translate(5.0, 7.0);

let bounds = glyph_bounds(&before, &ctm, &after);

assert!(bounds.left <= 5.0);
assert!(bounds.right >= 15.0);
assert!(bounds.top <= 7.0);
}

#[test]
fn glyph_bounds_respect_flipped_canvas_ctm() {
let mut before = TextState {
font_size: 20.0,
..Default::default()
};
before.matrix.post_translate(30.0, 40.0);
let mut after = before.clone();
after.matrix.post_translate(10.0, 0.0);
let ctm = Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, 100.0);

let bounds = glyph_bounds(&before, &ctm, &after);

let (_baseline_x, baseline_y) = before.matrix.transform_point(0.0, 0.0);
let baseline_y = 100.0 - baseline_y;
assert!(bounds.top < baseline_y);
assert!(bounds.bottom > baseline_y);
assert!(bounds.bottom - baseline_y < baseline_y - bounds.top);
}
}
4 changes: 4 additions & 0 deletions crates/pdf-canvas/src/truetype_font_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ impl<B: CanvasBackend> TextRenderer for TrueTypeFontRenderer<'_, '_, B> {
) -> Result<(), crate::error::PdfCanvasError> {
for char_code in text {
let state = self.canvas.current_state()?;
let text_state_before_advance = state.text_state.clone();
let ctm = state.transform;
let glyph_matrix_for_char = state
.text_state
.compose_glyph_matrix(self.glyph_base_transform, &state.transform);
Expand Down Expand Up @@ -142,6 +144,8 @@ impl<B: CanvasBackend> TextRenderer for TrueTypeFontRenderer<'_, '_, B> {
resolved_glyph_id,
self.units_per_em,
)?;
self.canvas
.record_text_glyph(char_code, &text_state_before_advance, &ctm)?;
}
Ok(())
}
Expand Down
18 changes: 9 additions & 9 deletions crates/pdf-canvas/src/type1_font_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,13 +351,6 @@ impl<'a, 'b, B: CanvasBackend> Type1FontRenderer<'a, 'b, B> {
})
}

fn glyph_matrix(&self) -> Result<Transform, PdfCanvasError> {
let state = self.canvas.current_state()?;
Ok(state
.text_state
.compose_glyph_matrix(self.glyph_base_transform, &state.transform))
}

fn prepare_glyph(&self, char_code: u16) -> Result<PreparedGlyph<'b>, PdfCanvasError> {
self.font
.prepare_glyph(&*self.canvas, self.is_cid, char_code)
Expand All @@ -381,10 +374,17 @@ impl<'a, 'b, B: CanvasBackend> Type1FontRenderer<'a, 'b, B> {
}

fn render_char(&mut self, char_code: u16) -> Result<(), PdfCanvasError> {
let glyph_matrix = self.glyph_matrix()?;
let state = self.canvas.current_state()?;
let text_state_before_advance = state.text_state.clone();
let ctm = state.transform;
let glyph_matrix = state
.text_state
.compose_glyph_matrix(self.glyph_base_transform, &state.transform);
let mut glyph = self.prepare_glyph(char_code)?;
self.draw_glyph(&mut glyph, &glyph_matrix)?;
self.advance_text_position(char_code, &glyph)
self.advance_text_position(char_code, &glyph)?;
self.canvas
.record_text_glyph(char_code, &text_state_before_advance, &ctm)
}
}

Expand Down
4 changes: 4 additions & 0 deletions crates/pdf-canvas/src/type3_font_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ impl<B: CanvasBackend> TextRenderer for Type3FontRenderer<'_, '_, B> {
) -> Result<(), crate::error::PdfCanvasError> {
for char_code_byte in iter {
let state = self.canvas.current_state()?;
let text_state_before_advance = state.text_state.clone();
let ctm = state.transform;

// Compute a relative glyph matrix (Tm × S × FontMatrix) without the CTM.
// render_content_stream will post-concatenate this onto the current CTM,
Expand Down Expand Up @@ -109,6 +111,8 @@ impl<B: CanvasBackend> TextRenderer for Type3FontRenderer<'_, '_, B> {
let glyph_width_y = (y1 - y0) * text_state.font_size;

text_state.advance_text_cursor(char_code_byte, glyph_width_x, glyph_width_y);
self.canvas
.record_text_glyph(char_code_byte, &text_state_before_advance, &ctm)?;
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/pdf-renderer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ pdf-annotations = { path = "../pdf-annotations" }
pdf-content-stream = { path = "../pdf-content-stream" }
pdf-document = { path = "../pdf-document" }
pdf-canvas = { path = "../pdf-canvas" }
pdf-graphics = { path = "../pdf-graphics" }
thiserror = "2.0.12"
Loading
Loading