fix: audit perbaikan NATS subject, worker spawn, frontend options, pipeline PDF output

Bug fixes:
1. NATS subject doubled prefix — gateway publish ke tools.tools.scan.*
   padahal workers subscribe ke tools.scan.*. Fix: pakai group() method
2. Worker subscriptions pake tokio::select! — cuma satu subscription
   yang diproses, sisanya di-cancel. Fix: ganti ke tokio::spawn + loop
3. WebSocket URL hardcoded ke localhost:3001 — gak jalan di prod.
   Fix: infer dari window.location atau env var
4. Frontend scan options hardcoded — user gak bisa atur OCR/enhance/format.
   Fix: interactive toggles, selects, slider
5. Frontend compress quality hardcoded — user gak bisa atur kualitas.
   Fix: quality range slider
6. Pipeline cuma output PNG — gak ada PDF/OCR.
   Fix: generate searchable PDF kalo tesseract feature enabled

Enhancements:
- Tool::group() method added — returns 'scan', 'image', 'pdf' dll
- Dockerfile: --features tesseract pas cargo build
- leptess OCR: proper API usage (set_image_from_mem, recognize, etc.)

Co-Authored-By: Kilo <kilo@kilo.ai>
This commit is contained in:
asepharyana
2026-07-24 18:24:22 +07:00
co-authored by Kilo
parent 37cc3a7486
commit d2bfcb83a0
9 changed files with 290 additions and 121 deletions
+8 -10
View File
@@ -53,18 +53,16 @@ impl JobConsumer {
.await?;
tracing::info!("Subscribed to tools.scheduler.cleanup");
let redis_clone = redis.clone();
let config_clone = config.clone();
// Process messages concurrently — use tokio::spawn so ALL run
tokio::spawn(Self::process_subscription(scan_sub, redis.clone(), config.clone()));
tokio::spawn(Self::process_subscription(image_sub, redis.clone(), config.clone()));
tokio::spawn(Self::process_subscription(pdf_sub, redis.clone(), config.clone()));
tokio::spawn(Self::process_cleanup(cleanup_sub, config.clone()));
// Process messages concurrently
tokio::select! {
_ = Self::process_subscription(scan_sub, redis.clone(), config.clone()) => {},
_ = Self::process_subscription(image_sub, redis.clone(), config.clone()) => {},
_ = Self::process_subscription(pdf_sub, redis.clone(), config.clone()) => {},
_ = Self::process_cleanup(cleanup_sub, config_clone) => {},
// Wait forever so the process doesn't exit
loop {
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
}
Ok(())
}
/// Process messages from a NATS subscription.
+1 -1
View File
@@ -50,7 +50,7 @@ impl ProgressReporter {
message: message.to_string(),
};
let subject = format!("tools.{}.progress.{}", self.tool.subject_prefix(), self.job_id);
let subject = format!("tools.{}.progress.{}", self.tool.group(), self.job_id);
if let Ok(payload) = serde_json::to_vec(&progress_msg) {
let _ = self.nats.publish(subject, payload.into()).await;
}
+67 -70
View File
@@ -1,5 +1,4 @@
use image::GrayImage;
use tools_common::error::PipelineError;
/// OCR result with text and word-level bounding boxes.
@@ -26,87 +25,85 @@ pub struct Bbox {
pub height: i32,
}
/// Initialize Tesseract OCR engine.
/// Uses leptess crate which binds to libtesseract.
/// Falls back gracefully if Tesseract is not installed.
/// Run OCR on a grayscale image and return extracted text.
/// Requires the "tesseract" feature. Falls back gracefully when unavailable.
#[cfg(feature = "tesseract")]
fn init_tesseract(lang: &str) -> Result<leptess::LepTess, PipelineError> {
pub fn ocr_text(img: &GrayImage, lang: &str) -> Result<OcrResult, PipelineError> {
let tessdata_prefix = std::env::var("TESSDATA_PREFIX")
.unwrap_or_else(|_| "/usr/share/tesseract-ocr/5/tessdata".to_string());
let mut tess = leptess::LepTess::new(Some(&tessdata_prefix), lang)
.map_err(|e| PipelineError::Ocr(format!("Failed to init Tesseract: {}", e)))?;
Ok(tess)
}
tess.set_source_resolution(300);
/// Run OCR on a grayscale image and return extracted text.
/// Uses Tesseract via leptess crate when the "tesseract" feature is enabled.
/// Falls back to a placeholder when Tesseract is unavailable.
pub fn ocr_text(img: &GrayImage, lang: &str) -> Result<OcrResult, PipelineError> {
#[cfg(feature = "tesseract")]
{
let mut tess = init_tesseract(lang)?;
// Set image from raw bytes
let bytes = img.to_vec();
let w = img.width() as i32;
let h = img.height() as i32;
let width = img.width() as i32;
let height = img.height() as i32;
// Set image from memory
tess.set_image_from_mem(&img.to_vec(), width, height, 1, width)
.map_err(|e| PipelineError::Ocr(format!("Failed to set image: {}", e)))?;
tess.set_source_resolution(300);
// Set PSM to automatic
tess.set_page_seg_mode(3);
let text = tess.get_utf8_text()
.map_err(|e| PipelineError::Ocr(format!("OCR failed: {}", e)))?;
let words = tess.get_words()
.iter()
.map(|w| OcrWord {
text: w.text.clone(),
bbox: Bbox {
x: w.x,
y: w.y,
width: w.w,
height: w.h,
},
confidence: w.confidence,
})
.collect();
let confidence = if words.is_empty() {
0.0
} else {
words.iter().map(|w| w.confidence as f32).sum::<f32>() / words.len() as f32
};
Ok(OcrResult {
full_text: text,
words,
confidence,
})
// leptess expects raw 8-bit grayscale data
// Use set_image_from_mem which loads from memory buffer
if let Err(e) = tess.set_image_from_mem(&bytes) {
tracing::warn!("set_image_from_mem failed: {:?}, trying set_image", e);
return Err(PipelineError::Ocr(format!("set_image_from_mem failed: {:?}", e)));
}
#[cfg(not(feature = "tesseract"))]
{
tracing::warn!("Tesseract feature not enabled, OCR returning placeholder");
Ok(OcrResult {
full_text: String::new(),
words: Vec::new(),
confidence: 0.0,
})
tess.recognize();
let text = tess.get_utf8_text().unwrap_or_default();
let confidence = tess.mean_text_conf() as f32;
// Get word-level bounding boxes from LSTM box text
let words = if let Ok(box_text) = tess.get_lstm_box_text(1) {
parse_lstm_boxes(&box_text)
} else {
Vec::new()
};
Ok(OcrResult {
full_text: text,
words,
confidence,
})
}
/// Parse LSTM box text format: "word x1 y1 x2 y2 confidence"
fn parse_lstm_boxes(box_text: &str) -> Vec<OcrWord> {
let mut words = Vec::new();
for line in box_text.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 5 {
if let (Ok(x), Ok(y), Ok(x2), Ok(y2)) = (
parts[1].parse::<i32>(),
parts[2].parse::<i32>(),
parts[3].parse::<i32>(),
parts[4].parse::<i32>(),
) {
let confidence = parts.get(5).and_then(|v| v.parse::<i32>().ok()).unwrap_or(0);
words.push(OcrWord {
text: parts[0].to_string(),
bbox: Bbox {
x,
y,
width: (x2 - x).max(1),
height: (y2 - y).max(1),
},
confidence,
});
}
}
}
words
}
/// Run OCR on a grayscale image, returning only the text.
pub fn ocr_text_only(img: &GrayImage, lang: &str) -> Result<String, PipelineError> {
ocr_text(img, lang).map(|r| r.full_text)
}
/// Run OCR with word-level bounding boxes.
pub fn ocr_words(img: &GrayImage, lang: &str) -> Result<Vec<OcrWord>, PipelineError> {
ocr_text(img, lang).map(|r| r.words)
/// Non-tesseract fallback: return empty result.
#[cfg(not(feature = "tesseract"))]
pub fn ocr_text(_img: &GrayImage, _lang: &str) -> Result<OcrResult, PipelineError> {
tracing::warn!("Tesseract feature not enabled, OCR returning placeholder");
Ok(OcrResult {
full_text: String::new(),
words: Vec::new(),
confidence: 0.0,
})
}
+72 -7
View File
@@ -1,7 +1,6 @@
use std::path::Path;
use std::time::Instant;
use image::DynamicImage;
use tools_common::error::PipelineError;
use tools_common::types::Job;
@@ -75,13 +74,11 @@ pub async fn process(
report(progress, "enhance", 90, "Mengoptimalkan kualitas...").await;
let final_img = enhance_final(&final_img);
// Stage 9: Save output (93-100%)
// Stage 9: OCR + PDF/PNG output (93-100%)
report(progress, "save", 95, "Menyimpan hasil...").await;
let output_filename = format!("{}.png", progress.job_id());
let output_path = output_dir.join(&output_filename);
final_img.save(&output_path)?;
let job_id = progress.job_id();
let (output_path, ocr_text) = generate_output(&final_img, &job_id, &output_dir, &job.options)?;
let elapsed = start.elapsed().as_millis() as u64;
@@ -95,11 +92,79 @@ pub async fn process(
output_path: output_path.to_string_lossy().to_string(),
page_count: 1,
file_size: tokio::fs::metadata(&output_path).await.map(|m| m.len()).unwrap_or(0),
ocr_text: None,
ocr_text,
processing_time_ms: elapsed,
})
}
/// Generate final output file: PDF with OCR text layer, or fallback to PNG.
fn generate_output(
final_img: &image::GrayImage,
job_id: &uuid::Uuid,
output_dir: &Path,
options: &serde_json::Value,
) -> Result<(std::path::PathBuf, Option<String>), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(feature = "tesseract")]
{
let ocr_enabled = options
.get("ocr")
.and_then(|v| v.as_bool())
.unwrap_or(true);
if ocr_enabled {
let lang = options
.get("language")
.and_then(|v| v.as_str())
.unwrap_or("eng+ind");
match super::ocr::ocr_text(final_img, lang) {
Ok(ocr_result) => {
// Compress image as JPEG for PDF embedding
let output_filename = format!("{}.pdf", job_id);
let output_path = output_dir.join(&output_filename);
let jpeg_data = super::pdf::compress_image_jpeg(final_img, 85)
.map_err(|e| format!("JPEG compression failed: {}", e))?;
let page_width = super::pdf::A4_WIDTH_PT;
let page_height = super::pdf::A4_HEIGHT_PT;
let pdf_data = super::pdf::generate_searchable_pdf(
&jpeg_data,
&ocr_result.full_text,
&ocr_result.words,
page_width,
page_height,
)?;
std::fs::write(&output_path, &pdf_data)?;
let ocr_text = if ocr_result.full_text.is_empty() {
None
} else {
Some(ocr_result.full_text)
};
return Ok((output_path, ocr_text));
}
Err(e) => {
tracing::warn!("OCR failed, falling back to PNG: {}", e);
}
}
}
}
#[cfg(not(feature = "tesseract"))]
{
tracing::warn!("Tesseract feature not enabled, saving as PNG");
}
// Fallback: save as PNG
let output_filename = format!("{}.png", job_id);
let output_path = output_dir.join(&output_filename);
final_img.save(&output_path)?;
Ok((output_path, None))
}
/// Helper to report progress.
async fn report(progress: &ProgressReporter, stage: &str, pct: u8, msg: &str) {
let _ = progress