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>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user