use crate::pdf_reader::PdfMetadata;
use std::fs::File;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use thiserror::Error;
const POINTS_TO_MM: f32 = 0.352_778;
const MM_PER_INCH: f32 = 25.4;
const POINTS_PER_INCH: f32 = 72.0;
const DEFAULT_DPI: u32 = 300;
const HIGH_QUALITY_DPI: u32 = 600;
const COMPACT_DPI: u32 = 150;
const DEFAULT_JPEG_QUALITY: u8 = 90;
const HIGH_QUALITY_JPEG: u8 = 95;
const COMPACT_JPEG_QUALITY: u8 = 75;
const MIN_JPEG_QUALITY: u8 = 1;
const MAX_JPEG_QUALITY: u8 = 100;
#[derive(Debug, Error)]
pub enum PdfWriterError {
#[error("No images provided")]
NoImages,
#[error("Image not found: {0}")]
ImageNotFound(PathBuf),
#[error("Unsupported image format: {0}")]
UnsupportedFormat(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("PDF generation error: {0}")]
GenerationError(String),
}
pub type Result<T> = std::result::Result<T, PdfWriterError>;
#[derive(Debug, Clone)]
pub struct PdfWriterOptions {
pub dpi: u32,
pub jpeg_quality: u8,
pub compression: ImageCompression,
pub page_size_mode: PageSizeMode,
pub metadata: Option<PdfMetadata>,
pub ocr_layer: Option<OcrLayer>,
}
impl Default for PdfWriterOptions {
fn default() -> Self {
Self {
dpi: DEFAULT_DPI,
jpeg_quality: DEFAULT_JPEG_QUALITY,
compression: ImageCompression::Jpeg,
page_size_mode: PageSizeMode::FirstPage,
metadata: None,
ocr_layer: None,
}
}
}
impl PdfWriterOptions {
pub fn builder() -> PdfWriterOptionsBuilder {
PdfWriterOptionsBuilder::default()
}
pub fn high_quality() -> Self {
Self {
dpi: HIGH_QUALITY_DPI,
jpeg_quality: HIGH_QUALITY_JPEG,
compression: ImageCompression::JpegLossless,
..Default::default()
}
}
pub fn compact() -> Self {
Self {
dpi: COMPACT_DPI,
jpeg_quality: COMPACT_JPEG_QUALITY,
compression: ImageCompression::Jpeg,
..Default::default()
}
}
}
#[derive(Debug, Default)]
pub struct PdfWriterOptionsBuilder {
options: PdfWriterOptions,
}
impl PdfWriterOptionsBuilder {
#[must_use]
pub fn dpi(mut self, dpi: u32) -> Self {
self.options.dpi = dpi;
self
}
#[must_use]
pub fn jpeg_quality(mut self, quality: u8) -> Self {
self.options.jpeg_quality = quality.clamp(MIN_JPEG_QUALITY, MAX_JPEG_QUALITY);
self
}
#[must_use]
pub fn compression(mut self, compression: ImageCompression) -> Self {
self.options.compression = compression;
self
}
#[must_use]
pub fn page_size_mode(mut self, mode: PageSizeMode) -> Self {
self.options.page_size_mode = mode;
self
}
#[must_use]
pub fn metadata(mut self, metadata: PdfMetadata) -> Self {
self.options.metadata = Some(metadata);
self
}
#[must_use]
pub fn ocr_layer(mut self, layer: OcrLayer) -> Self {
self.options.ocr_layer = Some(layer);
self
}
#[must_use]
pub fn build(self) -> PdfWriterOptions {
self.options
}
}
#[derive(Debug, Clone, Copy, Default)]
pub enum ImageCompression {
#[default]
Jpeg,
JpegLossless,
Flate,
None,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum PageSizeMode {
#[default]
FirstPage,
MaxSize,
Fixed { width_pt: f64, height_pt: f64 },
Original,
}
#[derive(Debug, Clone)]
pub struct OcrLayer {
pub pages: Vec<OcrPageText>,
}
#[derive(Debug, Clone)]
pub struct OcrPageText {
pub page_index: usize,
pub blocks: Vec<TextBlock>,
}
#[derive(Debug, Clone)]
pub struct TextBlock {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub text: String,
pub font_size: f64,
pub vertical: bool,
}
pub trait PdfWriter {
fn create_from_images(
images: &[PathBuf],
output: &Path,
options: &PdfWriterOptions,
) -> Result<()>;
fn create_streaming(
images: impl Iterator<Item = PathBuf>,
output: &Path,
options: &PdfWriterOptions,
) -> Result<()>;
}
pub struct PrintPdfWriter;
impl PrintPdfWriter {
pub fn new() -> Self {
Self
}
pub fn create_from_images(
images: &[PathBuf],
output: &Path,
options: &PdfWriterOptions,
) -> Result<()> {
if images.is_empty() {
return Err(PdfWriterError::NoImages);
}
for img_path in images {
if !img_path.exists() {
return Err(PdfWriterError::ImageNotFound(img_path.clone()));
}
}
let first_img =
image::open(&images[0]).map_err(|e| PdfWriterError::GenerationError(e.to_string()))?;
let (width_px, height_px) = (first_img.width(), first_img.height());
let dpi = options.dpi as f64;
let width_mm = (width_px as f32 / dpi as f32) * MM_PER_INCH;
let height_mm = (height_px as f32 / dpi as f32) * MM_PER_INCH;
let title = options
.metadata
.as_ref()
.and_then(|m| m.title.as_deref())
.unwrap_or("Document");
let (doc, page1, layer1) = printpdf::PdfDocument::new(
title,
printpdf::Mm(width_mm),
printpdf::Mm(height_mm),
"Layer 1",
);
Self::add_image_to_layer(&doc, page1, layer1, &first_img, width_mm, height_mm)?;
if let Some(ref ocr_layer) = options.ocr_layer {
if let Some(page_text) = ocr_layer.pages.iter().find(|p| p.page_index == 0) {
let text_layer = doc.get_page(page1).add_layer("OCR Text");
Self::add_ocr_text(&doc, text_layer, page_text, height_mm)?;
}
}
for (img_idx, img_path) in images.iter().enumerate().skip(1) {
let img = image::open(img_path)
.map_err(|e| PdfWriterError::GenerationError(e.to_string()))?;
let dpi_f32 = options.dpi as f32;
let (w_px, h_px) = match options.page_size_mode {
PageSizeMode::FirstPage => (width_px, height_px),
PageSizeMode::Original => (img.width(), img.height()),
PageSizeMode::MaxSize => (width_px.max(img.width()), height_px.max(img.height())),
PageSizeMode::Fixed {
width_pt,
height_pt,
} => {
let w = (width_pt as f32 * dpi_f32 / POINTS_PER_INCH) as u32;
let h = (height_pt as f32 * dpi_f32 / POINTS_PER_INCH) as u32;
(w, h)
}
};
let w_mm = (w_px as f32 / dpi_f32) * MM_PER_INCH;
let h_mm = (h_px as f32 / dpi_f32) * MM_PER_INCH;
let (page, layer) = doc.add_page(printpdf::Mm(w_mm), printpdf::Mm(h_mm), "Layer 1");
Self::add_image_to_layer(&doc, page, layer, &img, w_mm, h_mm)?;
if let Some(ref ocr_layer) = options.ocr_layer {
if let Some(page_text) = ocr_layer.pages.iter().find(|p| p.page_index == img_idx) {
let text_layer = doc.get_page(page).add_layer("OCR Text");
Self::add_ocr_text(&doc, text_layer, page_text, h_mm)?;
}
}
}
let file = File::create(output)?;
let mut writer = BufWriter::new(file);
doc.save(&mut writer)
.map_err(|e| PdfWriterError::GenerationError(e.to_string()))?;
Ok(())
}
fn add_image_to_layer(
doc: &printpdf::PdfDocumentReference,
page: printpdf::PdfPageIndex,
layer: printpdf::PdfLayerIndex,
img: &image::DynamicImage,
width_mm: f32,
height_mm: f32,
) -> Result<()> {
use printpdf::{Image, ImageTransform, Mm, Px};
let rgb_img = img.to_rgb8();
let (img_width, img_height) = rgb_img.dimensions();
let image_data = printpdf::ImageXObject {
width: Px(img_width as usize),
height: Px(img_height as usize),
color_space: printpdf::ColorSpace::Rgb,
bits_per_component: printpdf::ColorBits::Bit8,
interpolate: true,
image_data: rgb_img.into_raw(),
image_filter: None,
clipping_bbox: None,
smask: None,
};
let image = Image::from(image_data);
let layer_ref = doc.get_page(page).get_layer(layer);
let dpi = 300.0_f32;
let img_width_pt = img_width as f32 * 72.0 / dpi;
let img_height_pt = img_height as f32 * 72.0 / dpi;
let width_pt = width_mm * 72.0 / MM_PER_INCH;
let height_pt = height_mm * 72.0 / MM_PER_INCH;
let scale_x = width_pt / img_width_pt;
let scale_y = height_pt / img_height_pt;
let transform = ImageTransform {
translate_x: Some(Mm(0.0)),
translate_y: Some(Mm(0.0)),
scale_x: Some(scale_x),
scale_y: Some(scale_y),
rotate: None,
dpi: Some(dpi),
};
image.add_to_layer(layer_ref, transform);
Ok(())
}
fn add_ocr_text(
doc: &printpdf::PdfDocumentReference,
layer: printpdf::PdfLayerReference,
page_text: &OcrPageText,
page_height_mm: f32,
) -> Result<()> {
use printpdf::Mm;
let font = doc
.add_builtin_font(printpdf::BuiltinFont::Helvetica)
.map_err(|e| PdfWriterError::GenerationError(e.to_string()))?;
for block in &page_text.blocks {
let x_mm = block.x as f32 * POINTS_TO_MM;
let y_mm = page_height_mm
- (block.y as f32 * POINTS_TO_MM)
- (block.height as f32 * POINTS_TO_MM);
let font_size_pt = block.font_size as f32;
layer.set_text_rendering_mode(printpdf::TextRenderingMode::Invisible);
layer.use_text(&block.text, font_size_pt, Mm(x_mm), Mm(y_mm), &font);
}
Ok(())
}
pub fn create_streaming(
images: impl Iterator<Item = PathBuf>,
output: &Path,
options: &PdfWriterOptions,
) -> Result<()> {
let images_vec: Vec<PathBuf> = images.collect();
Self::create_from_images(&images_vec, output, options)
}
}
impl Default for PrintPdfWriter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_empty_images_error() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("output.pdf");
let result = PrintPdfWriter::create_from_images(&[], &output, &PdfWriterOptions::default());
assert!(matches!(result, Err(PdfWriterError::NoImages)));
}
#[test]
fn test_nonexistent_image_error() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("output.pdf");
let images = vec![PathBuf::from("/nonexistent/image.jpg")];
let result =
PrintPdfWriter::create_from_images(&images, &output, &PdfWriterOptions::default());
assert!(matches!(result, Err(PdfWriterError::ImageNotFound(_))));
}
#[test]
fn test_default_options() {
let opts = PdfWriterOptions::default();
assert_eq!(opts.dpi, 300);
assert_eq!(opts.jpeg_quality, 90);
assert!(matches!(opts.compression, ImageCompression::Jpeg));
assert!(matches!(opts.page_size_mode, PageSizeMode::FirstPage));
assert!(opts.metadata.is_none());
assert!(opts.ocr_layer.is_none());
}
#[test]
fn test_single_image_to_pdf() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("output.pdf");
let images = vec![PathBuf::from("tests/fixtures/book_page_1.png")];
let options = PdfWriterOptions::default();
PrintPdfWriter::create_from_images(&images, &output, &options).unwrap();
assert!(output.exists());
}
#[test]
fn test_multiple_images_to_pdf() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("output.pdf");
let images: Vec<_> = (1..=10)
.map(|i| PathBuf::from(format!("tests/fixtures/book_page_{}.png", i)))
.collect();
let options = PdfWriterOptions::default();
PrintPdfWriter::create_from_images(&images, &output, &options).unwrap();
let doc = lopdf::Document::load(&output).unwrap();
assert_eq!(doc.get_pages().len(), 10);
}
#[test]
fn test_metadata_setting() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("output.pdf");
let images = vec![PathBuf::from("tests/fixtures/book_page_1.png")];
let options = PdfWriterOptions {
metadata: Some(PdfMetadata {
title: Some("Test Document".to_string()),
author: Some("Test Author".to_string()),
..Default::default()
}),
..Default::default()
};
PrintPdfWriter::create_from_images(&images, &output, &options).unwrap();
assert!(output.exists());
}
#[test]
fn test_jpeg_quality() {
let temp_dir = tempdir().unwrap();
let images = vec![PathBuf::from("tests/fixtures/book_page_1.png")];
let output_high = temp_dir.path().join("high.pdf");
let options_high = PdfWriterOptions {
jpeg_quality: 95,
..Default::default()
};
PrintPdfWriter::create_from_images(&images, &output_high, &options_high).unwrap();
let output_low = temp_dir.path().join("low.pdf");
let options_low = PdfWriterOptions {
jpeg_quality: 50,
..Default::default()
};
PrintPdfWriter::create_from_images(&images, &output_low, &options_low).unwrap();
assert!(output_high.exists());
assert!(output_low.exists());
}
#[test]
fn test_builder_pattern() {
let options = PdfWriterOptions::builder()
.dpi(600)
.jpeg_quality(95)
.compression(ImageCompression::JpegLossless)
.page_size_mode(PageSizeMode::MaxSize)
.build();
assert_eq!(options.dpi, 600);
assert_eq!(options.jpeg_quality, 95);
assert!(matches!(
options.compression,
ImageCompression::JpegLossless
));
assert!(matches!(options.page_size_mode, PageSizeMode::MaxSize));
}
#[test]
fn test_builder_quality_clamping() {
let options = PdfWriterOptions::builder().jpeg_quality(150).build();
assert_eq!(options.jpeg_quality, 100);
let options = PdfWriterOptions::builder().jpeg_quality(0).build();
assert_eq!(options.jpeg_quality, 1);
}
#[test]
fn test_high_quality_preset() {
let options = PdfWriterOptions::high_quality();
assert_eq!(options.dpi, 600);
assert_eq!(options.jpeg_quality, 95);
assert!(matches!(
options.compression,
ImageCompression::JpegLossless
));
}
#[test]
fn test_compact_preset() {
let options = PdfWriterOptions::compact();
assert_eq!(options.dpi, 150);
assert_eq!(options.jpeg_quality, 75);
assert!(matches!(options.compression, ImageCompression::Jpeg));
}
#[test]
fn test_streaming_generation() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("output.pdf");
let images = (1..=5).map(|i| PathBuf::from(format!("tests/fixtures/book_page_{}.png", i)));
let options = PdfWriterOptions::default();
PrintPdfWriter::create_streaming(images, &output, &options).unwrap();
assert!(output.exists());
let doc = lopdf::Document::load(&output).unwrap();
assert_eq!(doc.get_pages().len(), 5);
}
#[test]
fn test_ocr_layer_option() {
let ocr_layer = OcrLayer {
pages: vec![OcrPageText {
page_index: 0,
blocks: vec![TextBlock {
x: 100.0,
y: 100.0,
width: 200.0,
height: 20.0,
text: "Test OCR Text".to_string(),
font_size: 12.0,
vertical: false,
}],
}],
};
let options = PdfWriterOptions::builder()
.ocr_layer(ocr_layer.clone())
.build();
assert!(options.ocr_layer.is_some());
assert_eq!(options.ocr_layer.unwrap().pages.len(), 1);
}
#[test]
fn test_vertical_ocr_text() {
let vertical_block = TextBlock {
x: 50.0,
y: 100.0,
width: 20.0,
height: 200.0,
text: "縦書きテスト".to_string(),
font_size: 14.0,
vertical: true,
};
assert!(vertical_block.vertical);
assert_eq!(vertical_block.text, "縦書きテスト");
}
#[test]
fn test_different_size_images() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("output.pdf");
let images = vec![
PathBuf::from("tests/fixtures/book_page_1.png"),
PathBuf::from("tests/fixtures/sample_page.png"),
];
let options = PdfWriterOptions::builder()
.page_size_mode(PageSizeMode::Original)
.build();
PrintPdfWriter::create_from_images(&images, &output, &options).unwrap();
assert!(output.exists());
let doc = lopdf::Document::load(&output).unwrap();
assert_eq!(doc.get_pages().len(), 2);
}
#[test]
fn test_all_compression_types() {
let compressions = vec![
ImageCompression::Jpeg,
ImageCompression::JpegLossless,
ImageCompression::Flate,
ImageCompression::None,
];
for comp in compressions {
let options = PdfWriterOptions::builder().compression(comp).build();
match (comp, options.compression) {
(ImageCompression::Jpeg, ImageCompression::Jpeg) => {}
(ImageCompression::JpegLossless, ImageCompression::JpegLossless) => {}
(ImageCompression::Flate, ImageCompression::Flate) => {}
(ImageCompression::None, ImageCompression::None) => {}
_ => panic!("Compression mismatch"),
}
}
}
#[test]
fn test_all_page_size_modes() {
let options = PdfWriterOptions::builder()
.page_size_mode(PageSizeMode::FirstPage)
.build();
assert!(matches!(options.page_size_mode, PageSizeMode::FirstPage));
let options = PdfWriterOptions::builder()
.page_size_mode(PageSizeMode::MaxSize)
.build();
assert!(matches!(options.page_size_mode, PageSizeMode::MaxSize));
let options = PdfWriterOptions::builder()
.page_size_mode(PageSizeMode::Original)
.build();
assert!(matches!(options.page_size_mode, PageSizeMode::Original));
let options = PdfWriterOptions::builder()
.page_size_mode(PageSizeMode::Fixed {
width_pt: 612.0,
height_pt: 792.0,
})
.build();
if let PageSizeMode::Fixed {
width_pt,
height_pt,
} = options.page_size_mode
{
assert_eq!(width_pt, 612.0);
assert_eq!(height_pt, 792.0);
} else {
panic!("Expected Fixed page size mode");
}
}
#[test]
fn test_text_block_construction() {
let block = TextBlock {
x: 100.0,
y: 200.0,
width: 300.0,
height: 50.0,
text: "Sample text".to_string(),
font_size: 12.0,
vertical: false,
};
assert_eq!(block.x, 100.0);
assert_eq!(block.y, 200.0);
assert_eq!(block.width, 300.0);
assert_eq!(block.height, 50.0);
assert_eq!(block.text, "Sample text");
assert_eq!(block.font_size, 12.0);
assert!(!block.vertical);
}
#[test]
fn test_ocr_page_text_construction() {
let page_text = OcrPageText {
page_index: 5,
blocks: vec![TextBlock {
x: 0.0,
y: 0.0,
width: 100.0,
height: 20.0,
text: "Test".to_string(),
font_size: 10.0,
vertical: false,
}],
};
assert_eq!(page_text.page_index, 5);
assert_eq!(page_text.blocks.len(), 1);
}
#[test]
fn test_ocr_layer_construction() {
let layer = OcrLayer {
pages: vec![
OcrPageText {
page_index: 0,
blocks: vec![],
},
OcrPageText {
page_index: 1,
blocks: vec![],
},
],
};
assert_eq!(layer.pages.len(), 2);
}
#[test]
fn test_error_types() {
let _err1 = PdfWriterError::NoImages;
let _err2 = PdfWriterError::ImageNotFound(PathBuf::from("/test/path"));
let _err3 = PdfWriterError::UnsupportedFormat("test".to_string());
let _err4 = PdfWriterError::GenerationError("gen error".to_string());
let _err5: PdfWriterError =
std::io::Error::new(std::io::ErrorKind::NotFound, "test").into();
}
#[test]
fn test_print_pdf_writer_default() {
let writer = PrintPdfWriter;
let _ = writer;
}
#[test]
fn test_builder_with_metadata() {
let metadata = PdfMetadata {
title: Some("Test Doc".to_string()),
author: Some("Test Author".to_string()),
..Default::default()
};
let options = PdfWriterOptions::builder().metadata(metadata).build();
assert!(options.metadata.is_some());
let meta = options.metadata.unwrap();
assert_eq!(meta.title, Some("Test Doc".to_string()));
assert_eq!(meta.author, Some("Test Author".to_string()));
}
#[test]
fn test_dpi_setting() {
let options = PdfWriterOptions::builder().dpi(600).build();
assert_eq!(options.dpi, 600);
let options = PdfWriterOptions::builder().dpi(150).build();
assert_eq!(options.dpi, 150);
}
#[test]
fn test_ocr_layer_with_multiple_blocks() {
let layer = OcrLayer {
pages: vec![OcrPageText {
page_index: 0,
blocks: vec![
TextBlock {
x: 100.0,
y: 700.0,
width: 400.0,
height: 20.0,
text: "First line".to_string(),
font_size: 12.0,
vertical: false,
},
TextBlock {
x: 100.0,
y: 680.0,
width: 300.0,
height: 20.0,
text: "Second line".to_string(),
font_size: 12.0,
vertical: false,
},
],
}],
};
let options = PdfWriterOptions::builder().ocr_layer(layer).build();
assert!(options.ocr_layer.is_some());
let ocr = options.ocr_layer.unwrap();
assert_eq!(ocr.pages.len(), 1);
assert_eq!(ocr.pages[0].blocks.len(), 2);
}
#[test]
fn test_vertical_text_block() {
let vertical_block = TextBlock {
x: 500.0,
y: 100.0,
width: 20.0,
height: 600.0,
text: "縦書きテスト".to_string(),
font_size: 12.0,
vertical: true,
};
assert!(vertical_block.vertical);
assert!(vertical_block.height > vertical_block.width);
let horizontal_block = TextBlock {
x: 100.0,
y: 700.0,
width: 400.0,
height: 20.0,
text: "横書きテスト".to_string(),
font_size: 12.0,
vertical: false,
};
assert!(!horizontal_block.vertical);
assert!(horizontal_block.width > horizontal_block.height);
}
#[test]
fn test_page_size_modes() {
let modes = [
PageSizeMode::FirstPage,
PageSizeMode::MaxSize,
PageSizeMode::Original,
PageSizeMode::Fixed {
width_pt: 595.0,
height_pt: 842.0,
},
];
for mode in modes {
let options = PdfWriterOptions::builder().page_size_mode(mode).build();
match (mode, options.page_size_mode) {
(PageSizeMode::FirstPage, PageSizeMode::FirstPage) => {}
(PageSizeMode::MaxSize, PageSizeMode::MaxSize) => {}
(PageSizeMode::Original, PageSizeMode::Original) => {}
(
PageSizeMode::Fixed {
width_pt: w1,
height_pt: h1,
},
PageSizeMode::Fixed {
width_pt: w2,
height_pt: h2,
},
) => {
assert_eq!(w1, w2);
assert_eq!(h1, h2);
}
_ => panic!("Page size mode mismatch"),
}
}
}
#[test]
fn test_compression_types_in_builder() {
let compressions = [
ImageCompression::Jpeg,
ImageCompression::JpegLossless,
ImageCompression::Flate,
ImageCompression::None,
];
for compression in compressions {
let options = PdfWriterOptions::builder().compression(compression).build();
match (compression, options.compression) {
(ImageCompression::Jpeg, ImageCompression::Jpeg) => {}
(ImageCompression::JpegLossless, ImageCompression::JpegLossless) => {}
(ImageCompression::Flate, ImageCompression::Flate) => {}
(ImageCompression::None, ImageCompression::None) => {}
_ => panic!("Compression type mismatch"),
}
}
}
#[test]
fn test_metadata_all_fields() {
let metadata = PdfMetadata {
title: Some("Test Title".to_string()),
author: Some("Test Author".to_string()),
subject: Some("Test Subject".to_string()),
keywords: Some("keyword1, keyword2".to_string()),
creator: Some("Test Creator".to_string()),
producer: Some("superbook-pdf".to_string()),
creation_date: Some("2024-01-01".to_string()),
modification_date: Some("2024-01-02".to_string()),
};
assert_eq!(metadata.title, Some("Test Title".to_string()));
assert_eq!(metadata.author, Some("Test Author".to_string()));
assert_eq!(metadata.subject, Some("Test Subject".to_string()));
assert!(metadata.keywords.is_some());
assert!(metadata.keywords.as_ref().unwrap().contains("keyword1"));
assert_eq!(metadata.creator, Some("Test Creator".to_string()));
assert_eq!(metadata.producer, Some("superbook-pdf".to_string()));
assert!(metadata.creation_date.is_some());
assert!(metadata.modification_date.is_some());
}
#[test]
fn test_error_display_messages() {
let err = PdfWriterError::NoImages;
assert!(!err.to_string().is_empty());
let err = PdfWriterError::ImageNotFound(PathBuf::from("/path/to/image.png"));
assert!(err.to_string().contains("/path/to/image.png"));
let err = PdfWriterError::UnsupportedFormat("BMP".to_string());
assert!(err.to_string().contains("BMP"));
let err = PdfWriterError::GenerationError("PDF creation failed".to_string());
assert!(err.to_string().contains("PDF creation failed"));
}
#[test]
fn test_builder_chaining() {
let options = PdfWriterOptions::builder()
.dpi(300)
.jpeg_quality(90)
.compression(ImageCompression::Jpeg)
.page_size_mode(PageSizeMode::FirstPage)
.build();
assert_eq!(options.dpi, 300);
assert_eq!(options.jpeg_quality, 90);
assert!(matches!(options.compression, ImageCompression::Jpeg));
assert!(matches!(options.page_size_mode, PageSizeMode::FirstPage));
}
#[test]
fn test_dpi_boundary_values() {
let options_low = PdfWriterOptions::builder().dpi(72).build();
assert_eq!(options_low.dpi, 72);
let options_std = PdfWriterOptions::builder().dpi(300).build();
assert_eq!(options_std.dpi, 300);
let options_high = PdfWriterOptions::builder().dpi(1200).build();
assert_eq!(options_high.dpi, 1200);
let options_very_high = PdfWriterOptions::builder().dpi(2400).build();
assert_eq!(options_very_high.dpi, 2400);
}
#[test]
fn test_jpeg_quality_boundary_values() {
let options_min = PdfWriterOptions::builder().jpeg_quality(1).build();
assert_eq!(options_min.jpeg_quality, 1);
let options_max = PdfWriterOptions::builder().jpeg_quality(100).build();
assert_eq!(options_max.jpeg_quality, 100);
for quality in [25, 50, 75, 85, 95] {
let opts = PdfWriterOptions::builder().jpeg_quality(quality).build();
assert_eq!(opts.jpeg_quality, quality);
}
}
#[test]
fn test_text_block_various_dimensions() {
let small = TextBlock {
x: 0.0,
y: 0.0,
width: 10.0,
height: 5.0,
text: "X".to_string(),
font_size: 8.0,
vertical: false,
};
assert!(small.width > 0.0);
assert!(small.height > 0.0);
let full_width = TextBlock {
x: 0.0,
y: 700.0,
width: 595.0, height: 14.0,
text: "Full width text line".to_string(),
font_size: 12.0,
vertical: false,
};
assert_eq!(full_width.width, 595.0);
let tall_vertical = TextBlock {
x: 500.0,
y: 50.0,
width: 14.0,
height: 700.0,
text: "縦書き長文".to_string(),
font_size: 12.0,
vertical: true,
};
assert!(tall_vertical.height > tall_vertical.width);
assert!(tall_vertical.vertical);
}
#[test]
fn test_ocr_layer_many_pages() {
let pages: Vec<OcrPageText> = (0..100)
.map(|i| OcrPageText {
page_index: i,
blocks: vec![TextBlock {
x: 100.0,
y: 700.0,
width: 400.0,
height: 20.0,
text: format!("Page {} text", i + 1),
font_size: 12.0,
vertical: false,
}],
})
.collect();
let layer = OcrLayer { pages };
assert_eq!(layer.pages.len(), 100);
assert_eq!(layer.pages[0].page_index, 0);
assert_eq!(layer.pages[99].page_index, 99);
}
#[test]
fn test_ocr_page_text_many_blocks() {
let blocks: Vec<TextBlock> = (0..50)
.map(|i| TextBlock {
x: 50.0,
y: 800.0 - (i as f64 * 15.0),
width: 500.0,
height: 12.0,
text: format!("Line {}", i + 1),
font_size: 10.0,
vertical: false,
})
.collect();
let page = OcrPageText {
page_index: 0,
blocks,
};
assert_eq!(page.blocks.len(), 50);
}
#[test]
fn test_error_from_io_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
let pdf_err: PdfWriterError = io_err.into();
let msg = pdf_err.to_string();
assert!(msg.contains("access denied") || msg.contains("IO error"));
}
#[test]
fn test_options_clone() {
let original = PdfWriterOptions::builder()
.dpi(400)
.jpeg_quality(88)
.compression(ImageCompression::Flate)
.build();
let cloned = original.clone();
assert_eq!(cloned.dpi, original.dpi);
assert_eq!(cloned.jpeg_quality, original.jpeg_quality);
}
#[test]
fn test_text_block_clone() {
let original = TextBlock {
x: 100.0,
y: 200.0,
width: 300.0,
height: 50.0,
text: "Clone test".to_string(),
font_size: 14.0,
vertical: true,
};
let cloned = original.clone();
assert_eq!(cloned.x, original.x);
assert_eq!(cloned.y, original.y);
assert_eq!(cloned.text, original.text);
assert_eq!(cloned.vertical, original.vertical);
}
#[test]
fn test_ocr_layer_clone() {
let original = OcrLayer {
pages: vec![OcrPageText {
page_index: 0,
blocks: vec![],
}],
};
let cloned = original.clone();
assert_eq!(cloned.pages.len(), original.pages.len());
}
#[test]
fn test_options_debug_impl() {
let options = PdfWriterOptions::builder()
.dpi(300)
.jpeg_quality(90)
.build();
let debug_str = format!("{:?}", options);
assert!(debug_str.contains("PdfWriterOptions"));
assert!(debug_str.contains("300"));
}
#[test]
fn test_compression_debug_impl() {
let comp = ImageCompression::Flate;
let debug_str = format!("{:?}", comp);
assert!(debug_str.contains("Flate"));
}
#[test]
fn test_page_size_mode_debug_impl() {
let mode = PageSizeMode::Fixed {
width_pt: 595.0,
height_pt: 842.0,
};
let debug_str = format!("{:?}", mode);
assert!(debug_str.contains("Fixed"));
assert!(debug_str.contains("595"));
}
#[test]
fn test_compression_default() {
let comp: ImageCompression = Default::default();
assert!(matches!(comp, ImageCompression::Jpeg));
}
#[test]
fn test_page_size_mode_default() {
let mode: PageSizeMode = Default::default();
assert!(matches!(mode, PageSizeMode::FirstPage));
}
#[test]
fn test_fixed_page_size_various_standards() {
let a4 = PageSizeMode::Fixed {
width_pt: 595.0,
height_pt: 842.0,
};
if let PageSizeMode::Fixed {
width_pt,
height_pt,
} = a4
{
assert_eq!(width_pt, 595.0);
assert_eq!(height_pt, 842.0);
}
let letter = PageSizeMode::Fixed {
width_pt: 612.0,
height_pt: 792.0,
};
if let PageSizeMode::Fixed {
width_pt,
height_pt,
} = letter
{
assert_eq!(width_pt, 612.0);
assert_eq!(height_pt, 792.0);
}
let legal = PageSizeMode::Fixed {
width_pt: 612.0,
height_pt: 1008.0,
};
if let PageSizeMode::Fixed {
width_pt,
height_pt,
} = legal
{
assert_eq!(width_pt, 612.0);
assert_eq!(height_pt, 1008.0);
}
}
#[test]
fn test_metadata_default() {
let meta = PdfMetadata::default();
assert!(meta.title.is_none());
assert!(meta.author.is_none());
assert!(meta.subject.is_none());
}
#[test]
fn test_metadata_partial_fill() {
let meta = PdfMetadata {
title: Some("Only Title".to_string()),
..Default::default()
};
assert!(meta.title.is_some());
assert!(meta.author.is_none());
assert!(meta.subject.is_none());
}
#[test]
fn test_multiple_images_pdf_verify_pages() {
let temp_dir = tempdir().unwrap();
let output = temp_dir.path().join("multi.pdf");
let images: Vec<_> = (1..=3)
.map(|i| PathBuf::from(format!("tests/fixtures/book_page_{}.png", i)))
.collect();
PrintPdfWriter::create_from_images(&images, &output, &PdfWriterOptions::default()).unwrap();
let doc = lopdf::Document::load(&output).unwrap();
assert_eq!(doc.get_pages().len(), 3);
}
#[test]
fn test_text_block_unicode() {
let unicode_block = TextBlock {
x: 100.0,
y: 700.0,
width: 400.0,
height: 20.0,
text: "日本語テキスト 中文 한글 🎉".to_string(),
font_size: 12.0,
vertical: false,
};
assert!(unicode_block.text.contains("日本語"));
assert!(unicode_block.text.contains("中文"));
assert!(unicode_block.text.contains("한글"));
}
#[test]
fn test_text_block_empty_text() {
let empty_block = TextBlock {
x: 100.0,
y: 700.0,
width: 400.0,
height: 20.0,
text: "".to_string(),
font_size: 12.0,
vertical: false,
};
assert!(empty_block.text.is_empty());
}
#[test]
fn test_font_size_variations() {
let sizes = [6.0, 8.0, 10.0, 12.0, 14.0, 18.0, 24.0, 36.0, 72.0];
for size in sizes {
let block = TextBlock {
x: 0.0,
y: 0.0,
width: 100.0,
height: size * 1.2,
text: "Test".to_string(),
font_size: size,
vertical: false,
};
assert_eq!(block.font_size, size);
}
}
#[test]
fn test_builder_full_chain_with_all_options() {
let metadata = PdfMetadata {
title: Some("Full Test".to_string()),
author: Some("Tester".to_string()),
..Default::default()
};
let ocr_layer = OcrLayer { pages: vec![] };
let options = PdfWriterOptions::builder()
.dpi(600)
.jpeg_quality(95)
.compression(ImageCompression::JpegLossless)
.page_size_mode(PageSizeMode::MaxSize)
.metadata(metadata)
.ocr_layer(ocr_layer)
.build();
assert_eq!(options.dpi, 600);
assert_eq!(options.jpeg_quality, 95);
assert!(options.metadata.is_some());
assert!(options.ocr_layer.is_some());
}
#[test]
fn test_error_variants_display() {
let errors = [
PdfWriterError::NoImages,
PdfWriterError::ImageNotFound(PathBuf::from("/test.png")),
PdfWriterError::UnsupportedFormat("RAW".to_string()),
PdfWriterError::GenerationError("Failed".to_string()),
];
for err in &errors {
let display = format!("{}", err);
assert!(!display.is_empty());
}
}
#[test]
fn test_error_no_images_display() {
let err = PdfWriterError::NoImages;
let msg = format!("{}", err);
assert!(msg.contains("No images provided"));
}
#[test]
fn test_error_no_images_debug() {
let err = PdfWriterError::NoImages;
let debug = format!("{:?}", err);
assert!(debug.contains("NoImages"));
}
#[test]
fn test_error_image_not_found_display() {
let path = PathBuf::from("/test/missing_image.png");
let err = PdfWriterError::ImageNotFound(path);
let msg = format!("{}", err);
assert!(msg.contains("Image not found"));
assert!(msg.contains("missing_image.png"));
}
#[test]
fn test_error_image_not_found_debug() {
let path = PathBuf::from("/test/missing.png");
let err = PdfWriterError::ImageNotFound(path);
let debug = format!("{:?}", err);
assert!(debug.contains("ImageNotFound"));
}
#[test]
fn test_error_unsupported_format_display() {
let err = PdfWriterError::UnsupportedFormat("HEIC".to_string());
let msg = format!("{}", err);
assert!(msg.contains("Unsupported image format"));
assert!(msg.contains("HEIC"));
}
#[test]
fn test_error_unsupported_format_debug() {
let err = PdfWriterError::UnsupportedFormat("WEBP".to_string());
let debug = format!("{:?}", err);
assert!(debug.contains("UnsupportedFormat"));
}
#[test]
fn test_error_io_error_display() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
let err = PdfWriterError::IoError(io_err);
let msg = format!("{}", err);
assert!(msg.contains("IO error"));
}
#[test]
fn test_error_io_error_debug() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "output not found");
let err = PdfWriterError::IoError(io_err);
let debug = format!("{:?}", err);
assert!(debug.contains("IoError"));
}
#[test]
fn test_error_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::WriteZero, "disk full");
let writer_err: PdfWriterError = io_err.into();
let msg = format!("{}", writer_err);
assert!(msg.contains("IO error"));
}
#[test]
fn test_error_generation_error_display() {
let err = PdfWriterError::GenerationError("font embedding failed".to_string());
let msg = format!("{}", err);
assert!(msg.contains("PDF generation error"));
assert!(msg.contains("font embedding failed"));
}
#[test]
fn test_error_generation_error_debug() {
let err = PdfWriterError::GenerationError("image compression failed".to_string());
let debug = format!("{:?}", err);
assert!(debug.contains("GenerationError"));
}
#[test]
fn test_error_all_variants_debug_impl() {
let errors: Vec<PdfWriterError> = vec![
PdfWriterError::NoImages,
PdfWriterError::ImageNotFound(PathBuf::from("/test.png")),
PdfWriterError::UnsupportedFormat("SVG".to_string()),
PdfWriterError::IoError(std::io::Error::other("io")),
PdfWriterError::GenerationError("gen fail".to_string()),
];
for err in &errors {
let debug = format!("{:?}", err);
assert!(!debug.is_empty());
let display = format!("{}", err);
assert!(!display.is_empty());
}
}
#[test]
fn test_error_unsupported_format_empty() {
let err = PdfWriterError::UnsupportedFormat(String::new());
let msg = format!("{}", err);
assert!(msg.contains("Unsupported image format"));
}
#[test]
fn test_error_generation_error_with_details() {
let err = PdfWriterError::GenerationError("page 5: overflow at 0x1234".to_string());
let msg = format!("{}", err);
assert!(msg.contains("page 5"));
assert!(msg.contains("0x1234"));
}
#[test]
fn test_pdf_writer_types_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<PdfWriterOptions>();
assert_send_sync::<ImageCompression>();
assert_send_sync::<PageSizeMode>();
assert_send_sync::<OcrLayer>();
assert_send_sync::<OcrPageText>();
assert_send_sync::<TextBlock>();
}
#[test]
fn test_concurrent_options_building() {
use std::thread;
let handles: Vec<_> = (0..8)
.map(|i| {
thread::spawn(move || {
PdfWriterOptions::builder()
.dpi(300 + (i as u32 * 100))
.jpeg_quality(80 + (i as u8 * 2))
.compression(if i % 2 == 0 {
ImageCompression::Jpeg
} else {
ImageCompression::Flate
})
.build()
})
})
.collect();
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert_eq!(results.len(), 8);
for (i, opt) in results.iter().enumerate() {
assert_eq!(opt.dpi, 300 + (i as u32 * 100));
}
}
#[test]
fn test_parallel_text_block_creation() {
use rayon::prelude::*;
let blocks: Vec<_> = (0..100)
.into_par_iter()
.map(|i| TextBlock {
x: i as f64 * 10.0,
y: i as f64 * 20.0,
width: 100.0,
height: 20.0,
text: format!("Text block {}", i),
font_size: 12.0,
vertical: i % 2 == 0,
})
.collect();
assert_eq!(blocks.len(), 100);
for (i, block) in blocks.iter().enumerate() {
assert_eq!(block.x, i as f64 * 10.0);
assert!(block.text.contains(&i.to_string()));
}
}
#[test]
fn test_ocr_layer_thread_transfer() {
use std::thread;
let layer = OcrLayer {
pages: vec![OcrPageText {
page_index: 0,
blocks: vec![TextBlock {
x: 0.0,
y: 0.0,
width: 100.0,
height: 20.0,
text: "Test".to_string(),
font_size: 12.0,
vertical: false,
}],
}],
};
let handle = thread::spawn(move || {
assert_eq!(layer.pages.len(), 1);
assert_eq!(layer.pages[0].blocks.len(), 1);
layer.pages[0].blocks[0].text.clone()
});
let result = handle.join().unwrap();
assert_eq!(result, "Test");
}
#[test]
fn test_options_shared_across_threads() {
use std::sync::Arc;
use std::thread;
let options = Arc::new(
PdfWriterOptions::builder()
.dpi(600)
.jpeg_quality(95)
.build(),
);
let handles: Vec<_> = (0..4)
.map(|_| {
let opts = Arc::clone(&options);
thread::spawn(move || {
assert_eq!(opts.dpi, 600);
assert_eq!(opts.jpeg_quality, 95);
opts.dpi
})
})
.collect();
for handle in handles {
assert_eq!(handle.join().unwrap(), 600);
}
}
#[test]
fn test_dpi_boundary_minimum() {
let opts = PdfWriterOptions::builder().dpi(1).build();
assert_eq!(opts.dpi, 1);
}
#[test]
fn test_dpi_boundary_maximum() {
let opts = PdfWriterOptions::builder().dpi(2400).build();
assert_eq!(opts.dpi, 2400);
}
#[test]
fn test_jpeg_quality_clamped_to_min() {
let opts = PdfWriterOptions::builder().jpeg_quality(0).build();
assert_eq!(opts.jpeg_quality, MIN_JPEG_QUALITY); }
#[test]
fn test_jpeg_quality_clamped_to_max() {
let opts = PdfWriterOptions::builder().jpeg_quality(255).build();
assert_eq!(opts.jpeg_quality, MAX_JPEG_QUALITY); }
#[test]
fn test_text_block_zero_dimensions() {
let block = TextBlock {
x: 0.0,
y: 0.0,
width: 0.0,
height: 0.0,
text: String::new(),
font_size: 0.0,
vertical: false,
};
assert_eq!(block.width, 0.0);
assert_eq!(block.height, 0.0);
}
#[test]
fn test_text_block_large_dimensions() {
let block = TextBlock {
x: 10000.0,
y: 10000.0,
width: 5000.0,
height: 1000.0,
text: "Large page".to_string(),
font_size: 144.0,
vertical: true,
};
assert_eq!(block.width, 5000.0);
assert!(block.vertical);
}
#[test]
fn test_font_size_boundary_small() {
let block = TextBlock {
x: 0.0,
y: 0.0,
width: 100.0,
height: 10.0,
text: "Tiny".to_string(),
font_size: 1.0,
vertical: false,
};
assert_eq!(block.font_size, 1.0);
}
#[test]
fn test_font_size_boundary_large() {
let block = TextBlock {
x: 0.0,
y: 0.0,
width: 1000.0,
height: 500.0,
text: "HUGE".to_string(),
font_size: 500.0,
vertical: false,
};
assert_eq!(block.font_size, 500.0);
}
#[test]
fn test_fixed_page_size_zero() {
let mode = PageSizeMode::Fixed {
width_pt: 0.0,
height_pt: 0.0,
};
if let PageSizeMode::Fixed {
width_pt,
height_pt,
} = mode
{
assert_eq!(width_pt, 0.0);
assert_eq!(height_pt, 0.0);
}
}
#[test]
fn test_fixed_page_size_a4() {
let mode = PageSizeMode::Fixed {
width_pt: 595.28,
height_pt: 841.89,
};
if let PageSizeMode::Fixed {
width_pt,
height_pt,
} = mode
{
assert!((width_pt - 595.28).abs() < 0.01);
assert!((height_pt - 841.89).abs() < 0.01);
}
}
#[test]
fn test_ocr_page_text_empty_blocks() {
let page = OcrPageText {
page_index: 0,
blocks: vec![],
};
assert!(page.blocks.is_empty());
}
#[test]
fn test_ocr_layer_many_pages_boundary() {
let pages: Vec<OcrPageText> = (0..1000)
.map(|i| OcrPageText {
page_index: i,
blocks: vec![],
})
.collect();
let layer = OcrLayer { pages };
assert_eq!(layer.pages.len(), 1000);
}
}