diff --git a/client/blueprints/maker.py b/client/blueprints/maker.py index b40089e..1d616b7 100644 --- a/client/blueprints/maker.py +++ b/client/blueprints/maker.py @@ -1,8 +1,11 @@ """Maker blueprint - recipe creation and editing.""" -from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for +import requests as http_requests + +from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, session, url_for from flask_babel import gettext as _ from blueprints.auth import login_required, role_required +from config import Config from services.api_client import api_client maker_bp = Blueprint("maker", __name__, url_prefix="/maker") @@ -349,6 +352,25 @@ def api_upload_file(): return jsonify(resp), 201 +@maker_bp.route("/api/files/", methods=["GET"]) +@login_required +def api_get_file(file_path: str): + """Proxy: Serve file from API server (browser can't send X-API-Key).""" + api_key = session.get("api_key", "") + base_url = Config.API_SERVER_URL.rstrip("/") + resp = http_requests.get( + f"{base_url}/api/files/{file_path}", + headers={"X-API-Key": api_key}, + timeout=30, + ) + if resp.status_code != 200: + return Response(resp.text, status=resp.status_code) + return Response( + resp.content, + content_type=resp.headers.get("content-type", "application/octet-stream"), + ) + + @maker_bp.route("/api/files/", methods=["DELETE"]) @login_required @role_required("Maker") diff --git a/client/services/api_client.py b/client/services/api_client.py index 6d42e82..de6e0ac 100644 --- a/client/services/api_client.py +++ b/client/services/api_client.py @@ -40,6 +40,11 @@ class APIClient: try: error_body = response.json() detail = error_body.get("detail", str(error_body)) + # FastAPI 422 returns detail as a list of validation errors + if isinstance(detail, list): + detail = "; ".join( + e.get("msg", str(e)) for e in detail if isinstance(e, dict) + ) or str(detail) except Exception: detail = response.text or f"HTTP {response.status_code}" diff --git a/client/static/js/annotation-editor.js b/client/static/js/annotation-editor.js index 9fbc043..c77ffd2 100644 --- a/client/static/js/annotation-editor.js +++ b/client/static/js/annotation-editor.js @@ -90,7 +90,6 @@ function annotationEditor() { this.setupEvents(); this.setupKeyboard(); - this.resizeCanvas(); this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200); window.addEventListener('resize', this._debouncedResize); @@ -126,26 +125,63 @@ function annotationEditor() { this._onImageLoaded = function (e) { if (e.detail && e.detail.path) { - // Wait for x-show transition to reveal the canvas - setTimeout(function () { - self.resizeCanvas(); - self.loadBackgroundImage('/api/files/' + e.detail.path); - if (e.detail.annotations) { - self.loadAnnotationsJson(e.detail.annotations); - } - }, 150); + // Wait for x-show transition to reveal the canvas and DOM layout + self._deferredLoad( + '/maker/api/files/' + e.detail.path, + e.detail.annotations || null + ); } }; window.addEventListener('image-loaded', this._onImageLoaded); // ---- Initial load: check parent scope for existing file ---- + // Deferred to requestAnimationFrame so the browser has laid out the + // container (x-cloak removed, x-show applied) before we read dimensions. var filePath = this.currentFilePath; // from parent recipeEditor scope if (filePath) { - var annoJson = this.annotationsJson; // from parent recipeEditor scope - this.loadBackgroundImage('/api/files/' + filePath); - if (annoJson) { - this.loadAnnotationsJson(annoJson); + this._deferredLoad( + '/maker/api/files/' + filePath, + this.annotationsJson || null + ); + } else { + // No file yet — still resize so canvas is ready when image is uploaded + requestAnimationFrame(function () { + self.resizeCanvas(); + }); + } + }, + + /** + * Deferred load: waits for the DOM to be fully laid out (via + * requestAnimationFrame) then resizes the canvas and loads the image. + * Falls back to a second attempt after 300ms if the container still + * has zero width (e.g. Alpine x-show transition not yet complete). + */ + _deferredLoad(imageUrl, annotationsJson) { + var self = this; + + requestAnimationFrame(function () { + self.resizeCanvas(); + + // If container is still hidden / zero-width, retry after transition + if (!self.canvas.width) { + setTimeout(function () { + self.resizeCanvas(); + self._loadImageAndAnnotations(imageUrl, annotationsJson); + }, 350); + } else { + self._loadImageAndAnnotations(imageUrl, annotationsJson); } + }); + }, + + /** + * Internal: load background image and optionally annotations. + */ + _loadImageAndAnnotations(imageUrl, annotationsJson) { + this.loadBackgroundImage(imageUrl); + if (annotationsJson) { + this.loadAnnotationsJson(annotationsJson); } }, @@ -154,32 +190,93 @@ function annotationEditor() { // ---------------------------------------------------------------- /** - * Load an image as the canvas background. - * Scales the image to fit within the canvas while maintaining aspect ratio - * and never exceeding the original size (scale <= 1). + * Load an image or PDF as the canvas background. + * For PDFs, renders the first page via PDF.js then uses it as background. + * Scales to fit within the canvas while maintaining aspect ratio. * - * @param {string} imageUrl - URL of the image to load + * @param {string} imageUrl - URL of the image or PDF to load */ loadBackgroundImage(imageUrl) { if (!this.canvas) return; + if (!this.canvas.width) { + this.resizeCanvas(); + } + + var isPdf = imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf'); + + if (isPdf && typeof pdfjsLib !== 'undefined') { + this._loadPdfBackground(imageUrl); + } else { + this._loadImageBackground(imageUrl); + } + }, + + /** + * Render first page of a PDF via PDF.js and set as canvas background. + */ + _loadPdfBackground(pdfUrl) { + var self = this; + var loadingTask = pdfjsLib.getDocument(pdfUrl); + + loadingTask.promise.then(function (pdf) { + return pdf.getPage(1); + }).then(function (page) { + // Render at 2x scale for crisp display + var scale = 2; + var viewport = page.getViewport({ scale: scale }); + + // Off-screen canvas for rendering + var offCanvas = document.createElement('canvas'); + offCanvas.width = viewport.width; + offCanvas.height = viewport.height; + var ctx = offCanvas.getContext('2d'); + + var renderContext = { + canvasContext: ctx, + viewport: viewport, + }; + + page.render(renderContext).promise.then(function () { + // Convert rendered PDF page to data URL, then load as Fabric image + var dataUrl = offCanvas.toDataURL('image/png'); + self._loadImageBackground(dataUrl); + }); + }).catch(function (err) { + console.error('AnnotationEditor: failed to load PDF:', err); + }); + }, + + /** + * Load a raster image URL as the canvas background. + */ + _loadImageBackground(imageUrl) { + var self = this; + fabric.Image.fromURL( imageUrl, function (img) { if (!img || !img.width || !img.height) { - console.error('AnnotationEditor: failed to load background image'); + console.error('AnnotationEditor: failed to load background image from', imageUrl); return; } + if (!self.canvas.width) { + self.resizeCanvas(); + } + + var cw = self.canvas.width || 800; + var ch = self.canvas.height || 480; + var scale = Math.min( - this.canvas.width / img.width, - this.canvas.height / img.height, + cw / img.width, + ch / img.height, 1 // never upscale ); - this.canvas.setBackgroundImage( + self.canvas.setBackgroundImage( img, - this.canvas.renderAll.bind(this.canvas), + self.canvas.renderAll.bind(self.canvas), { scaleX: scale, scaleY: scale, @@ -188,9 +285,8 @@ function annotationEditor() { } ); - this.imageLoaded = true; - }.bind(this), - { crossOrigin: 'anonymous' } + self.imageLoaded = true; + } ); }, @@ -670,16 +766,25 @@ function annotationEditor() { /** * Resize the Fabric.js canvas to fill its parent container. * Maintains a minimum 5:3 aspect ratio (height = width * 0.6). + * + * Note: Fabric.js wraps the in its own div, so parentElement + * is the Fabric wrapper (which has a fixed pixel width from creation). + * We need the .canvas-container grandparent for the real layout width. */ resizeCanvas() { - var container = this.canvasEl ? this.canvasEl.parentElement : null; - if (!container || !this.canvas) return; + if (!this.canvasEl || !this.canvas) return; + + // Fabric wrapper → .canvas-container (or whatever holds the canvas) + var fabricWrapper = this.canvasEl.parentElement; + var container = fabricWrapper ? fabricWrapper.parentElement : null; + if (!container) return; var width = container.clientWidth; var height = Math.max(400, Math.round(width * 0.6)); this.canvas.setWidth(width); this.canvas.setHeight(height); + this.canvas.calcOffset(); this.canvas.renderAll(); }, diff --git a/client/static/js/annotation-viewer.js b/client/static/js/annotation-viewer.js index c6a29a0..41995d4 100644 --- a/client/static/js/annotation-viewer.js +++ b/client/static/js/annotation-viewer.js @@ -80,47 +80,90 @@ function annotationViewer() { }, /** - * Carica e renderizza l'immagine con scaling + * Carica e renderizza l'immagine (o la prima pagina PDF) con scaling */ loadImage() { + var isPdf = this.imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf'); + + if (isPdf && typeof pdfjsLib !== 'undefined') { + this._loadPdf(); + } else { + this._loadRasterImage(); + } + }, + + /** + * Render first page of a PDF via PDF.js + */ + _loadPdf() { + const self = this; + pdfjsLib.getDocument(this.imageUrl).promise.then(function (pdf) { + return pdf.getPage(1); + }).then(function (page) { + const pdfScale = 2; // render at 2x for clarity + const viewport = page.getViewport({ scale: pdfScale }); + + const offCanvas = document.createElement('canvas'); + offCanvas.width = viewport.width; + offCanvas.height = viewport.height; + const offCtx = offCanvas.getContext('2d'); + + page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () { + // Use rendered PDF page as if it were an image + self._drawImageOnCanvas(offCanvas, viewport.width, viewport.height); + }); + }).catch(function (err) { + console.error('AnnotationViewer: failed to load PDF:', err); + }); + }, + + /** + * Load a raster image (PNG, JPG, etc.) + */ + _loadRasterImage() { + const self = this; const img = new Image(); - img.onload = () => { - // Calculate scale to fit container - const container = this.canvas.parentElement; - if (!container) { - console.error('AnnotationViewer: parent container not found'); - return; - } - - const containerWidth = container.clientWidth; - const containerHeight = container.clientHeight || 500; // fallback height - - this.scale = Math.min( - containerWidth / img.width, - containerHeight / img.height, - 1 // non ingrandire oltre dimensione originale - ); - - this.canvas.width = img.width * this.scale; - this.canvas.height = img.height * this.scale; - - // Draw image - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height); - this.imageLoaded = true; - - // Draw annotations on top - this.drawAnnotations(); + img.onload = function () { + self._drawImageOnCanvas(img, img.width, img.height); }; - img.onerror = () => { - console.error('AnnotationViewer: failed to load image:', this.imageUrl); + img.onerror = function () { + console.error('AnnotationViewer: failed to load image:', self.imageUrl); }; img.src = this.imageUrl; }, + /** + * Draw an image source (Image or Canvas) scaled to fit the container + */ + _drawImageOnCanvas(source, srcWidth, srcHeight) { + const container = this.canvas.parentElement; + if (!container) { + console.error('AnnotationViewer: parent container not found'); + return; + } + + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight || 500; + + this.scale = Math.min( + containerWidth / srcWidth, + containerHeight / srcHeight, + 1 + ); + + this.canvas.width = srcWidth * this.scale; + this.canvas.height = srcHeight * this.scale; + + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.drawImage(source, 0, 0, this.canvas.width, this.canvas.height); + this.imageLoaded = true; + + this.drawAnnotations(); + }, + /** * Disegna tutti i markers sulle annotazioni */ diff --git a/client/templates/maker/recipe_editor.html b/client/templates/maker/recipe_editor.html index 0d30c8a..17dbc39 100644 --- a/client/templates/maker/recipe_editor.html +++ b/client/templates/maker/recipe_editor.html @@ -580,7 +580,11 @@ {% block extra_js %} - + + + + + +