Disegno Tecnico: BMP convertiti in PNG nel browser, errore upload nel riquadro. Refs #107

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 18:07:52 +01:00
parent 57376556d2
commit a3e032d000
7 changed files with 135 additions and 0 deletions
@@ -0,0 +1,38 @@
/**
* image_to_png.js - #107: converte nel browser le immagini che il backend non ammette (es. BMP) in PNG.
*
* Cosa fa: tmfNeedsPng(type) dice se il MIME e' un'immagine fuori da PNG/JPEG/GIF/WebP;
* tmfImageToPng(file) la ridisegna su canvas e ritorna un File PNG (null se illeggibile, es. TIFF).
* Come si usa: await tmfImageToPng(file) prima del ritaglio e del caricamento.
* Dipende da: createImageBitmap e canvas del browser (non servono nei test: il caso "nessuna conversione" e' puro).
*/
// #107: tipi che il backend ammette gia': non vanno convertiti
const TMF_PNG_PASSTHROUGH = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
// #107: true per un'immagine (image/*) non ammessa dal backend, es. image/bmp
function tmfNeedsPng(type) {
return typeof type === 'string' && type.indexOf('image/') === 0 && TMF_PNG_PASSTHROUGH.indexOf(type) === -1;
}
// #107: converte in PNG con canvas; ritorna il file invariato se non serve, null se il browser non sa decodificarlo
async function tmfImageToPng(file) {
if (!file || !tmfNeedsPng(file.type)) return file;
try {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
if (bitmap.close) bitmap.close();
const blob = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
if (!blob) return null;
const base = (file.name || 'immagine').replace(/\.[^.]+$/, '');
return new File([blob], base + '.png', { type: 'image/png' });
} catch (err) {
// #107: formato non decodificabile dal browser (es. TIFF)
return null;
}
}
// #107: export per il test node (nel browser module non esiste)
if (typeof module !== 'undefined') module.exports = { tmfNeedsPng, tmfImageToPng };
@@ -504,6 +504,7 @@
{% block extra_js %} {% block extra_js %}
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script> <script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
<script> <script>
// #56: stringhe e percorsi per il ritaglio prima dell'upload // #56: stringhe e percorsi per il ritaglio prima dell'upload
ImageCrop.configure({ ImageCrop.configure({
@@ -686,6 +687,13 @@ function recipeEditor() {
async uploadFile(file) { async uploadFile(file) {
if (!file) return; if (!file) return;
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
file = await tmfImageToPng(file);
if (!file) {
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
return;
}
// Validate file type // Validate file type
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
if (!allowed.includes(file.type)) { if (!allowed.includes(file.type)) {
@@ -879,6 +879,8 @@
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''"> @change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
</label> </label>
</template> </template>
<!-- #107: errore di caricamento anche dentro il riquadro, non solo nel banner in cima -->
<p x-show="uploadErrorTask === task.id && errorMessage" x-text="errorMessage" class="text-xs text-red-600 dark:text-red-400 mt-2"></p>
</div> </div>
<!-- Task Directive / Description (read-only display) --> <!-- Task Directive / Description (read-only display) -->
@@ -1484,6 +1486,7 @@
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script> <script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script> <script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script> <script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
<script> <script>
// #56: stringhe e percorsi per il ritaglio prima dell'upload // #56: stringhe e percorsi per il ritaglio prima dell'upload
ImageCrop.configure({ ImageCrop.configure({
@@ -1530,6 +1533,7 @@ function taskEditor() {
showAddTask: false, showAddTask: false,
saving: false, saving: false,
errorMessage: '', errorMessage: '',
uploadErrorTask: null, // #107: id del task il cui upload e' fallito (errore mostrato nel riquadro Disegno Tecnico)
successMessage: '', successMessage: '',
// ---- Task CRUD state ---- // ---- Task CRUD state ----
@@ -2415,14 +2419,24 @@ function taskEditor() {
async uploadTaskFile(taskId, file, task) { async uploadTaskFile(taskId, file, task) {
if (!file) return; if (!file) return;
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
file = await tmfImageToPng(file);
if (!file) {
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
this.uploadErrorTask = taskId;
return;
}
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf']; const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf'];
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }}; this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }};
this.uploadErrorTask = taskId; // #107
return; return;
} }
if (file.size > 20 * 1024 * 1024) { if (file.size > 20 * 1024 * 1024) {
this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }}; this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }};
this.uploadErrorTask = taskId; // #107
return; return;
} }
@@ -2432,6 +2446,7 @@ function taskEditor() {
this.saving = true; this.saving = true;
this.errorMessage = ''; this.errorMessage = '';
this.uploadErrorTask = null; // #107: nuovo tentativo, azzera l'errore del riquadro
try { try {
// 1. Upload file // 1. Upload file
@@ -2448,6 +2463,7 @@ function taskEditor() {
const uploadData = await uploadResp.json(); const uploadData = await uploadResp.json();
if (uploadData.error) { if (uploadData.error) {
this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }}; this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }};
this.uploadErrorTask = taskId; // #107
this.saving = false; this.saving = false;
return; return;
} }
@@ -2472,6 +2488,7 @@ function taskEditor() {
const updateData = await updateResp.json(); const updateData = await updateResp.json();
if (updateData.error) { if (updateData.error) {
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }}; this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
this.uploadErrorTask = taskId; // #107
this.saving = false; this.saving = false;
return; return;
} }
@@ -2487,6 +2504,7 @@ function taskEditor() {
} catch (err) { } catch (err) {
console.error('uploadTaskFile error:', err); console.error('uploadTaskFile error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }}; this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
this.uploadErrorTask = taskId; // #107
} }
this.saving = false; this.saving = false;
@@ -0,0 +1,32 @@
/**
* image_to_png.js, run with plain node (#107): solo la parte pura (niente canvas qui).
* Run from src/frontend/flask_app: node tests/js/test_image_to_png.js
*/
'use strict';
const path = require('path');
const { tmfNeedsPng, tmfImageToPng } = require(
path.join(__dirname, '..', '..', 'static', 'js', 'image_to_png.js'),
);
let failures = 0;
function check(label, cond) {
if (cond) { console.log('ok - ' + label); } else { console.error('FAIL - ' + label); failures++; }
}
check('bmp va convertito', tmfNeedsPng('image/bmp') === true);
check('tiff va convertito', tmfNeedsPng('image/tiff') === true);
check('png no', tmfNeedsPng('image/png') === false);
check('jpeg no', tmfNeedsPng('image/jpeg') === false);
check('gif no', tmfNeedsPng('image/gif') === false);
check('webp no', tmfNeedsPng('image/webp') === false);
check('pdf no', tmfNeedsPng('application/pdf') === false);
check('tipo vuoto no', tmfNeedsPng('') === false);
(async () => {
const fake = { name: 'a.png', type: 'image/png' };
check('png ritorna lo stesso oggetto', (await tmfImageToPng(fake)) === fake);
const pdf = { name: 'a.pdf', type: 'application/pdf' };
check('pdf ritorna lo stesso oggetto', (await tmfImageToPng(pdf)) === pdf);
if (failures) { console.error(failures + ' failure(s)'); process.exit(1); }
console.log('all ok');
})();
@@ -0,0 +1,31 @@
"""Runs the plain-node image_to_png.js test (tests/js/test_image_to_png.js).
Covers gitea:Adriano/TieMeasureFlow#107.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
JS_TEST = Path(__file__).resolve().parent / "js" / "test_image_to_png.js"
def test_image_to_png_js_pure_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS di image_to_png.js saltato")
result = subprocess.run(
[node, str(JS_TEST)],
cwd=str(FLASK_APP_DIR),
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"tests/js/test_image_to_png.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
@@ -3584,3 +3584,7 @@ msgstr "File not found"
#, python-format #, python-format
msgid "Servizio non raggiungibile: %(detail)s" msgid "Servizio non raggiungibile: %(detail)s"
msgstr "Service not reachable: %(detail)s" msgstr "Service not reachable: %(detail)s"
#: templates/maker/task_editor.html templates/maker/recipe_editor.html (#107)
msgid "Formato immagine non leggibile. Usa PNG, JPG o PDF."
msgstr "Unreadable image format. Use PNG, JPG or PDF."
@@ -3568,3 +3568,7 @@ msgstr "File non trovato"
#, python-format #, python-format
msgid "Servizio non raggiungibile: %(detail)s" msgid "Servizio non raggiungibile: %(detail)s"
msgstr "Servizio non raggiungibile: %(detail)s" msgstr "Servizio non raggiungibile: %(detail)s"
#: templates/maker/task_editor.html templates/maker/recipe_editor.html (#107)
msgid "Formato immagine non leggibile. Usa PNG, JPG o PDF."
msgstr "Formato immagine non leggibile. Usa PNG, JPG o PDF."