use crate::elements::{
ContentElement, ImageContent, PathContent, PathOperation, StructureElement, TableCellAlign,
TableContent, TextContent,
};
use crate::error::Result;
use crate::fonts::GlyphRemapper;
use crate::layout::Color;
use std::collections::HashMap;
use std::io::Write;
#[derive(Debug, Clone)]
pub enum ContentStreamOp {
SaveState,
RestoreState,
Transform(f32, f32, f32, f32, f32, f32),
BeginText,
EndText,
SetFont(String, f32),
MoveText(f32, f32),
SetTextMatrix(f32, f32, f32, f32, f32, f32),
ShowText(String),
ShowHexText(String),
ShowEmbeddedText {
font_name: String,
glyph_ids: Vec<u16>,
},
ShowTextArray(Vec<TextArrayItem>),
SetCharacterSpacing(f32),
SetWordSpacing(f32),
SetTextLeading(f32),
NextLine,
SetFillColorRGB(f32, f32, f32),
SetStrokeColorRGB(f32, f32, f32),
SetFillColorGray(f32),
SetStrokeColorGray(f32),
SetLineWidth(f32),
MoveTo(f32, f32),
LineTo(f32, f32),
CurveTo(f32, f32, f32, f32, f32, f32),
Rectangle(f32, f32, f32, f32),
ClosePath,
Stroke,
Fill,
FillStroke,
CloseStroke,
EndPath,
PaintXObject(String),
BeginMarkedContentDict {
tag: String,
mcid: u32,
},
EndMarkedContent,
Clip,
ClipEvenOdd,
SetExtGState(String),
SetFillColorSpace(String),
SetStrokeColorSpace(String),
SetFillColorN(Vec<f32>),
SetStrokeColorN(Vec<f32>),
SetFillPattern(String, Vec<f32>),
SetStrokePattern(String, Vec<f32>),
PaintShading(String),
CurveToV(f32, f32, f32, f32),
CurveToY(f32, f32, f32, f32),
FillEvenOdd,
FillStrokeEvenOdd,
CloseFillStroke,
CloseFillStrokeEvenOdd,
SetLineCap(LineCap),
SetLineJoin(LineJoin),
SetMiterLimit(f32),
SetDashPattern(Vec<f32>, f32),
SetFillColorCMYK(f32, f32, f32, f32),
SetStrokeColorCMYK(f32, f32, f32, f32),
Raw(String),
}
#[derive(Debug, Clone, Copy, Default)]
pub enum LineCap {
#[default]
Butt = 0,
Round = 1,
Square = 2,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum LineJoin {
#[default]
Miter = 0,
Round = 1,
Bevel = 2,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum BlendMode {
#[default]
Normal,
Multiply,
Screen,
Overlay,
Darken,
Lighten,
ColorDodge,
ColorBurn,
HardLight,
SoftLight,
Difference,
Exclusion,
}
impl BlendMode {
pub fn as_pdf_name(&self) -> &'static str {
match self {
BlendMode::Normal => "Normal",
BlendMode::Multiply => "Multiply",
BlendMode::Screen => "Screen",
BlendMode::Overlay => "Overlay",
BlendMode::Darken => "Darken",
BlendMode::Lighten => "Lighten",
BlendMode::ColorDodge => "ColorDodge",
BlendMode::ColorBurn => "ColorBurn",
BlendMode::HardLight => "HardLight",
BlendMode::SoftLight => "SoftLight",
BlendMode::Difference => "Difference",
BlendMode::Exclusion => "Exclusion",
}
}
}
#[derive(Debug, Clone)]
pub enum TextArrayItem {
Text(String),
HexText(String),
Adjustment(f32),
}
#[derive(Debug, Clone)]
pub struct PendingImage {
pub image: ImageContent,
pub resource_id: String,
}
#[derive(Debug, Default)]
pub struct ContentStreamBuilder {
operations: Vec<ContentStreamOp>,
current_font: Option<String>,
current_font_size: f32,
in_text_object: bool,
mcid_counter: u32,
pending_images: Vec<PendingImage>,
next_image_id: u32,
}
impl ContentStreamBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn op(&mut self, op: ContentStreamOp) -> &mut Self {
self.operations.push(op);
self
}
pub fn ops(&mut self, ops: impl IntoIterator<Item = ContentStreamOp>) -> &mut Self {
self.operations.extend(ops);
self
}
pub fn begin_text(&mut self) -> &mut Self {
if !self.in_text_object {
self.op(ContentStreamOp::BeginText);
self.in_text_object = true;
}
self
}
pub fn end_text(&mut self) -> &mut Self {
if self.in_text_object {
self.op(ContentStreamOp::EndText);
self.in_text_object = false;
}
self
}
pub fn set_font(&mut self, font_name: &str, size: f32) -> &mut Self {
if self.current_font.as_deref() != Some(font_name) || self.current_font_size != size {
self.op(ContentStreamOp::SetFont(font_name.to_string(), size));
self.current_font = Some(font_name.to_string());
self.current_font_size = size;
}
self
}
pub fn text(&mut self, text: &str, x: f32, y: f32) -> &mut Self {
self.begin_text();
self.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, x, y));
self.op(ContentStreamOp::ShowText(text.to_string()));
self
}
pub fn hex_text(&mut self, hex_string: &str, x: f32, y: f32) -> &mut Self {
self.begin_text();
self.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, x, y));
self.op(ContentStreamOp::ShowHexText(hex_string.to_string()));
self
}
pub fn embedded_text(
&mut self,
font_name: &str,
glyph_ids: Vec<u16>,
x: f32,
y: f32,
) -> &mut Self {
self.begin_text();
self.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, x, y));
self.op(ContentStreamOp::ShowEmbeddedText {
font_name: font_name.to_string(),
glyph_ids,
});
self
}
pub fn fill_color(&mut self, color: Color) -> &mut Self {
self.op(ContentStreamOp::SetFillColorRGB(color.r, color.g, color.b))
}
pub fn draw_image(
&mut self,
resource_id: &str,
x: f32,
y: f32,
width: f32,
height: f32,
) -> &mut Self {
self.end_text();
self.op(ContentStreamOp::SaveState);
self.op(ContentStreamOp::Transform(width, 0.0, 0.0, height, x, y));
self.op(ContentStreamOp::PaintXObject(resource_id.to_string()));
self.op(ContentStreamOp::RestoreState);
self
}
pub fn draw_image_at(
&mut self,
resource_id: &str,
placement: &super::image_handler::ImagePlacement,
) -> &mut Self {
self.draw_image(resource_id, placement.x, placement.y, placement.width, placement.height)
}
pub fn stroke_color(&mut self, color: Color) -> &mut Self {
self.op(ContentStreamOp::SetStrokeColorRGB(color.r, color.g, color.b))
}
pub fn set_fill_color(&mut self, r: f32, g: f32, b: f32) -> &mut Self {
self.op(ContentStreamOp::SetFillColorRGB(r, g, b))
}
pub fn set_stroke_color(&mut self, r: f32, g: f32, b: f32) -> &mut Self {
self.op(ContentStreamOp::SetStrokeColorRGB(r, g, b))
}
pub fn set_line_width(&mut self, width: f32) -> &mut Self {
self.op(ContentStreamOp::SetLineWidth(width))
}
pub fn move_to(&mut self, x: f32, y: f32) -> &mut Self {
self.op(ContentStreamOp::MoveTo(x, y))
}
pub fn line_to(&mut self, x: f32, y: f32) -> &mut Self {
self.op(ContentStreamOp::LineTo(x, y))
}
pub fn rect(&mut self, x: f32, y: f32, width: f32, height: f32) -> &mut Self {
self.op(ContentStreamOp::Rectangle(x, y, width, height))
}
pub fn stroke(&mut self) -> &mut Self {
self.op(ContentStreamOp::Stroke)
}
pub fn fill(&mut self) -> &mut Self {
self.op(ContentStreamOp::Fill)
}
pub fn fill_even_odd(&mut self) -> &mut Self {
self.op(ContentStreamOp::FillEvenOdd)
}
pub fn fill_stroke(&mut self) -> &mut Self {
self.op(ContentStreamOp::FillStroke)
}
pub fn fill_stroke_even_odd(&mut self) -> &mut Self {
self.op(ContentStreamOp::FillStrokeEvenOdd)
}
pub fn close_fill_stroke(&mut self) -> &mut Self {
self.op(ContentStreamOp::CloseFillStroke)
}
pub fn close_path(&mut self) -> &mut Self {
self.op(ContentStreamOp::ClosePath)
}
pub fn clip(&mut self) -> &mut Self {
self.op(ContentStreamOp::Clip)
}
pub fn clip_even_odd(&mut self) -> &mut Self {
self.op(ContentStreamOp::ClipEvenOdd)
}
pub fn end_path(&mut self) -> &mut Self {
self.op(ContentStreamOp::EndPath)
}
pub fn clip_rect(&mut self, x: f32, y: f32, width: f32, height: f32) -> &mut Self {
self.rect(x, y, width, height).clip().end_path()
}
pub fn save_state(&mut self) -> &mut Self {
self.op(ContentStreamOp::SaveState)
}
pub fn restore_state(&mut self) -> &mut Self {
self.op(ContentStreamOp::RestoreState)
}
pub fn set_ext_gstate(&mut self, gs_name: &str) -> &mut Self {
self.op(ContentStreamOp::SetExtGState(gs_name.to_string()))
}
pub fn transform(&mut self, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) -> &mut Self {
self.op(ContentStreamOp::Transform(a, b, c, d, e, f))
}
pub fn translate(&mut self, tx: f32, ty: f32) -> &mut Self {
self.transform(1.0, 0.0, 0.0, 1.0, tx, ty)
}
pub fn scale(&mut self, sx: f32, sy: f32) -> &mut Self {
self.transform(sx, 0.0, 0.0, sy, 0.0, 0.0)
}
pub fn rotate(&mut self, angle: f32) -> &mut Self {
let cos = angle.cos();
let sin = angle.sin();
self.transform(cos, sin, -sin, cos, 0.0, 0.0)
}
pub fn rotate_degrees(&mut self, degrees: f32) -> &mut Self {
self.rotate(degrees * std::f32::consts::PI / 180.0)
}
pub fn set_line_cap(&mut self, cap: LineCap) -> &mut Self {
self.op(ContentStreamOp::SetLineCap(cap))
}
pub fn set_line_join(&mut self, join: LineJoin) -> &mut Self {
self.op(ContentStreamOp::SetLineJoin(join))
}
pub fn set_miter_limit(&mut self, limit: f32) -> &mut Self {
self.op(ContentStreamOp::SetMiterLimit(limit))
}
pub fn set_dash_pattern(&mut self, pattern: Vec<f32>, phase: f32) -> &mut Self {
self.op(ContentStreamOp::SetDashPattern(pattern, phase))
}
pub fn set_solid_line(&mut self) -> &mut Self {
self.set_dash_pattern(vec![], 0.0)
}
pub fn set_fill_color_space(&mut self, name: &str) -> &mut Self {
self.op(ContentStreamOp::SetFillColorSpace(name.to_string()))
}
pub fn set_stroke_color_space(&mut self, name: &str) -> &mut Self {
self.op(ContentStreamOp::SetStrokeColorSpace(name.to_string()))
}
pub fn set_fill_color_n(&mut self, components: Vec<f32>) -> &mut Self {
self.op(ContentStreamOp::SetFillColorN(components))
}
pub fn set_stroke_color_n(&mut self, components: Vec<f32>) -> &mut Self {
self.op(ContentStreamOp::SetStrokeColorN(components))
}
pub fn set_fill_color_cmyk(&mut self, c: f32, m: f32, y: f32, k: f32) -> &mut Self {
self.op(ContentStreamOp::SetFillColorCMYK(c, m, y, k))
}
pub fn set_stroke_color_cmyk(&mut self, c: f32, m: f32, y: f32, k: f32) -> &mut Self {
self.op(ContentStreamOp::SetStrokeColorCMYK(c, m, y, k))
}
pub fn set_fill_pattern(&mut self, pattern_name: &str, components: Vec<f32>) -> &mut Self {
self.op(ContentStreamOp::SetFillPattern(pattern_name.to_string(), components))
}
pub fn set_stroke_pattern(&mut self, pattern_name: &str, components: Vec<f32>) -> &mut Self {
self.op(ContentStreamOp::SetStrokePattern(pattern_name.to_string(), components))
}
pub fn paint_shading(&mut self, shading_name: &str) -> &mut Self {
self.op(ContentStreamOp::PaintShading(shading_name.to_string()))
}
pub fn draw_gradient_rect(
&mut self,
shading_name: &str,
x: f32,
y: f32,
width: f32,
height: f32,
) -> &mut Self {
self.save_state()
.rect(x, y, width, height)
.clip()
.end_path()
.paint_shading(shading_name)
.restore_state()
}
pub fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) -> &mut Self {
self.op(ContentStreamOp::CurveTo(x1, y1, x2, y2, x3, y3))
}
pub fn curve_to_v(&mut self, x2: f32, y2: f32, x3: f32, y3: f32) -> &mut Self {
self.op(ContentStreamOp::CurveToV(x2, y2, x3, y3))
}
pub fn curve_to_y(&mut self, x1: f32, y1: f32, x3: f32, y3: f32) -> &mut Self {
self.op(ContentStreamOp::CurveToY(x1, y1, x3, y3))
}
pub fn circle(&mut self, cx: f32, cy: f32, radius: f32) -> &mut Self {
let k = 0.552_284_8; let c = radius * k;
self.move_to(cx + radius, cy)
.curve_to(cx + radius, cy + c, cx + c, cy + radius, cx, cy + radius)
.curve_to(cx - c, cy + radius, cx - radius, cy + c, cx - radius, cy)
.curve_to(cx - radius, cy - c, cx - c, cy - radius, cx, cy - radius)
.curve_to(cx + c, cy - radius, cx + radius, cy - c, cx + radius, cy)
.close_path()
}
pub fn ellipse(&mut self, cx: f32, cy: f32, rx: f32, ry: f32) -> &mut Self {
let kx = rx * 0.552_284_8;
let ky = ry * 0.552_284_8;
self.move_to(cx + rx, cy)
.curve_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry)
.curve_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy)
.curve_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry)
.curve_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy)
.close_path()
}
pub fn rounded_rect(
&mut self,
x: f32,
y: f32,
width: f32,
height: f32,
radius: f32,
) -> &mut Self {
let r = radius.min(width / 2.0).min(height / 2.0);
let k = r * 0.552_284_8;
self.move_to(x + r, y)
.line_to(x + width - r, y)
.curve_to(x + width - r + k, y, x + width, y + k, x + width, y + r)
.line_to(x + width, y + height - r)
.curve_to(
x + width,
y + height - r + k,
x + width - k,
y + height,
x + width - r,
y + height,
)
.line_to(x + r, y + height)
.curve_to(x + r - k, y + height, x, y + height - k, x, y + height - r)
.line_to(x, y + r)
.curve_to(x, y + r - k, x + r - k, y, x + r, y)
.close_path()
}
pub fn add_element(&mut self, element: &ContentElement) -> &mut Self {
match element {
ContentElement::Text(text) => self.add_text_content(text),
ContentElement::Path(path) => self.add_path_content(path),
ContentElement::Image(image) => self.add_image_content(image),
ContentElement::Structure(_) => self, ContentElement::Table(table) => self.add_table_content(table),
}
}
fn add_text_content(&mut self, text: &TextContent) -> &mut Self {
self.begin_text();
if text.style.color.r != 0.0 || text.style.color.g != 0.0 || text.style.color.b != 0.0 {
self.fill_color(text.style.color);
}
let font_name = self.map_font_name(&text.font.name, text.style.weight.is_bold());
self.set_font(&font_name, text.font.size);
self.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, text.bbox.x, text.bbox.y));
self.op(ContentStreamOp::ShowText(text.text.clone()));
self
}
fn map_font_name(&self, name: &str, bold: bool) -> String {
let base = match name.to_lowercase().as_str() {
"helvetica" | "arial" | "sans-serif" => "Helvetica",
"times" | "times-roman" | "times new roman" | "serif" => "Times-Roman",
"courier" | "courier new" | "monospace" => "Courier",
_ => "Helvetica",
};
if bold {
format!("{}-Bold", base)
} else {
base.to_string()
}
}
fn add_path_content(&mut self, path: &PathContent) -> &mut Self {
self.end_text();
if let Some(color) = path.stroke_color {
self.stroke_color(color);
}
if let Some(color) = path.fill_color {
self.fill_color(color);
}
self.op(ContentStreamOp::SetLineWidth(path.stroke_width));
for op in &path.operations {
match op {
PathOperation::MoveTo(x, y) => {
self.op(ContentStreamOp::MoveTo(*x, *y));
},
PathOperation::LineTo(x, y) => {
self.op(ContentStreamOp::LineTo(*x, *y));
},
PathOperation::CurveTo(x1, y1, x2, y2, x3, y3) => {
self.op(ContentStreamOp::CurveTo(*x1, *y1, *x2, *y2, *x3, *y3));
},
PathOperation::Rectangle(x, y, w, h) => {
self.op(ContentStreamOp::Rectangle(*x, *y, *w, *h));
},
PathOperation::ClosePath => {
self.op(ContentStreamOp::ClosePath);
},
}
}
match (path.stroke_color.is_some(), path.fill_color.is_some()) {
(true, true) => self.op(ContentStreamOp::FillStroke),
(true, false) => self.op(ContentStreamOp::Stroke),
(false, true) => self.op(ContentStreamOp::Fill),
(false, false) => self.op(ContentStreamOp::EndPath),
};
self
}
fn add_table_content(&mut self, table: &TableContent) -> &mut Self {
self.end_text();
let style = &table.style;
let padding = style.cell_padding;
self.op(ContentStreamOp::SaveState);
let mut current_y = table.bbox.y + table.bbox.height;
for (row_idx, row) in table.rows.iter().enumerate() {
let row_height = row
.height
.unwrap_or_else(|| table.bbox.height / table.rows.len() as f32);
current_y -= row_height;
let mut current_x = table.bbox.x;
if let Some((r, g, b)) = row.background {
self.op(ContentStreamOp::SetFillColorRGB(r, g, b));
self.op(ContentStreamOp::Rectangle(
table.bbox.x,
current_y,
table.bbox.width,
row_height,
));
self.op(ContentStreamOp::Fill);
}
if row_idx % 2 == 1 {
if let Some((r, g, b)) = style.stripe_background {
self.op(ContentStreamOp::SetFillColorRGB(r, g, b));
self.op(ContentStreamOp::Rectangle(
table.bbox.x,
current_y,
table.bbox.width,
row_height,
));
self.op(ContentStreamOp::Fill);
}
}
if row.is_header {
if let Some((r, g, b)) = style.header_background {
self.op(ContentStreamOp::SetFillColorRGB(r, g, b));
self.op(ContentStreamOp::Rectangle(
table.bbox.x,
current_y,
table.bbox.width,
row_height,
));
self.op(ContentStreamOp::Fill);
}
}
for (col_idx, cell) in row.cells.iter().enumerate() {
let cell_width = if col_idx < table.column_widths.len() {
table.column_widths[col_idx] * cell.colspan as f32
} else if !table.column_widths.is_empty() {
table.column_widths[0]
} else {
table.bbox.width / row.cells.len() as f32
};
if let Some((r, g, b)) = cell.background {
self.op(ContentStreamOp::SetFillColorRGB(r, g, b));
self.op(ContentStreamOp::Rectangle(
current_x, current_y, cell_width, row_height,
));
self.op(ContentStreamOp::Fill);
}
if !cell.text.is_empty() {
let font_size = cell.font_size.unwrap_or(10.0);
let font_name = if cell.bold {
"Helvetica-Bold"
} else {
"Helvetica"
};
let text_x = match cell.align {
TableCellAlign::Left => current_x + padding,
TableCellAlign::Center => current_x + cell_width / 2.0,
TableCellAlign::Right => current_x + cell_width - padding,
};
let text_y = current_y + row_height - padding - font_size;
self.begin_text();
self.op(ContentStreamOp::SetFillColorRGB(0.0, 0.0, 0.0)); self.set_font(font_name, font_size);
self.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, text_x, text_y));
self.op(ContentStreamOp::ShowText(cell.text.clone()));
self.end_text();
}
current_x += cell_width;
}
}
if style.border_width > 0.0 {
let (r, g, b) = style.border_color;
self.op(ContentStreamOp::SetStrokeColorRGB(r, g, b));
self.op(ContentStreamOp::SetLineWidth(style.border_width));
if style.outer_border {
self.op(ContentStreamOp::Rectangle(
table.bbox.x,
table.bbox.y,
table.bbox.width,
table.bbox.height,
));
self.op(ContentStreamOp::Stroke);
}
if style.horizontal_borders {
let mut y = table.bbox.y + table.bbox.height;
for row in &table.rows {
let row_height = row
.height
.unwrap_or_else(|| table.bbox.height / table.rows.len() as f32);
y -= row_height;
if y > table.bbox.y {
self.op(ContentStreamOp::MoveTo(table.bbox.x, y));
self.op(ContentStreamOp::LineTo(table.bbox.x + table.bbox.width, y));
self.op(ContentStreamOp::Stroke);
}
}
}
if style.vertical_borders && !table.column_widths.is_empty() {
let mut x = table.bbox.x;
for (i, &width) in table.column_widths.iter().enumerate() {
x += width;
if i < table.column_widths.len() - 1 {
self.op(ContentStreamOp::MoveTo(x, table.bbox.y));
self.op(ContentStreamOp::LineTo(x, table.bbox.y + table.bbox.height));
self.op(ContentStreamOp::Stroke);
}
}
}
}
self.op(ContentStreamOp::RestoreState);
self
}
fn add_image_content(&mut self, image: &ImageContent) -> &mut Self {
self.end_text();
self.next_image_id += 1;
let resource_id = format!("Im{}", self.next_image_id);
self.pending_images.push(PendingImage {
image: image.clone(),
resource_id: resource_id.clone(),
});
self.draw_image(
&resource_id,
image.bbox.x,
image.bbox.y,
image.bbox.width,
image.bbox.height,
);
self
}
pub fn take_pending_images(&mut self) -> Vec<PendingImage> {
std::mem::take(&mut self.pending_images)
}
pub fn pending_images(&self) -> &[PendingImage] {
&self.pending_images
}
pub fn add_elements(&mut self, elements: &[ContentElement]) -> &mut Self {
for element in elements {
self.add_element(element);
}
self.end_text();
self
}
pub fn next_mcid(&mut self) -> u32 {
let mcid = self.mcid_counter;
self.mcid_counter += 1;
mcid
}
pub fn add_structure_element(&mut self, elem: &StructureElement) -> &mut Self {
self.add_structure_element_impl(elem)
}
fn add_structure_element_impl(&mut self, elem: &StructureElement) -> &mut Self {
let mcid = self.next_mcid();
self.op(ContentStreamOp::BeginMarkedContentDict {
tag: elem.structure_type.clone(),
mcid,
});
for child in &elem.children {
match child {
ContentElement::Structure(nested_elem) => {
self.add_structure_element_impl(nested_elem);
},
_ => {
self.add_element(child);
},
}
}
self.op(ContentStreamOp::EndMarkedContent);
self
}
pub fn build(&self) -> Result<Vec<u8>> {
self.build_with_remappers(&HashMap::new())
}
pub fn build_with_remappers(
&self,
remappers: &HashMap<String, GlyphRemapper>,
) -> Result<Vec<u8>> {
let mut buf = Vec::new();
for op in &self.operations {
self.write_op(&mut buf, op, remappers)?;
writeln!(buf)?;
}
Ok(buf)
}
fn write_op<W: Write>(
&self,
w: &mut W,
op: &ContentStreamOp,
remappers: &HashMap<String, GlyphRemapper>,
) -> std::io::Result<()> {
match op {
ContentStreamOp::SaveState => write!(w, "q"),
ContentStreamOp::RestoreState => write!(w, "Q"),
ContentStreamOp::Transform(a, b, c, d, e, f) => {
write!(w, "{} {} {} {} {} {} cm", a, b, c, d, e, f)
},
ContentStreamOp::BeginText => write!(w, "BT"),
ContentStreamOp::EndText => write!(w, "ET"),
ContentStreamOp::SetFont(name, size) => write!(w, "/{} {} Tf", name, size),
ContentStreamOp::MoveText(tx, ty) => write!(w, "{} {} Td", tx, ty),
ContentStreamOp::SetTextMatrix(a, b, c, d, e, f) => {
write!(w, "{} {} {} {} {} {} Tm", a, b, c, d, e, f)
},
ContentStreamOp::ShowText(text) => {
write!(w, "(")?;
self.write_escaped_string(w, text)?;
write!(w, ") Tj")
},
ContentStreamOp::ShowHexText(hex) => {
write!(w, "{} Tj", hex)
},
ContentStreamOp::ShowEmbeddedText {
font_name,
glyph_ids,
} => {
let remapper = remappers.get(font_name);
write!(w, "<")?;
for &orig in glyph_ids {
let emitted = remapper.and_then(|r| r.get(orig)).unwrap_or(orig);
write!(w, "{:04X}", emitted)?;
}
write!(w, "> Tj")
},
ContentStreamOp::ShowTextArray(items) => {
write!(w, "[")?;
for item in items {
match item {
TextArrayItem::Text(t) => {
write!(w, "(")?;
self.write_escaped_string(w, t)?;
write!(w, ")")?;
},
TextArrayItem::HexText(hex) => {
write!(w, "{}", hex)?;
},
TextArrayItem::Adjustment(adj) => {
write!(w, "{}", adj)?;
},
}
write!(w, " ")?;
}
write!(w, "] TJ")
},
ContentStreamOp::SetCharacterSpacing(spacing) => write!(w, "{} Tc", spacing),
ContentStreamOp::SetWordSpacing(spacing) => write!(w, "{} Tw", spacing),
ContentStreamOp::SetTextLeading(leading) => write!(w, "{} TL", leading),
ContentStreamOp::NextLine => write!(w, "T*"),
ContentStreamOp::SetFillColorRGB(r, g, b) => write!(w, "{} {} {} rg", r, g, b),
ContentStreamOp::SetStrokeColorRGB(r, g, b) => write!(w, "{} {} {} RG", r, g, b),
ContentStreamOp::SetFillColorGray(g) => write!(w, "{} g", g),
ContentStreamOp::SetStrokeColorGray(g) => write!(w, "{} G", g),
ContentStreamOp::SetLineWidth(width) => write!(w, "{} w", width),
ContentStreamOp::MoveTo(x, y) => write!(w, "{} {} m", x, y),
ContentStreamOp::LineTo(x, y) => write!(w, "{} {} l", x, y),
ContentStreamOp::CurveTo(x1, y1, x2, y2, x3, y3) => {
write!(w, "{} {} {} {} {} {} c", x1, y1, x2, y2, x3, y3)
},
ContentStreamOp::Rectangle(x, y, w_val, h) => {
write!(w, "{} {} {} {} re", x, y, w_val, h)
},
ContentStreamOp::ClosePath => write!(w, "h"),
ContentStreamOp::Stroke => write!(w, "S"),
ContentStreamOp::Fill => write!(w, "f"),
ContentStreamOp::FillStroke => write!(w, "B"),
ContentStreamOp::CloseStroke => write!(w, "s"),
ContentStreamOp::EndPath => write!(w, "n"),
ContentStreamOp::PaintXObject(name) => write!(w, "/{} Do", name),
ContentStreamOp::BeginMarkedContentDict { tag, mcid } => {
write!(w, "/{} <</MCID {}>> BDC", tag, mcid)
},
ContentStreamOp::EndMarkedContent => write!(w, "EMC"),
ContentStreamOp::Clip => write!(w, "W"),
ContentStreamOp::ClipEvenOdd => write!(w, "W*"),
ContentStreamOp::SetExtGState(name) => write!(w, "/{} gs", name),
ContentStreamOp::SetFillColorSpace(name) => write!(w, "/{} cs", name),
ContentStreamOp::SetStrokeColorSpace(name) => write!(w, "/{} CS", name),
ContentStreamOp::SetFillColorN(components) => {
for c in components {
write!(w, "{} ", c)?;
}
write!(w, "scn")
},
ContentStreamOp::SetStrokeColorN(components) => {
for c in components {
write!(w, "{} ", c)?;
}
write!(w, "SCN")
},
ContentStreamOp::SetFillPattern(name, components) => {
for c in components {
write!(w, "{} ", c)?;
}
write!(w, "/{} scn", name)
},
ContentStreamOp::SetStrokePattern(name, components) => {
for c in components {
write!(w, "{} ", c)?;
}
write!(w, "/{} SCN", name)
},
ContentStreamOp::PaintShading(name) => write!(w, "/{} sh", name),
ContentStreamOp::CurveToV(x2, y2, x3, y3) => {
write!(w, "{} {} {} {} v", x2, y2, x3, y3)
},
ContentStreamOp::CurveToY(x1, y1, x3, y3) => {
write!(w, "{} {} {} {} y", x1, y1, x3, y3)
},
ContentStreamOp::FillEvenOdd => write!(w, "f*"),
ContentStreamOp::FillStrokeEvenOdd => write!(w, "B*"),
ContentStreamOp::CloseFillStroke => write!(w, "b"),
ContentStreamOp::CloseFillStrokeEvenOdd => write!(w, "b*"),
ContentStreamOp::SetLineCap(cap) => write!(w, "{} J", *cap as u8),
ContentStreamOp::SetLineJoin(join) => write!(w, "{} j", *join as u8),
ContentStreamOp::SetMiterLimit(limit) => write!(w, "{} M", limit),
ContentStreamOp::SetDashPattern(pattern, phase) => {
write!(w, "[")?;
for (i, p) in pattern.iter().enumerate() {
if i > 0 {
write!(w, " ")?;
}
write!(w, "{}", p)?;
}
write!(w, "] {} d", phase)
},
ContentStreamOp::SetFillColorCMYK(c, m, y, k) => {
write!(w, "{} {} {} {} k", c, m, y, k)
},
ContentStreamOp::SetStrokeColorCMYK(c, m, y, k) => {
write!(w, "{} {} {} {} K", c, m, y, k)
},
ContentStreamOp::Raw(raw) => write!(w, "{}", raw),
}
}
fn write_escaped_string<W: Write>(&self, w: &mut W, text: &str) -> std::io::Result<()> {
for byte in text.bytes() {
match byte {
b'(' => write!(w, "\\(")?,
b')' => write!(w, "\\)")?,
b'\\' => write!(w, "\\\\")?,
b'\n' => write!(w, "\\n")?,
b'\r' => write!(w, "\\r")?,
b'\t' => write!(w, "\\t")?,
_ => w.write_all(&[byte])?,
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::elements::{FontSpec, TextStyle};
use crate::geometry::Rect;
#[test]
fn test_simple_text() {
let mut builder = ContentStreamBuilder::new();
builder
.begin_text()
.set_font("Helvetica", 12.0)
.text("Hello, World!", 72.0, 720.0)
.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("BT"));
assert!(content.contains("/Helvetica 12 Tf"));
assert!(content.contains("(Hello, World!) Tj"));
assert!(content.contains("ET"));
}
#[test]
fn test_text_content_element() {
let text_content = TextContent {
artifact_type: None,
text: "Test".to_string(),
bbox: Rect::new(100.0, 700.0, 50.0, 12.0),
font: FontSpec::new("Helvetica", 12.0),
style: TextStyle::default(),
reading_order: Some(0),
origin: None,
rotation_degrees: None,
matrix: None,
};
let mut builder = ContentStreamBuilder::new();
builder.add_element(&ContentElement::Text(text_content));
builder.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("BT"));
assert!(content.contains("100 700"));
assert!(content.contains("(Test) Tj"));
assert!(content.contains("ET"));
}
#[test]
fn test_path_operations() {
let mut builder = ContentStreamBuilder::new();
builder
.stroke_color(Color::black())
.op(ContentStreamOp::SetLineWidth(1.0))
.op(ContentStreamOp::MoveTo(0.0, 0.0))
.op(ContentStreamOp::LineTo(100.0, 100.0))
.stroke();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0 0 0 RG"));
assert!(content.contains("1 w"));
assert!(content.contains("0 0 m"));
assert!(content.contains("100 100 l"));
assert!(content.contains("S"));
}
#[test]
fn test_marked_content_operators() {
let mut builder = ContentStreamBuilder::new();
builder
.op(ContentStreamOp::BeginMarkedContentDict {
tag: "P".to_string(),
mcid: 0,
})
.op(ContentStreamOp::EndMarkedContent);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/P <</MCID 0>> BDC"));
assert!(content.contains("EMC"));
}
#[test]
fn test_mcid_allocation() {
let mut builder = ContentStreamBuilder::new();
assert_eq!(builder.next_mcid(), 0);
assert_eq!(builder.next_mcid(), 1);
assert_eq!(builder.next_mcid(), 2);
}
#[test]
fn test_structure_element_with_text() {
use crate::elements::FontSpec;
use crate::geometry::Rect;
let text_content = TextContent {
artifact_type: None,
text: "Hello".to_string(),
bbox: Rect::new(100.0, 700.0, 50.0, 12.0),
font: FontSpec::new("Helvetica", 12.0),
style: TextStyle::default(),
reading_order: Some(0),
origin: None,
rotation_degrees: None,
matrix: None,
};
let structure = StructureElement {
structure_type: "P".to_string(),
bbox: Rect::new(100.0, 700.0, 200.0, 50.0),
children: vec![ContentElement::Text(text_content)],
reading_order: Some(0),
alt_text: None,
language: None,
};
let mut builder = ContentStreamBuilder::new();
builder.add_structure_element(&structure);
builder.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/P <</MCID 0>> BDC"));
assert!(content.contains("EMC"));
assert!(content.contains("(Hello) Tj"));
}
#[test]
fn test_nested_structure_elements() {
use crate::geometry::Rect;
let inner_structure = StructureElement {
structure_type: "Span".to_string(),
bbox: Rect::new(100.0, 700.0, 50.0, 12.0),
children: vec![],
reading_order: None,
alt_text: None,
language: None,
};
let outer_structure = StructureElement {
structure_type: "P".to_string(),
bbox: Rect::new(100.0, 700.0, 200.0, 50.0),
children: vec![ContentElement::Structure(inner_structure)],
reading_order: Some(0),
alt_text: None,
language: None,
};
let mut builder = ContentStreamBuilder::new();
builder.add_structure_element(&outer_structure);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/P <</MCID 0>> BDC"));
assert!(content.contains("/Span <</MCID 1>> BDC"));
let emc_count = content.matches("EMC").count();
assert_eq!(emc_count, 2);
}
#[test]
fn test_rectangle() {
let mut builder = ContentStreamBuilder::new();
builder.rect(72.0, 72.0, 468.0, 648.0).stroke();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("72 72 468 648 re"));
assert!(content.contains("S"));
}
#[test]
fn test_escaped_text() {
let mut builder = ContentStreamBuilder::new();
builder
.begin_text()
.set_font("Helvetica", 12.0)
.text("Text with (parens) and \\backslash", 72.0, 720.0)
.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("\\(parens\\)"));
assert!(content.contains("\\\\backslash"));
}
#[test]
fn test_font_mapping() {
let builder = ContentStreamBuilder::new();
assert_eq!(builder.map_font_name("Arial", false), "Helvetica");
assert_eq!(builder.map_font_name("Arial", true), "Helvetica-Bold");
assert_eq!(builder.map_font_name("Times New Roman", false), "Times-Roman");
assert_eq!(builder.map_font_name("Courier", false), "Courier");
}
#[test]
fn test_table_content_rendering() {
use crate::elements::{TableCellContent, TableContent, TableContentStyle, TableRowContent};
let mut table = TableContent::new(Rect::new(72.0, 600.0, 200.0, 100.0));
table.column_widths = vec![100.0, 100.0];
table.style = TableContentStyle::bordered();
let header = TableRowContent::header(vec![
TableCellContent::header("Name"),
TableCellContent::header("Value"),
]);
table.add_row(header);
let row =
TableRowContent::new(vec![TableCellContent::new("Item"), TableCellContent::new("100")]);
table.add_row(row);
let mut builder = ContentStreamBuilder::new();
builder.add_element(&ContentElement::Table(table));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("q")); assert!(content.contains("Q"));
assert!(content.contains("(Name) Tj"));
assert!(content.contains("(Value) Tj"));
assert!(content.contains("(Item) Tj"));
assert!(content.contains("(100) Tj"));
assert!(content.contains("re")); assert!(content.contains("S"));
assert!(builder.pending_images().is_empty());
}
#[test]
fn test_image_content_rendering() {
use crate::elements::{ColorSpace, ImageContent, ImageFormat};
let image = ImageContent {
bbox: Rect::new(100.0, 500.0, 200.0, 150.0),
format: ImageFormat::Jpeg,
data: vec![0xFF, 0xD8, 0xFF, 0xE0], width: 800,
height: 600,
bits_per_component: 8,
color_space: ColorSpace::RGB,
reading_order: Some(0),
alt_text: Some("Test image".to_string()),
horizontal_dpi: None,
vertical_dpi: None,
soft_mask: None,
};
let mut builder = ContentStreamBuilder::new();
builder.add_element(&ContentElement::Image(image));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("q")); assert!(content.contains("Q")); assert!(content.contains("cm")); assert!(content.contains("Do"));
let pending = builder.pending_images();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].resource_id, "Im1");
assert_eq!(pending[0].image.width, 800);
assert_eq!(pending[0].image.height, 600);
}
#[test]
fn test_mixed_content_elements() {
use crate::elements::{
ColorSpace, ImageContent, ImageFormat, TableCellContent, TableContent,
TableContentStyle, TableRowContent,
};
let mut builder = ContentStreamBuilder::new();
let text_content = TextContent {
artifact_type: None,
text: "Header".to_string(),
bbox: Rect::new(72.0, 720.0, 100.0, 14.0),
font: FontSpec::new("Helvetica", 14.0),
style: TextStyle::default(),
reading_order: Some(0),
origin: None,
rotation_degrees: None,
matrix: None,
};
builder.add_element(&ContentElement::Text(text_content));
let mut table = TableContent::new(Rect::new(72.0, 600.0, 200.0, 50.0));
table.column_widths = vec![200.0];
table.style = TableContentStyle::minimal();
table.add_row(TableRowContent::new(vec![TableCellContent::new("Row 1")]));
builder.add_element(&ContentElement::Table(table));
let image = ImageContent {
bbox: Rect::new(72.0, 400.0, 100.0, 100.0),
format: ImageFormat::Png,
data: vec![0x89, 0x50, 0x4E, 0x47], width: 200,
height: 200,
bits_per_component: 8,
color_space: ColorSpace::RGB,
reading_order: Some(2),
alt_text: None,
horizontal_dpi: None,
vertical_dpi: None,
soft_mask: None,
};
builder.add_element(&ContentElement::Image(image));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("(Header) Tj")); assert!(content.contains("(Row 1) Tj")); assert!(content.contains("/Im1 Do"));
assert_eq!(builder.pending_images().len(), 1);
}
#[test]
fn test_take_pending_images() {
use crate::elements::{ColorSpace, ImageContent, ImageFormat};
let image = ImageContent {
bbox: Rect::new(0.0, 0.0, 100.0, 100.0),
format: ImageFormat::Jpeg,
data: vec![0xFF, 0xD8],
width: 100,
height: 100,
bits_per_component: 8,
color_space: ColorSpace::RGB,
reading_order: None,
alt_text: None,
horizontal_dpi: None,
vertical_dpi: None,
soft_mask: None,
};
let mut builder = ContentStreamBuilder::new();
builder.add_element(&ContentElement::Image(image));
let pending = builder.take_pending_images();
assert_eq!(pending.len(), 1);
assert!(builder.pending_images().is_empty());
assert!(builder.take_pending_images().is_empty());
}
#[test]
fn test_save_restore_state() {
let mut builder = ContentStreamBuilder::new();
builder.save_state().restore_state();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("q\n"));
assert!(content.contains("Q\n"));
}
#[test]
fn test_transform_matrix() {
let mut builder = ContentStreamBuilder::new();
builder.transform(1.0, 0.0, 0.0, 1.0, 100.0, 200.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("1 0 0 1 100 200 cm"));
}
#[test]
fn test_translate() {
let mut builder = ContentStreamBuilder::new();
builder.translate(50.0, 75.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("1 0 0 1 50 75 cm"));
}
#[test]
fn test_scale() {
let mut builder = ContentStreamBuilder::new();
builder.scale(2.0, 3.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("2 0 0 3 0 0 cm"));
}
#[test]
fn test_rotate() {
let mut builder = ContentStreamBuilder::new();
builder.rotate(std::f32::consts::PI / 2.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("cm"));
}
#[test]
fn test_rotate_degrees() {
let mut builder = ContentStreamBuilder::new();
builder.rotate_degrees(90.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("cm"));
}
#[test]
fn test_fill_color() {
let mut builder = ContentStreamBuilder::new();
builder.fill_color(Color {
r: 1.0,
g: 0.0,
b: 0.0,
});
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("1 0 0 rg"));
}
#[test]
fn test_stroke_color() {
let mut builder = ContentStreamBuilder::new();
builder.stroke_color(Color {
r: 0.0,
g: 1.0,
b: 0.0,
});
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0 1 0 RG"));
}
#[test]
fn test_set_fill_color_rgb() {
let mut builder = ContentStreamBuilder::new();
builder.set_fill_color(0.5, 0.6, 0.7);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0.5 0.6 0.7 rg"));
}
#[test]
fn test_set_stroke_color_rgb() {
let mut builder = ContentStreamBuilder::new();
builder.set_stroke_color(0.1, 0.2, 0.3);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0.1 0.2 0.3 RG"));
}
#[test]
fn test_set_line_width() {
let mut builder = ContentStreamBuilder::new();
builder.set_line_width(2.5);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("2.5 w"));
}
#[test]
fn test_move_to_and_line_to() {
let mut builder = ContentStreamBuilder::new();
builder.move_to(10.0, 20.0).line_to(30.0, 40.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("10 20 m"));
assert!(content.contains("30 40 l"));
}
#[test]
fn test_close_path() {
let mut builder = ContentStreamBuilder::new();
builder
.move_to(0.0, 0.0)
.line_to(100.0, 0.0)
.line_to(100.0, 100.0)
.close_path();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("h\n"));
}
#[test]
fn test_fill() {
let mut builder = ContentStreamBuilder::new();
builder.rect(0.0, 0.0, 100.0, 100.0).fill();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("re\n"));
assert!(content.contains("f\n"));
}
#[test]
fn test_fill_stroke() {
let mut builder = ContentStreamBuilder::new();
builder.rect(0.0, 0.0, 100.0, 100.0).fill_stroke();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("B\n"));
}
#[test]
fn test_fill_even_odd() {
let mut builder = ContentStreamBuilder::new();
builder.rect(0.0, 0.0, 100.0, 100.0).fill_even_odd();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("f*\n"));
}
#[test]
fn test_fill_stroke_even_odd() {
let mut builder = ContentStreamBuilder::new();
builder.rect(0.0, 0.0, 100.0, 100.0).fill_stroke_even_odd();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("B*\n"));
}
#[test]
fn test_close_fill_stroke() {
let mut builder = ContentStreamBuilder::new();
builder
.move_to(0.0, 0.0)
.line_to(100.0, 0.0)
.close_fill_stroke();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("b\n"));
}
#[test]
fn test_clip() {
let mut builder = ContentStreamBuilder::new();
builder.rect(10.0, 10.0, 200.0, 200.0).clip().end_path();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("W\n"));
assert!(content.contains("n\n"));
}
#[test]
fn test_clip_even_odd() {
let mut builder = ContentStreamBuilder::new();
builder
.rect(10.0, 10.0, 200.0, 200.0)
.clip_even_odd()
.end_path();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("W*\n"));
}
#[test]
fn test_clip_rect() {
let mut builder = ContentStreamBuilder::new();
builder.clip_rect(10.0, 10.0, 200.0, 200.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("10 10 200 200 re"));
assert!(content.contains("W\n"));
assert!(content.contains("n\n"));
}
#[test]
fn test_end_path() {
let mut builder = ContentStreamBuilder::new();
builder.rect(0.0, 0.0, 100.0, 100.0).end_path();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("n\n"));
}
#[test]
fn test_set_ext_gstate() {
let mut builder = ContentStreamBuilder::new();
builder.set_ext_gstate("GS0");
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/GS0 gs"));
}
#[test]
fn test_curve_to() {
let mut builder = ContentStreamBuilder::new();
builder
.move_to(0.0, 0.0)
.curve_to(10.0, 20.0, 30.0, 40.0, 50.0, 60.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("10 20 30 40 50 60 c"));
}
#[test]
fn test_curve_to_v() {
let mut builder = ContentStreamBuilder::new();
builder.move_to(0.0, 0.0).curve_to_v(10.0, 20.0, 30.0, 40.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("10 20 30 40 v"));
}
#[test]
fn test_curve_to_y() {
let mut builder = ContentStreamBuilder::new();
builder.move_to(0.0, 0.0).curve_to_y(10.0, 20.0, 30.0, 40.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("10 20 30 40 y"));
}
#[test]
fn test_circle() {
let mut builder = ContentStreamBuilder::new();
builder.circle(100.0, 100.0, 50.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("m\n"));
assert!(content.contains("c\n"));
assert!(content.contains("h\n")); }
#[test]
fn test_ellipse() {
let mut builder = ContentStreamBuilder::new();
builder.ellipse(200.0, 200.0, 80.0, 40.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("m\n"));
assert!(content.contains("c\n"));
assert!(content.contains("h\n"));
}
#[test]
fn test_rounded_rect() {
let mut builder = ContentStreamBuilder::new();
builder.rounded_rect(50.0, 50.0, 200.0, 100.0, 10.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("m\n"));
assert!(content.contains("l\n"));
assert!(content.contains("c\n"));
assert!(content.contains("h\n"));
}
#[test]
fn test_rounded_rect_large_radius() {
let mut builder = ContentStreamBuilder::new();
builder.rounded_rect(0.0, 0.0, 20.0, 40.0, 50.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("m\n"));
}
#[test]
fn test_set_line_cap() {
let mut builder = ContentStreamBuilder::new();
builder.set_line_cap(LineCap::Round);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("1 J"));
}
#[test]
fn test_set_line_cap_square() {
let mut builder = ContentStreamBuilder::new();
builder.set_line_cap(LineCap::Square);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("2 J"));
}
#[test]
fn test_set_line_join() {
let mut builder = ContentStreamBuilder::new();
builder.set_line_join(LineJoin::Round);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("1 j"));
}
#[test]
fn test_set_line_join_bevel() {
let mut builder = ContentStreamBuilder::new();
builder.set_line_join(LineJoin::Bevel);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("2 j"));
}
#[test]
fn test_set_miter_limit() {
let mut builder = ContentStreamBuilder::new();
builder.set_miter_limit(10.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("10 M"));
}
#[test]
fn test_set_dash_pattern() {
let mut builder = ContentStreamBuilder::new();
builder.set_dash_pattern(vec![3.0, 2.0], 0.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("[3 2] 0 d"));
}
#[test]
fn test_set_solid_line() {
let mut builder = ContentStreamBuilder::new();
builder.set_solid_line();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("[] 0 d"));
}
#[test]
fn test_set_fill_color_space() {
let mut builder = ContentStreamBuilder::new();
builder.set_fill_color_space("DeviceRGB");
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/DeviceRGB cs"));
}
#[test]
fn test_set_stroke_color_space() {
let mut builder = ContentStreamBuilder::new();
builder.set_stroke_color_space("DeviceCMYK");
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/DeviceCMYK CS"));
}
#[test]
fn test_set_fill_color_n() {
let mut builder = ContentStreamBuilder::new();
builder.set_fill_color_n(vec![0.1, 0.2, 0.3]);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0.1 0.2 0.3 scn"));
}
#[test]
fn test_set_stroke_color_n() {
let mut builder = ContentStreamBuilder::new();
builder.set_stroke_color_n(vec![0.4, 0.5]);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0.4 0.5 SCN"));
}
#[test]
fn test_set_fill_color_cmyk() {
let mut builder = ContentStreamBuilder::new();
builder.set_fill_color_cmyk(0.0, 1.0, 1.0, 0.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0 1 1 0 k"));
}
#[test]
fn test_set_stroke_color_cmyk() {
let mut builder = ContentStreamBuilder::new();
builder.set_stroke_color_cmyk(1.0, 0.0, 0.0, 0.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("1 0 0 0 K"));
}
#[test]
fn test_set_fill_pattern() {
let mut builder = ContentStreamBuilder::new();
builder.set_fill_pattern("P1", vec![]);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/P1 scn"));
}
#[test]
fn test_set_stroke_pattern() {
let mut builder = ContentStreamBuilder::new();
builder.set_stroke_pattern("P2", vec![0.5]);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0.5 /P2 SCN"));
}
#[test]
fn test_paint_shading() {
let mut builder = ContentStreamBuilder::new();
builder.paint_shading("Sh1");
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/Sh1 sh"));
}
#[test]
fn test_draw_gradient_rect() {
let mut builder = ContentStreamBuilder::new();
builder.draw_gradient_rect("Sh0", 10.0, 20.0, 200.0, 100.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("q\n")); assert!(content.contains("10 20 200 100 re"));
assert!(content.contains("W\n")); assert!(content.contains("n\n")); assert!(content.contains("/Sh0 sh"));
assert!(content.contains("Q\n")); }
#[test]
fn test_paint_xobject() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::PaintXObject("Img0".to_string()));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/Img0 Do"));
}
#[test]
fn test_close_stroke() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::CloseStroke);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("s\n"));
}
#[test]
fn test_close_fill_stroke_even_odd() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::CloseFillStrokeEvenOdd);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("b*\n"));
}
#[test]
fn test_set_fill_color_gray() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::SetFillColorGray(0.5));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0.5 g"));
}
#[test]
fn test_set_stroke_color_gray() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::SetStrokeColorGray(0.75));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("0.75 G"));
}
#[test]
fn test_set_character_spacing() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::SetCharacterSpacing(2.0));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("2 Tc"));
}
#[test]
fn test_set_word_spacing() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::SetWordSpacing(5.0));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("5 Tw"));
}
#[test]
fn test_set_text_leading() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::SetTextLeading(14.0));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("14 TL"));
}
#[test]
fn test_next_line() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::NextLine);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("T*"));
}
#[test]
fn test_move_text() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::MoveText(10.0, -14.0));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("10 -14 Td"));
}
#[test]
fn test_set_text_matrix() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::SetTextMatrix(1.0, 0.0, 0.0, 1.0, 72.0, 720.0));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("1 0 0 1 72 720 Tm"));
}
#[test]
fn test_show_hex_text() {
let mut builder = ContentStreamBuilder::new();
builder.begin_text();
builder.op(ContentStreamOp::ShowHexText("<0041004200>".to_string()));
builder.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("<0041004200> Tj"));
}
#[test]
fn test_show_text_array() {
let mut builder = ContentStreamBuilder::new();
builder.begin_text();
builder.op(ContentStreamOp::ShowTextArray(vec![
TextArrayItem::Text("Hello".to_string()),
TextArrayItem::Adjustment(-10.0),
TextArrayItem::Text("World".to_string()),
]));
builder.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("[(Hello) -10 (World) ] TJ"));
}
#[test]
fn test_show_text_array_with_hex() {
let mut builder = ContentStreamBuilder::new();
builder.begin_text();
builder.op(ContentStreamOp::ShowTextArray(vec![
TextArrayItem::HexText("<0041>".to_string()),
TextArrayItem::Adjustment(-50.0),
TextArrayItem::HexText("<0042>".to_string()),
]));
builder.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("<0041>"));
assert!(content.contains("<0042>"));
assert!(content.contains("TJ"));
}
#[test]
fn test_raw_operator() {
let mut builder = ContentStreamBuilder::new();
builder.op(ContentStreamOp::Raw("% custom comment".to_string()));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("% custom comment"));
}
#[test]
fn test_draw_image() {
let mut builder = ContentStreamBuilder::new();
builder.draw_image("Im1", 100.0, 200.0, 300.0, 400.0);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("q\n"));
assert!(content.contains("300 0 0 400 100 200 cm"));
assert!(content.contains("/Im1 Do"));
assert!(content.contains("Q\n"));
}
#[test]
fn test_hex_text_method() {
let mut builder = ContentStreamBuilder::new();
builder.begin_text();
builder.set_font("F1", 12.0);
builder.hex_text("<00410042>", 72.0, 720.0);
builder.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("<00410042> Tj"));
}
#[test]
fn test_begin_text_idempotent() {
let mut builder = ContentStreamBuilder::new();
builder.begin_text();
builder.begin_text(); builder.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
let bt_count = content.matches("BT\n").count();
assert_eq!(bt_count, 1);
}
#[test]
fn test_end_text_idempotent() {
let mut builder = ContentStreamBuilder::new();
builder.end_text(); builder.begin_text();
builder.end_text();
builder.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
let et_count = content.matches("ET\n").count();
assert_eq!(et_count, 1);
}
#[test]
fn test_set_font_caching() {
let mut builder = ContentStreamBuilder::new();
builder.begin_text();
builder.set_font("Helvetica", 12.0);
builder.set_font("Helvetica", 12.0); builder.set_font("Helvetica", 14.0); builder.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
let tf_count = content.matches("Tf\n").count();
assert_eq!(tf_count, 2);
}
#[test]
fn test_ops_method() {
let mut builder = ContentStreamBuilder::new();
builder.ops(vec![
ContentStreamOp::SaveState,
ContentStreamOp::SetLineWidth(2.0),
ContentStreamOp::RestoreState,
]);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("q\n"));
assert!(content.contains("2 w\n"));
assert!(content.contains("Q\n"));
}
#[test]
fn test_add_elements() {
let text1 = TextContent {
artifact_type: None,
text: "First".to_string(),
bbox: Rect::new(72.0, 720.0, 50.0, 12.0),
font: FontSpec::new("Helvetica", 12.0),
style: TextStyle::default(),
reading_order: Some(0),
origin: None,
rotation_degrees: None,
matrix: None,
};
let text2 = TextContent {
artifact_type: None,
text: "Second".to_string(),
bbox: Rect::new(72.0, 700.0, 50.0, 12.0),
font: FontSpec::new("Helvetica", 12.0),
style: TextStyle::default(),
reading_order: Some(1),
origin: None,
rotation_degrees: None,
matrix: None,
};
let mut builder = ContentStreamBuilder::new();
builder.add_elements(&[ContentElement::Text(text1), ContentElement::Text(text2)]);
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("(First) Tj"));
assert!(content.contains("(Second) Tj"));
}
#[test]
fn test_escaped_special_chars() {
let mut builder = ContentStreamBuilder::new();
builder
.begin_text()
.set_font("Helvetica", 12.0)
.text("line1\nline2\rtab\there", 72.0, 720.0)
.end_text();
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("\\n"));
assert!(content.contains("\\r"));
assert!(content.contains("\\t"));
}
#[test]
fn test_font_mapping_sans_serif() {
let builder = ContentStreamBuilder::new();
assert_eq!(builder.map_font_name("sans-serif", false), "Helvetica");
}
#[test]
fn test_font_mapping_serif() {
let builder = ContentStreamBuilder::new();
assert_eq!(builder.map_font_name("serif", false), "Times-Roman");
assert_eq!(builder.map_font_name("serif", true), "Times-Roman-Bold");
}
#[test]
fn test_font_mapping_monospace() {
let builder = ContentStreamBuilder::new();
assert_eq!(builder.map_font_name("monospace", false), "Courier");
assert_eq!(builder.map_font_name("monospace", true), "Courier-Bold");
}
#[test]
fn test_font_mapping_unknown() {
let builder = ContentStreamBuilder::new();
assert_eq!(builder.map_font_name("Unknown Font", false), "Helvetica");
assert_eq!(builder.map_font_name("Unknown Font", true), "Helvetica-Bold");
}
#[test]
fn test_blend_mode_names() {
assert_eq!(BlendMode::Normal.as_pdf_name(), "Normal");
assert_eq!(BlendMode::Multiply.as_pdf_name(), "Multiply");
assert_eq!(BlendMode::Screen.as_pdf_name(), "Screen");
assert_eq!(BlendMode::Overlay.as_pdf_name(), "Overlay");
assert_eq!(BlendMode::Darken.as_pdf_name(), "Darken");
assert_eq!(BlendMode::Lighten.as_pdf_name(), "Lighten");
assert_eq!(BlendMode::ColorDodge.as_pdf_name(), "ColorDodge");
assert_eq!(BlendMode::ColorBurn.as_pdf_name(), "ColorBurn");
assert_eq!(BlendMode::HardLight.as_pdf_name(), "HardLight");
assert_eq!(BlendMode::SoftLight.as_pdf_name(), "SoftLight");
assert_eq!(BlendMode::Difference.as_pdf_name(), "Difference");
assert_eq!(BlendMode::Exclusion.as_pdf_name(), "Exclusion");
}
#[test]
fn test_blend_mode_default() {
let mode = BlendMode::default();
assert_eq!(mode.as_pdf_name(), "Normal");
}
#[test]
fn test_line_cap_default() {
let cap = LineCap::default();
assert_eq!(cap as u8, 0);
}
#[test]
fn test_line_join_default() {
let join = LineJoin::default();
assert_eq!(join as u8, 0);
}
#[test]
fn test_path_content_stroke_and_fill() {
use crate::elements::PathContent;
let path = PathContent {
operations: vec![
PathOperation::MoveTo(0.0, 0.0),
PathOperation::LineTo(100.0, 0.0),
PathOperation::LineTo(100.0, 100.0),
PathOperation::ClosePath,
],
stroke_color: Some(Color::black()),
fill_color: Some(Color {
r: 1.0,
g: 0.0,
b: 0.0,
}),
stroke_width: 2.0,
bbox: Rect::new(0.0, 0.0, 100.0, 100.0),
line_cap: Default::default(),
line_join: Default::default(),
reading_order: None,
};
let mut builder = ContentStreamBuilder::new();
builder.add_element(&ContentElement::Path(path));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("B\n")); }
#[test]
fn test_path_content_stroke_only() {
use crate::elements::PathContent;
let path = PathContent {
operations: vec![
PathOperation::MoveTo(0.0, 0.0),
PathOperation::LineTo(100.0, 100.0),
],
stroke_color: Some(Color::black()),
fill_color: None,
stroke_width: 1.0,
bbox: Rect::new(0.0, 0.0, 100.0, 100.0),
line_cap: Default::default(),
line_join: Default::default(),
reading_order: None,
};
let mut builder = ContentStreamBuilder::new();
builder.add_element(&ContentElement::Path(path));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("S\n")); }
#[test]
fn test_path_content_fill_only() {
use crate::elements::PathContent;
let path = PathContent {
operations: vec![PathOperation::Rectangle(0.0, 0.0, 100.0, 100.0)],
stroke_color: None,
fill_color: Some(Color {
r: 0.0,
g: 0.0,
b: 1.0,
}),
stroke_width: 0.0,
bbox: Rect::new(0.0, 0.0, 100.0, 100.0),
line_cap: Default::default(),
line_join: Default::default(),
reading_order: None,
};
let mut builder = ContentStreamBuilder::new();
builder.add_element(&ContentElement::Path(path));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("f\n")); }
#[test]
fn test_path_content_no_stroke_no_fill() {
use crate::elements::PathContent;
let path = PathContent {
operations: vec![
PathOperation::MoveTo(0.0, 0.0),
PathOperation::CurveTo(10.0, 20.0, 30.0, 40.0, 50.0, 60.0),
],
stroke_color: None,
fill_color: None,
stroke_width: 0.0,
bbox: Rect::new(0.0, 0.0, 50.0, 60.0),
line_cap: Default::default(),
line_join: Default::default(),
reading_order: None,
};
let mut builder = ContentStreamBuilder::new();
builder.add_element(&ContentElement::Path(path));
let bytes = builder.build().unwrap();
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("n\n")); }
#[test]
fn test_empty_build() {
let builder = ContentStreamBuilder::new();
let bytes = builder.build().unwrap();
assert!(bytes.is_empty());
}
}