/** * Image Viewer — upload, display, bbox overlay */ import { state, emit, on, api, fitZoom, toast } from '../app.js'; const $ = id => document.getElementById(id); const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.tif', '.tiff', '.bmp', '.gif', '.webp']); function extensionOf(file) { const name = file?.name || ''; const dot = name.lastIndexOf('.'); return dot >= 0 ? name.slice(dot).toLowerCase() : ''; } function isImageOrPdf(file) { const ext = extensionOf(file); return file.type.startsWith('image/') || ext === '.pdf' || IMAGE_EXTENSIONS.has(ext); } export function initImageViewer() { const uploadArea = $('upload-area'); const fileInput = $('file-input'); const xmlInput = $('xml-input'); const viewerScroll = $('viewer-scroll'); const viewerPlaceholder = $('viewer-placeholder'); const handleDroppedFiles = files => { const img = files.find(isImageOrPdf); const xml = files.find(f => f.name.toLowerCase().endsWith('.xml')); if (img) uploadFile(img); if (xml) uploadXml(xml); // queued after image upload sets imageId }; // Click to browse image uploadArea.addEventListener('click', () => fileInput.click()); // File selected fileInput.addEventListener('change', () => { if (fileInput.files.length > 0) uploadFile(fileInput.files[0]); }); // Drag & drop — accept image, PDF, and XML on the upload box or viewer. const dropTargets = [uploadArea, viewerScroll, viewerPlaceholder].filter(Boolean); dropTargets.forEach(target => { target.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('dragover'); if (viewerPlaceholder && !state.imageId) viewerPlaceholder.classList.add('dragover'); }); target.addEventListener('dragleave', e => { if (!e.currentTarget.contains(e.relatedTarget)) { uploadArea.classList.remove('dragover'); viewerPlaceholder?.classList.remove('dragover'); } }); target.addEventListener('drop', e => { e.preventDefault(); uploadArea.classList.remove('dragover'); viewerPlaceholder?.classList.remove('dragover'); handleDroppedFiles(Array.from(e.dataTransfer.files)); }); }); // Keep the explicit upload area compatible with batch-panel's capture-phase // drop interception for multi-image queues. uploadArea.addEventListener('drop', e => { e.preventDefault(); }); // XML file picker xmlInput.addEventListener('change', () => { if (xmlInput.files.length > 0) uploadXml(xmlInput.files[0]); }); // Batch panel: load a completed item's image into the viewer on('batch-item-start', ({ imageId, filename }) => { state.imageId = imageId; // Clear bboxes immediately for the new item currentBboxes = []; currentRegions = []; const img = $('page-image'); img.src = `/api/image/${imageId}`; $('image-container').classList.remove('hidden'); $('viewer-placeholder').classList.add('hidden'); img.onload = () => { const canvas = $('overlay-canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; fitZoom(); // Redraw any bboxes that arrived before the image finished loading if (currentBboxes.length > 0) { drawBboxes(currentBboxes, -1, currentRegions); } else { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); } }; $('image-info').textContent = filename; $('xml-upload-row').classList.remove('hidden'); $('xml-status').textContent = 'No PAGE XML'; $('xml-status').classList.remove('xml-ok'); emit('transcription-start', {}); }); // Draw bboxes after segmentation; keep state.regions in sync on('sse-segmentation', data => { state.regions = data.regions || []; if (data.source === 'page') { // Page-level engine: clear any old line bboxes, don't draw full-page box drawBboxes([], -1, []); } else { drawBboxes(data.bboxes, -1, state.regions); } if (data.source === 'pagexml') { $('xml-status').textContent = `PAGE XML: ${data.num_lines} lines`; } }); // Highlight line on click from transcription panel on('highlight-line', ({ index }) => highlightBbox(index)); // Click on canvas → highlight the clicked bbox and emit highlight-line const canvas = $('overlay-canvas'); canvas.addEventListener('click', e => { if (currentBboxes.length === 0) return; const img = $('page-image'); // Scale factor: natural image coords / displayed canvas coords const scaleX = img.naturalWidth / img.clientWidth; const scaleY = img.naturalHeight / img.clientHeight; const rect = canvas.getBoundingClientRect(); const clickX = (e.clientX - rect.left) * scaleX; const clickY = (e.clientY - rect.top) * scaleY; for (let i = 0; i < currentBboxes.length; i++) { const [x1, y1, x2, y2] = currentBboxes[i]; if (clickX >= x1 && clickX <= x2 && clickY >= y1 && clickY <= y2) { emit('highlight-line', { index: i }); break; } } }); } async function uploadFile(file) { const formData = new FormData(); formData.append('file', file); $('image-info').textContent = 'Uploading...'; try { const resp = await fetch('/api/image/upload', { method: 'POST', body: formData, }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.detail); } const data = await resp.json(); // PDF: redirect all pages to batch panel if (data.is_pdf) { $('image-info').textContent = `PDF: ${data.num_pages} page(s) — added to batch queue`; emit('pdf-pages-ready', data); return; } state.imageId = data.image_id; state.imageInfo = data; // Display image — show container, hide placeholder const img = $('page-image'); img.src = `/api/image/${data.image_id}`; $('image-container').classList.remove('hidden'); $('viewer-placeholder').classList.add('hidden'); // Wait for image to load to size canvas and fit zoom img.onload = () => { const canvas = $('overlay-canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; fitZoom(); // sets img.style.width/height and canvas display size }; $('image-info').textContent = `${data.filename} (${data.width}×${data.height})`; // Show XML upload row $('xml-upload-row').classList.remove('hidden'); $('xml-status').textContent = 'No PAGE XML'; $('xml-status').classList.remove('xml-ok'); emit('image-uploaded', data); } catch (err) { $('image-info').textContent = `Error: ${err.message}`; toast(`Upload failed: ${err.message}`, 'error', 7000); } } async function uploadXml(file) { if (!state.imageId) { // Will retry after image upload finishes on('image-uploaded', () => uploadXml(file), { once: true }); return; } const xmlStatus = $('xml-status'); xmlStatus.textContent = 'Uploading XML...'; xmlStatus.classList.remove('xml-ok'); try { const formData = new FormData(); formData.append('file', file); const resp = await fetch(`/api/image/${state.imageId}/xml`, { method: 'POST', body: formData, }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.detail); } xmlStatus.textContent = `✓ ${file.name}`; xmlStatus.classList.add('xml-ok'); emit('xml-uploaded', { filename: file.name }); } catch (err) { xmlStatus.textContent = `XML error: ${err.message}`; } } let currentBboxes = []; let currentRegions = []; // Distinct colours for up to 8 regions (cycling) const REGION_COLORS = [ 'rgba(255, 160, 30, 0.55)', // orange 'rgba( 46, 213, 115, 0.55)', // green 'rgba(232, 65, 24, 0.55)', // red 'rgba( 52, 172, 224, 0.55)', // blue 'rgba(162, 16, 213, 0.55)', // purple 'rgba(255, 211, 42, 0.55)', // yellow 'rgba( 18, 203, 196, 0.55)', // teal 'rgba(253, 89, 166, 0.55)', // pink ]; function drawBboxes(bboxes, highlightIndex = -1, regions = []) { currentBboxes = bboxes; currentRegions = regions; const canvas = $('overlay-canvas'); const img = $('page-image'); const ctx = canvas.getContext('2d'); // Keep canvas display size in sync with zoom-controlled img size canvas.style.width = img.style.width || img.clientWidth + 'px'; canvas.style.height = img.style.height || img.clientHeight + 'px'; ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw region outlines first (underneath line boxes) regions.forEach((r, ri) => { const [x1, y1, x2, y2] = r.bbox; const color = REGION_COLORS[ri % REGION_COLORS.length]; ctx.strokeStyle = color; ctx.lineWidth = 2.5; ctx.setLineDash([8, 4]); ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); ctx.setLineDash([]); // Subtle fill ctx.fillStyle = color.replace('0.55', '0.07'); ctx.fillRect(x1, y1, x2 - x1, y2 - y1); // Region label ctx.fillStyle = color.replace('0.55', '0.9'); ctx.font = 'bold 13px sans-serif'; ctx.fillText(`R${ri + 1} (${r.num_lines} lines)`, x1 + 4, y1 + 16); }); // Draw line boxes on top for (let i = 0; i < bboxes.length; i++) { const [x1, y1, x2, y2] = bboxes[i]; const isHighlighted = i === highlightIndex; ctx.strokeStyle = isHighlighted ? '#e94560' : 'rgba(58, 134, 255, 0.6)'; ctx.lineWidth = isHighlighted ? 3 : 1.5; ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); if (isHighlighted) { ctx.fillStyle = 'rgba(233, 69, 96, 0.1)'; ctx.fillRect(x1, y1, x2 - x1, y2 - y1); } } } function highlightBbox(index) { if (currentBboxes.length > 0) { drawBboxes(currentBboxes, index, currentRegions); } }