Files
infra/apps/tools/backend/workers/src/scanner/pdf.rs
T
asepharyanaandKilo 67288c8723 feat(infra): add tools service with document scanner, image & PDF tools
Implement self-hosted document scanner and media processing tools as
an alternative to CamScanner/ilovepdf without third-party uploads.

Backend: Rust Axum gateway + worker pool with NATS JetStream queue
Frontend: Next.js 16 + shadcn/ui + Tailwind v4 + Framer Motion
Pipeline: Canny edge detection -> DLT homography warp -> Sauvola
binarization -> Hough deskew -> Tesseract OCR -> searchable PDF

Phase 1 (MVP) delivers:
- Document scanner with perspective correction and OCR
- Image compress/resize/convert tools
- PDF merge/split/compress tools
- Real-time WebSocket progress updates
- Rate limiting, auto-cleanup, Prometheus metrics
- Full CI/CD pipeline with Docker multi-stage build

Co-Authored-By: Kilo <kilo@kilo.ai>
2026-07-24 13:08:09 +07:00

190 lines
6.7 KiB
Rust

use image::GrayImage;
use lopdf::{Document, Object, Stream, Dictionary};
use tools_common::error::PipelineError;
/// A4 page dimensions in points (1 pt = 1/72 inch).
pub const A4_WIDTH_PT: f64 = 595.28;
pub const A4_HEIGHT_PT: f64 = 841.89;
/// Generate a searchable PDF with JPEG image + invisible OCR text layer.
pub fn generate_searchable_pdf(
image_data: &[u8],
_ocr_text: &str,
words: &[super::ocr::OcrWord],
page_width: f64,
page_height: f64,
) -> Result<Vec<u8>, PipelineError> {
let mut doc = Document::new();
// ── Pages object ──
let pages_id = doc.new_object_id();
let mut pages = Dictionary::new();
pages.set("Type", Object::Name("Pages".as_bytes().to_vec()));
pages.set("Kids", Object::Array(vec![]));
pages.set("Count", Object::Integer(0));
doc.objects.insert(pages_id, Object::Dictionary(pages));
// ── Image XObject ──
let mut img_dict = Dictionary::new();
img_dict.set("Type", Object::Name("XObject".as_bytes().to_vec()));
img_dict.set("Subtype", Object::Name("Image".as_bytes().to_vec()));
img_dict.set("Width", Object::Integer(page_width as i64));
img_dict.set("Height", Object::Integer(page_height as i64));
img_dict.set("ColorSpace", Object::Name("DeviceGray".as_bytes().to_vec()));
img_dict.set("BitsPerComponent", Object::Integer(8));
img_dict.set("Filter", Object::Name("DCTDecode".as_bytes().to_vec()));
let image_stream = Stream::new(img_dict, image_data.to_vec());
let image_id = doc.add_object(Object::Stream(image_stream));
// ── Content stream: place image + invisible text ──
let mut content = Vec::new();
// Place image at full page
content.extend_from_slice(b"q\n");
content.extend_from_slice(
format!("{} 0 0 {} 0 0 cm\n", page_width, page_height).as_bytes(),
);
content.extend_from_slice(b"/Im0 Do\n");
content.extend_from_slice(b"Q\n");
// Add invisible text layer (searchable)
for word in words {
let x = word.bbox.x as f64 / 300.0 * 72.0;
let y = page_height - (word.bbox.y as f64 / 300.0 * 72.0);
let font_size = (word.bbox.height as f64 / 300.0 * 72.0 * 0.8).max(4.0);
content.extend_from_slice(b"BT\n");
content.extend_from_slice(b"3 Tr\n"); // Rendering mode: invisible (neither fill nor stroke)
content.extend_from_slice(
format!("/F1 {} Tf\n{} {} Td\n", font_size, x, y - font_size).as_bytes(),
);
content.extend_from_slice(
format!("({}) Tj\n", escape_pdf_string(&word.text)).as_bytes(),
);
content.extend_from_slice(b"ET\n");
}
let content_stream = Stream::new(Dictionary::new(), content);
let content_id = doc.add_object(Object::Stream(content_stream));
// ── Font dictionary ──
let mut font_dict = Dictionary::new();
let mut f1 = Dictionary::new();
f1.set("Type", Object::Name("Font".as_bytes().to_vec()));
f1.set("Subtype", Object::Name("Type1".as_bytes().to_vec()));
f1.set("BaseFont", Object::Name("Helvetica".as_bytes().to_vec()));
font_dict.set("F1", Object::Dictionary(f1));
// ── Resources dictionary ──
let mut xobject_dict = Dictionary::new();
xobject_dict.set("Im0", Object::Reference(image_id));
let mut resources = Dictionary::new();
resources.set("XObject", Object::Dictionary(xobject_dict));
resources.set("Font", Object::Dictionary(font_dict));
// ── Page object ──
let page_id = doc.new_object_id();
let mut page = Dictionary::new();
page.set("Type", Object::Name("Page".as_bytes().to_vec()));
page.set("Parent", Object::Reference(pages_id));
page.set(
"MediaBox",
Object::Array(vec![
Object::Real(0.0),
Object::Real(0.0),
Object::Real(page_width as f32),
Object::Real(page_height as f32),
]),
);
page.set("Contents", Object::Reference(content_id));
page.set("Resources", Object::Dictionary(resources));
doc.objects.insert(page_id, Object::Dictionary(page));
// ── Update pages object ──
if let Some(Object::Dictionary(ref mut pages_dict)) = doc.objects.get_mut(&pages_id) {
pages_dict.set("Count", Object::Integer(1));
pages_dict.set("Kids", Object::Array(vec![Object::Reference(page_id)]));
}
// ── Save ──
let mut output = Vec::new();
doc.save_to(&mut output)
.map_err(|e| PipelineError::PdfGeneration(e.to_string()))?;
Ok(output)
}
/// Escape special characters for PDF string literals.
fn escape_pdf_string(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'(' => result.push_str("\\("),
')' => result.push_str("\\)"),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
other => result.push(other),
}
}
result
}
/// Compress grayscale image as JPEG bytes.
pub fn compress_image_jpeg(img: &GrayImage, quality: u8) -> Result<Vec<u8>, PipelineError> {
let mut bytes = Vec::new();
let rgb = image::DynamicImage::ImageLuma8(img.clone()).into_rgb8();
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality);
encoder
.encode(
rgb.as_raw(),
img.width(),
img.height(),
image::ExtendedColorType::Rgb8,
)
.map_err(|e| PipelineError::PdfGeneration(format!("JPEG compression failed: {}", e)))?;
Ok(bytes)
}
/// Compress RGB image data as JPEG bytes.
pub fn compress_rgb_image_jpeg(
data: &[u8],
width: u32,
height: u32,
quality: u8,
) -> Result<Vec<u8>, PipelineError> {
let mut bytes = Vec::new();
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality);
encoder
.encode(data, width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| PipelineError::PdfGeneration(format!("JPEG compression failed: {}", e)))?;
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use image::Luma;
#[test]
fn test_escape_pdf_string() {
assert_eq!(escape_pdf_string("hello"), "hello");
assert_eq!(escape_pdf_string("(parens)"), "\\(parens\\)");
assert_eq!(escape_pdf_string("back\\slash"), "back\\\\slash");
}
#[test]
fn test_jpeg_compression() {
let img = GrayImage::from_pixel(100, 100, Luma([128]));
let result = compress_image_jpeg(&img, 90);
assert!(result.is_ok(), "JPEG compression failed: {:?}", result.err());
let bytes = result.unwrap();
assert!(!bytes.is_empty());
assert_eq!(&bytes[0..2], &[0xFF, 0xD8]);
}
}