From d2bfcb83a05ebbcbda3e22ed44f00ac41916024b Mon Sep 17 00:00:00 2001 From: asepharyana Date: Fri, 24 Jul 2026 18:24:22 +0700 Subject: [PATCH] fix: audit perbaikan NATS subject, worker spawn, frontend options, pipeline PDF output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/common/src/types.rs | 14 +-- backend/gateway/src/nats/publisher.rs | 4 +- backend/workers/src/nats/consumer.rs | 18 ++- backend/workers/src/nats/progress.rs | 2 +- backend/workers/src/scanner/ocr.rs | 137 +++++++++++------------ backend/workers/src/scanner/pipeline.rs | 79 +++++++++++-- frontend/src/app/image/compress/page.tsx | 42 +++++-- frontend/src/app/scan/page.tsx | 103 +++++++++++++++-- frontend/src/hooks/use-job-status.ts | 12 +- 9 files changed, 290 insertions(+), 121 deletions(-) diff --git a/backend/common/src/types.rs b/backend/common/src/types.rs index 6e01d17..a92a19a 100644 --- a/backend/common/src/types.rs +++ b/backend/common/src/types.rs @@ -36,21 +36,21 @@ pub enum Tool { } impl Tool { - /// Returns the NATS subject prefix for this tool. - pub fn subject_prefix(&self) -> &'static str { + /// Returns the NATS subject group (without "tools." prefix). + pub fn group(&self) -> &'static str { match self { - Tool::Scan => "tools.scan", + Tool::Scan => "scan", Tool::ImageCompress | Tool::ImageResize | Tool::ImageConvert - | Tool::RemoveBg => "tools.image", + | Tool::RemoveBg => "image", Tool::PdfMerge | Tool::PdfSplit | Tool::ImagesToPdf | Tool::PdfCompress - | Tool::PdfToImages => "tools.pdf", - Tool::VideoCompress | Tool::VideoTrim | Tool::GifMaker => "tools.video", - Tool::AudioExtract | Tool::AudioConvert => "tools.audio", + | Tool::PdfToImages => "pdf", + Tool::VideoCompress | Tool::VideoTrim | Tool::GifMaker => "video", + Tool::AudioExtract | Tool::AudioConvert => "audio", } } diff --git a/backend/gateway/src/nats/publisher.rs b/backend/gateway/src/nats/publisher.rs index 36ee1f8..77c57b7 100644 --- a/backend/gateway/src/nats/publisher.rs +++ b/backend/gateway/src/nats/publisher.rs @@ -16,8 +16,8 @@ impl NatsPublisher { /// Publish a job to the appropriate NATS subject. pub async fn publish_job(nats: &Client, tool: &Tool, job: &Job) -> Result<(), NatsError> { - let prefix = tool.subject_prefix(); - let subject = nats::job_subject(prefix, &job.id.to_string()); + let group = tool.group(); + let subject = nats::job_subject(group, &job.id.to_string()); let payload = serde_json::to_vec(job) .map_err(|e| NatsError::Publish(e.to_string()))?; diff --git a/backend/workers/src/nats/consumer.rs b/backend/workers/src/nats/consumer.rs index 447e4ac..927cd01 100644 --- a/backend/workers/src/nats/consumer.rs +++ b/backend/workers/src/nats/consumer.rs @@ -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. diff --git a/backend/workers/src/nats/progress.rs b/backend/workers/src/nats/progress.rs index 0833434..2e7532d 100644 --- a/backend/workers/src/nats/progress.rs +++ b/backend/workers/src/nats/progress.rs @@ -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; } diff --git a/backend/workers/src/scanner/ocr.rs b/backend/workers/src/scanner/ocr.rs index 20c57f1..a0ef084 100644 --- a/backend/workers/src/scanner/ocr.rs +++ b/backend/workers/src/scanner/ocr.rs @@ -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 { +pub fn ocr_text(img: &GrayImage, lang: &str) -> Result { 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 { - #[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::() / 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 { + 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::(), + parts[2].parse::(), + parts[3].parse::(), + parts[4].parse::(), + ) { + let confidence = parts.get(5).and_then(|v| v.parse::().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 { - 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, 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 { + tracing::warn!("Tesseract feature not enabled, OCR returning placeholder"); + Ok(OcrResult { + full_text: String::new(), + words: Vec::new(), + confidence: 0.0, + }) } \ No newline at end of file diff --git a/backend/workers/src/scanner/pipeline.rs b/backend/workers/src/scanner/pipeline.rs index 6ac6eed..fae2349 100644 --- a/backend/workers/src/scanner/pipeline.rs +++ b/backend/workers/src/scanner/pipeline.rs @@ -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), Box> { + #[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 diff --git a/frontend/src/app/image/compress/page.tsx b/frontend/src/app/image/compress/page.tsx index f9cc09c..eb20d73 100644 --- a/frontend/src/app/image/compress/page.tsx +++ b/frontend/src/app/image/compress/page.tsx @@ -15,10 +15,11 @@ export default function ImageCompressPage() { const [pageState, setPageState] = useState("upload"); const [jobId, setJobId] = useState(null); const [errorMsg, setErrorMsg] = useState(null); + const [quality, setQuality] = useState(80); const { upload } = useUpload({ tool: "image-compress", - options: { quality: 80 }, + options: { quality }, }); const handleComplete = useCallback(() => setPageState("result"), []); @@ -61,11 +62,35 @@ export default function ImageCompressPage() { phase={1} > {pageState === "upload" && ( - +
+ {/* Quality Slider */} +
+
+ Quality + + {quality}% + +
+ setQuality(Number(e.target.value))} + className="w-full" + /> +
+ Kecil + Besar +
+
+ + +
)} {pageState === "processing" && jobId && ( @@ -79,10 +104,7 @@ export default function ImageCompressPage() { )} {pageState === "result" && result && ( - + )} {pageState === "error" && ( diff --git a/frontend/src/app/scan/page.tsx b/frontend/src/app/scan/page.tsx index 0bd6fb8..746d39b 100644 --- a/frontend/src/app/scan/page.tsx +++ b/frontend/src/app/scan/page.tsx @@ -10,14 +10,29 @@ import { useJobStatus } from "@/hooks/use-job-status"; type PageState = "upload" | "processing" | "result" | "error"; +interface ScanOptions { + ocr: boolean; + enhance: boolean; + output_format: "pdf" | "jpeg" | "png"; + dpi: number; + color_mode: "black_and_white" | "grayscale" | "color"; +} + export default function ScanPage() { const [pageState, setPageState] = useState("upload"); const [jobId, setJobId] = useState(null); const [errorMsg, setErrorMsg] = useState(null); + const [opts, setOpts] = useState({ + ocr: true, + enhance: true, + output_format: "pdf", + dpi: 300, + color_mode: "black_and_white", + }); const { upload, isUploading } = useUpload({ tool: "scan", - options: { ocr: true, enhance: true, output_format: "pdf", dpi: 300 }, + options: opts as unknown as Record, }); const handleComplete = useCallback(() => { @@ -75,15 +90,83 @@ export default function ScanPage() { maxSizeMB={50} /> - {/* Options info */} -
-

Scan Options

-
    -
  • • OCR: Enabled (English + Indonesian)
  • -
  • • Output: Searchable PDF
  • -
  • • DPI: 300
  • -
  • • Auto-enhance: On
  • -
+ {/* Interactive Options */} +
+

Scan Options

+ + {/* OCR Toggle */} + + + {/* Enhance Toggle */} + + + {/* Output Format */} + + + {/* Color Mode */} + + + {/* DPI */} +
)} diff --git a/frontend/src/hooks/use-job-status.ts b/frontend/src/hooks/use-job-status.ts index 27fb489..55ebddd 100644 --- a/frontend/src/hooks/use-job-status.ts +++ b/frontend/src/hooks/use-job-status.ts @@ -24,6 +24,7 @@ interface JobProgress { interface UseJobStatusOptions { onComplete?: (result: JobProgress["result"]) => void; onError?: (error: string) => void; + wsBaseUrl?: string; // optional override, e.g. "wss://tools.asepharyana.my.id" } export function useJobStatus(jobId: string | null, options?: UseJobStatusOptions) { @@ -40,10 +41,13 @@ export function useJobStatus(jobId: string | null, options?: UseJobStatusOptions const connect = useCallback(() => { if (!jobId) return; - // Connect directly to Rust gateway WebSocket (not via Next.js) - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const host = "localhost:3001"; // Rust gateway - wss for production - const url = `${protocol}//${host}/api/job/${jobId}/ws`; + // Determine WS base URL: from options, NEXT_PUBLIC_WS_URL, or infer from page location + const wsBase = options?.wsBaseUrl + ?? process.env.NEXT_PUBLIC_WS_URL + ?? (typeof window !== "undefined" + ? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}` + : "ws://localhost:3002"); + const url = `${wsBase}/api/job/${jobId}/ws`; const ws = new WebSocket(url); wsRef.current = ws;