| |
| |
| |
|
|
| 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); |
| }; |
|
|
| |
| uploadArea.addEventListener('click', () => fileInput.click()); |
|
|
| |
| fileInput.addEventListener('change', () => { |
| if (fileInput.files.length > 0) uploadFile(fileInput.files[0]); |
| }); |
|
|
| |
| 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)); |
| }); |
| }); |
|
|
| |
| |
| uploadArea.addEventListener('drop', e => { |
| e.preventDefault(); |
| }); |
|
|
| |
| xmlInput.addEventListener('change', () => { |
| if (xmlInput.files.length > 0) uploadXml(xmlInput.files[0]); |
| }); |
|
|
| |
| on('batch-item-start', ({ imageId, filename }) => { |
| state.imageId = imageId; |
| |
| 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(); |
| |
| 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', {}); |
| }); |
|
|
| |
| on('sse-segmentation', data => { |
| state.regions = data.regions || []; |
| if (data.source === 'page') { |
| |
| drawBboxes([], -1, []); |
| } else { |
| drawBboxes(data.bboxes, -1, state.regions); |
| } |
| if (data.source === 'pagexml') { |
| $('xml-status').textContent = `PAGE XML: ${data.num_lines} lines`; |
| } |
| }); |
|
|
| |
| on('highlight-line', ({ index }) => highlightBbox(index)); |
|
|
| |
| const canvas = $('overlay-canvas'); |
| canvas.addEventListener('click', e => { |
| if (currentBboxes.length === 0) return; |
|
|
| const img = $('page-image'); |
| |
| 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(); |
|
|
| |
| 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; |
|
|
| |
| const img = $('page-image'); |
| img.src = `/api/image/${data.image_id}`; |
| $('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(); |
| }; |
|
|
| $('image-info').textContent = `${data.filename} (${data.width}×${data.height})`; |
| |
| $('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) { |
| |
| 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 = []; |
|
|
| |
| const REGION_COLORS = [ |
| 'rgba(255, 160, 30, 0.55)', |
| 'rgba( 46, 213, 115, 0.55)', |
| 'rgba(232, 65, 24, 0.55)', |
| 'rgba( 52, 172, 224, 0.55)', |
| 'rgba(162, 16, 213, 0.55)', |
| 'rgba(255, 211, 42, 0.55)', |
| 'rgba( 18, 203, 196, 0.55)', |
| 'rgba(253, 89, 166, 0.55)', |
| ]; |
|
|
| function drawBboxes(bboxes, highlightIndex = -1, regions = []) { |
| currentBboxes = bboxes; |
| currentRegions = regions; |
| const canvas = $('overlay-canvas'); |
| const img = $('page-image'); |
| const ctx = canvas.getContext('2d'); |
|
|
| |
| 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); |
|
|
| |
| 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([]); |
| |
| ctx.fillStyle = color.replace('0.55', '0.07'); |
| ctx.fillRect(x1, y1, x2 - x1, y2 - y1); |
| |
| 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); |
| }); |
|
|
| |
| 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); |
| } |
| } |
|
|