use std::fmt::Write as FmtWrite;
#[derive(Debug, Clone, PartialEq)]
pub enum TextItem<'a> {
Text(&'a str),
Spacing(i64),
}
pub struct ContentStreamBuilder {
buf: String,
}
impl ContentStreamBuilder {
pub fn new() -> Self {
Self { buf: String::new() }
}
pub fn begin_text(&mut self) -> &mut Self {
self.buf.push_str("BT\n");
self
}
pub fn end_text(&mut self) -> &mut Self {
self.buf.push_str("ET\n");
self
}
pub fn set_font(&mut self, name: &str, size: f64) -> &mut Self {
self.buf.push('/');
write_name(&mut self.buf, name);
self.buf.push(' ');
write_number(&mut self.buf, size);
self.buf.push_str(" Tf\n");
self
}
pub fn move_to(&mut self, x: f64, y: f64) -> &mut Self {
write_number(&mut self.buf, x);
self.buf.push(' ');
write_number(&mut self.buf, y);
self.buf.push_str(" Td\n");
self
}
pub fn show_text(&mut self, text: &str) -> &mut Self {
self.buf.push('(');
write_literal_string(&mut self.buf, text);
self.buf.push_str(") Tj\n");
self
}
pub fn show_text_bytes(&mut self, bytes: &[u8]) -> &mut Self {
self.buf.push('(');
for &b in bytes {
match b {
b'(' => self.buf.push_str("\\("),
b')' => self.buf.push_str("\\)"),
b'\\' => self.buf.push_str("\\\\"),
_ => self.buf.push(b as char),
}
}
self.buf.push_str(") Tj\n");
self
}
pub fn show_text_adjusted(&mut self, items: &[TextItem<'_>]) -> &mut Self {
self.buf.push('[');
for item in items {
match item {
TextItem::Text(s) => {
self.buf.push('(');
write_literal_string(&mut self.buf, s);
self.buf.push(')');
}
TextItem::Spacing(n) => {
let _ = write!(self.buf, "{}", n);
}
}
self.buf.push(' ');
}
if self.buf.ends_with(' ') {
self.buf.pop();
}
self.buf.push_str("] TJ\n");
self
}
pub fn set_text_rendering_mode(&mut self, mode: u8) -> &mut Self {
self.buf.push_str(&format!("{} Tr\n", mode));
self
}
pub fn set_text_matrix(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> &mut Self {
write_number(&mut self.buf, a);
self.buf.push(' ');
write_number(&mut self.buf, b);
self.buf.push(' ');
write_number(&mut self.buf, c);
self.buf.push(' ');
write_number(&mut self.buf, d);
self.buf.push(' ');
write_number(&mut self.buf, e);
self.buf.push(' ');
write_number(&mut self.buf, f);
self.buf.push_str(" Tm\n");
self
}
pub fn set_character_spacing(&mut self, spacing: f64) -> &mut Self {
write_number(&mut self.buf, spacing);
self.buf.push_str(" Tc\n");
self
}
pub fn set_word_spacing(&mut self, spacing: f64) -> &mut Self {
write_number(&mut self.buf, spacing);
self.buf.push_str(" Tw\n");
self
}
pub fn set_leading(&mut self, leading: f64) -> &mut Self {
write_number(&mut self.buf, leading);
self.buf.push_str(" TL\n");
self
}
pub fn next_line(&mut self) -> &mut Self {
self.buf.push_str("T*\n");
self
}
pub fn save_state(&mut self) -> &mut Self {
self.buf.push_str("q\n");
self
}
pub fn restore_state(&mut self) -> &mut Self {
self.buf.push_str("Q\n");
self
}
pub fn set_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> &mut Self {
write_number(&mut self.buf, a);
self.buf.push(' ');
write_number(&mut self.buf, b);
self.buf.push(' ');
write_number(&mut self.buf, c);
self.buf.push(' ');
write_number(&mut self.buf, d);
self.buf.push(' ');
write_number(&mut self.buf, e);
self.buf.push(' ');
write_number(&mut self.buf, f);
self.buf.push_str(" cm\n");
self
}
pub fn set_line_width(&mut self, width: f64) -> &mut Self {
write_number(&mut self.buf, width);
self.buf.push_str(" w\n");
self
}
pub fn set_fill_color_rgb(&mut self, r: f64, g: f64, b: f64) -> &mut Self {
write_number(&mut self.buf, r);
self.buf.push(' ');
write_number(&mut self.buf, g);
self.buf.push(' ');
write_number(&mut self.buf, b);
self.buf.push_str(" rg\n");
self
}
pub fn set_stroke_color_rgb(&mut self, r: f64, g: f64, b: f64) -> &mut Self {
write_number(&mut self.buf, r);
self.buf.push(' ');
write_number(&mut self.buf, g);
self.buf.push(' ');
write_number(&mut self.buf, b);
self.buf.push_str(" RG\n");
self
}
pub fn set_fill_color_gray(&mut self, gray: f64) -> &mut Self {
write_number(&mut self.buf, gray);
self.buf.push_str(" g\n");
self
}
pub fn set_stroke_color_gray(&mut self, gray: f64) -> &mut Self {
write_number(&mut self.buf, gray);
self.buf.push_str(" G\n");
self
}
pub fn move_line_to(&mut self, x: f64, y: f64) -> &mut Self {
write_number(&mut self.buf, x);
self.buf.push(' ');
write_number(&mut self.buf, y);
self.buf.push_str(" m\n");
self
}
pub fn line_to(&mut self, x: f64, y: f64) -> &mut Self {
write_number(&mut self.buf, x);
self.buf.push(' ');
write_number(&mut self.buf, y);
self.buf.push_str(" l\n");
self
}
pub fn rect(&mut self, x: f64, y: f64, width: f64, height: f64) -> &mut Self {
write_number(&mut self.buf, x);
self.buf.push(' ');
write_number(&mut self.buf, y);
self.buf.push(' ');
write_number(&mut self.buf, width);
self.buf.push(' ');
write_number(&mut self.buf, height);
self.buf.push_str(" re\n");
self
}
pub fn stroke(&mut self) -> &mut Self {
self.buf.push_str("S\n");
self
}
pub fn fill(&mut self) -> &mut Self {
self.buf.push_str("f\n");
self
}
pub fn fill_and_stroke(&mut self) -> &mut Self {
self.buf.push_str("B\n");
self
}
pub fn close_path(&mut self) -> &mut Self {
self.buf.push_str("h\n");
self
}
pub fn draw_image(&mut self, name: &str) -> &mut Self {
self.buf.push('/');
write_name(&mut self.buf, name);
self.buf.push_str(" Do\n");
self
}
pub fn begin_marked_content(&mut self, tag: &str) -> &mut Self {
self.buf.push('/');
write_name(&mut self.buf, tag);
self.buf.push_str(" BMC\n");
self
}
pub fn begin_marked_content_with_properties(&mut self, tag: &str, mcid: u32) -> &mut Self {
self.buf.push('/');
write_name(&mut self.buf, tag);
let _ = write!(self.buf, " <</MCID {}>> BDC", mcid);
self.buf.push('\n');
self
}
pub fn end_marked_content(&mut self) -> &mut Self {
self.buf.push_str("EMC\n");
self
}
pub fn build(self) -> Vec<u8> {
self.buf.into_bytes()
}
}
impl Default for ContentStreamBuilder {
fn default() -> Self {
Self::new()
}
}
fn write_number(buf: &mut String, n: f64) {
if n == n.floor() && n.abs() < 1e15 {
let _ = write!(buf, "{}", n as i64);
} else {
let _ = write!(buf, "{}", n);
}
}
fn write_name(buf: &mut String, name: &str) {
for &b in name.as_bytes() {
if !(0x21..=0x7E).contains(&b)
|| matches!(
b,
b'#' | b'/' | b'%' | b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}'
)
{
let _ = write!(buf, "#{:02X}", b);
} else {
buf.push(b as char);
}
}
}
fn write_literal_string(buf: &mut String, text: &str) {
for &b in text.as_bytes() {
match b {
b'(' => buf.push_str("\\("),
b')' => buf.push_str("\\)"),
b'\\' => buf.push_str("\\\\"),
_ => buf.push(b as char),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content::{extract_text_from_content, tokenize_content_stream, ContentToken};
use crate::core::objects::{Object, PdfName};
fn build<F: FnOnce(&mut ContentStreamBuilder)>(f: F) -> Vec<u8> {
let mut b = ContentStreamBuilder::new();
f(&mut b);
b.build()
}
#[test]
fn builder_empty_stream() {
let data = ContentStreamBuilder::new().build();
assert!(data.is_empty());
}
#[test]
fn builder_begin_end_text() {
let data = build(|b| {
b.begin_text().end_text();
});
assert_eq!(data, b"BT\nET\n");
}
#[test]
fn builder_set_font() {
let data = build(|b| {
b.set_font("F1", 12.0);
});
assert_eq!(data, b"/F1 12 Tf\n");
}
#[test]
fn builder_text_position() {
let data = build(|b| {
b.move_to(100.0, 700.0);
});
assert_eq!(data, b"100 700 Td\n");
}
#[test]
fn builder_show_text() {
let data = build(|b| {
b.show_text("Hello");
});
assert_eq!(data, b"(Hello) Tj\n");
}
#[test]
fn builder_show_text_escaping() {
let data = build(|b| {
b.show_text("a(b)c\\d");
});
assert_eq!(data, b"(a\\(b\\)c\\\\d) Tj\n");
}
#[test]
fn builder_show_text_adjusted() {
let data = build(|b| {
b.show_text_adjusted(&[
TextItem::Text("Hello"),
TextItem::Spacing(-100),
TextItem::Text("World"),
]);
});
assert_eq!(data, b"[(Hello) -100 (World)] TJ\n");
}
#[test]
fn builder_save_restore() {
let data = build(|b| {
b.save_state().restore_state();
});
assert_eq!(data, b"q\nQ\n");
}
#[test]
fn builder_set_transform() {
let data = build(|b| {
b.set_transform(1.0, 0.0, 0.0, 1.0, 50.0, 50.0);
});
assert_eq!(data, b"1 0 0 1 50 50 cm\n");
}
#[test]
fn builder_fill_color_rgb() {
let data = build(|b| {
b.set_fill_color_rgb(1.0, 0.0, 0.0);
});
assert_eq!(data, b"1 0 0 rg\n");
}
#[test]
fn builder_stroke_color_rgb() {
let data = build(|b| {
b.set_stroke_color_rgb(1.0, 0.0, 0.0);
});
assert_eq!(data, b"1 0 0 RG\n");
}
#[test]
fn builder_fill_color_gray() {
let data = build(|b| {
b.set_fill_color_gray(0.5);
});
assert_eq!(data, b"0.5 g\n");
}
#[test]
fn builder_stroke_color_gray() {
let data = build(|b| {
b.set_stroke_color_gray(0.5);
});
assert_eq!(data, b"0.5 G\n");
}
#[test]
fn builder_rect() {
let data = build(|b| {
b.rect(10.0, 20.0, 100.0, 50.0);
});
assert_eq!(data, b"10 20 100 50 re\n");
}
#[test]
fn builder_line_path() {
let data = build(|b| {
b.move_line_to(0.0, 0.0)
.line_to(100.0, 100.0)
.close_path()
.stroke();
});
assert_eq!(data, b"0 0 m\n100 100 l\nh\nS\n");
}
#[test]
fn builder_draw_image() {
let data = build(|b| {
b.draw_image("Im1");
});
assert_eq!(data, b"/Im1 Do\n");
}
#[test]
fn builder_set_line_width() {
let data = build(|b| {
b.set_line_width(2.0);
});
assert_eq!(data, b"2 w\n");
}
#[test]
fn builder_roundtrip() {
let data = build(|b| {
b.save_state()
.set_transform(1.0, 0.0, 0.0, 1.0, 50.0, 50.0)
.begin_text()
.set_font("F1", 12.0)
.move_to(100.0, 700.0)
.show_text("Hello World")
.end_text()
.restore_state();
});
let tokens = tokenize_content_stream(&data).unwrap();
assert_eq!(tokens[0], ContentToken::Operator("q".to_string()));
assert_eq!(tokens[1], ContentToken::Operand(Object::Integer(1)));
assert_eq!(tokens[7], ContentToken::Operator("cm".to_string()));
assert_eq!(tokens[8], ContentToken::Operator("BT".to_string()));
assert_eq!(
tokens[9],
ContentToken::Operand(Object::Name(PdfName::new("F1")))
);
assert_eq!(tokens[10], ContentToken::Operand(Object::Integer(12)));
assert_eq!(tokens[11], ContentToken::Operator("Tf".to_string()));
assert_eq!(tokens[12], ContentToken::Operand(Object::Integer(100)));
assert_eq!(tokens[13], ContentToken::Operand(Object::Integer(700)));
assert_eq!(tokens[14], ContentToken::Operator("Td".to_string()));
assert_eq!(tokens[16], ContentToken::Operator("Tj".to_string()));
assert_eq!(tokens[17], ContentToken::Operator("ET".to_string()));
assert_eq!(tokens[18], ContentToken::Operator("Q".to_string()));
}
#[test]
fn builder_full_page_content() {
let data = build(|b| {
b.begin_text()
.set_font("F1", 12.0)
.move_to(72.0, 720.0)
.show_text("Hello World")
.end_text();
});
let extracted = extract_text_from_content(&data).unwrap();
assert!(extracted.contains("Hello World"));
}
#[test]
fn builder_fractional_numbers() {
let data = build(|b| {
b.move_to(72.5, 100.25);
});
assert_eq!(data, b"72.5 100.25 Td\n");
}
#[test]
fn builder_negative_numbers() {
let data = build(|b| {
b.move_to(-10.0, -20.0);
});
assert_eq!(data, b"-10 -20 Td\n");
}
#[test]
fn builder_fill_operator() {
let data = build(|b| {
b.rect(0.0, 0.0, 100.0, 100.0).fill();
});
assert_eq!(data, b"0 0 100 100 re\nf\n");
}
#[test]
fn builder_fill_and_stroke() {
let data = build(|b| {
b.rect(0.0, 0.0, 100.0, 100.0).fill_and_stroke();
});
assert_eq!(data, b"0 0 100 100 re\nB\n");
}
#[test]
fn builder_text_state_operators() {
let data = build(|b| {
b.set_character_spacing(0.5)
.set_word_spacing(1.0)
.set_leading(14.0);
});
assert_eq!(data, b"0.5 Tc\n1 Tw\n14 TL\n");
}
#[test]
fn builder_next_line() {
let data = build(|b| {
b.next_line();
});
assert_eq!(data, b"T*\n");
}
#[test]
fn builder_text_matrix() {
let data = build(|b| {
b.set_text_matrix(1.0, 0.0, 0.0, 1.0, 72.0, 720.0);
});
assert_eq!(data, b"1 0 0 1 72 720 Tm\n");
}
#[test]
fn builder_name_escaping() {
let data = build(|b| {
b.set_font("F#1", 12.0);
});
assert_eq!(data, b"/F#231 12 Tf\n");
}
#[test]
fn builder_text_rendering_mode() {
let data = build(|b| {
b.begin_text()
.set_text_rendering_mode(3)
.set_font("F1", 12.0)
.move_to(100.0, 700.0)
.show_text("invisible")
.end_text();
});
assert!(data.windows(4).any(|w| w == b"3 Tr"));
}
}