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:
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user