Unisce lavoro/daitarn-tmf-visore: visore standard <tmf-viewer> (Refs #122)

This commit is contained in:
2026-10-28 22:06:43 +01:00
6 changed files with 897 additions and 30 deletions
@@ -0,0 +1,446 @@
/**
* image_viewer.js - #122: <tmf-viewer>, il visualizzatore immagine standard di TieMeasureFlow.
*
* Cosa fa: Custom Element in JS puro (senza Alpine ne' Tailwind) con zoom -/+, Adatta, Salva, percentuale,
* Ctrl+rotella intorno al puntatore, pan col puntatore, overlay SVG a tratto costante ed etichette.
* Sostituisce le quattro implementazioni di zoom di prima (pannello operatore #115, wizard DXF #113 e #104,
* pagina camera #111). Light DOM, stile iniettato una volta nel <head> (<style id="tmf-viewer-css">).
* Come si usa: <script> vision_overlay.js e save_image.js, poi image_viewer.js; poi
* <tmf-viewer src="..." width="1920" height="1080" save-name="task_14" fit-height></tmf-viewer>
* Attributi: src, width, height (px logici), save-name, save-src, no-save, fit-height,
* label-fit, label-save, title-in, title-out, title-save.
* Senza src, un figlio <svg> (DXF) va nello stage al 100 % (modo contenuto; Salva lo rasterizza in PNG).
* Proprieta': overlay (forme vs-tools -> VisionOverlay.svg), labels ([{key,x,y,text,cls}] in px logici),
* zoom (sola lettura); metodi fit(), zoomBy(f). Eventi (bubbling): viewer-load {width,height}, viewer-zoom {zoom,fit}.
* In Alpine: :src="...", x-effect="$el.overlay = ...; $el.labels = ...".
* Dipende da: window.VisionOverlay (facoltativo, per l'overlay), tmfSaveImage/tmfSaveBlob/tmfSvgToPngBlob di save_image.js
* (per Salva). Il modulo si carica anche in node (require): esporta solo TmfViewerMath, funzioni pure.
*/
(function () {
'use strict';
var TAG = 'tmf-viewer';
var STEP = 1.25; // #122: passo dei pulsanti +/- e della rotella (come #111 e #115)
// #122: matematica pura dello zoom, testata in node senza browser
var TmfViewerMath = {
// zoom che fa stare tutta l'immagine (w x h) nel box (boxW x boxH); 0 se non calcolabile. boxH puo' essere Infinity
fitZoom: function (boxW, boxH, w, h) {
if (!(boxW > 0) || !(boxH > 0) || !(w > 0) || !(h > 0)) return 0;
var f = Math.min(boxW / w, boxH / h);
return isFinite(f) && f > 0 ? f : 0;
},
// zoom ristretto a [fit * 0.5, max(8, fit * 8)]: il massimo segue il fit, cosi' un DXF in mm (fit ~10) si ingrandisce ancora
clampZoom: function (z, fit) {
var lo = fit * 0.5, hi = Math.max(8, fit * 8);
return Math.min(hi, Math.max(lo, z));
},
// nuovo scroll che tiene fermo il punto (px, py) del viewport passando dallo zoom z0 a z1
zoomAround: function (z0, z1, scrollL, scrollT, px, py) {
var k = z1 / z0;
return { scrollLeft: (scrollL + px) * k - px, scrollTop: (scrollT + py) * k - py };
},
pct: function (z) { return Math.round(z * 100) + ' %'; },
};
var CSS = [
'tmf-viewer{display:block;position:relative;overflow:hidden;background:#1f2937;min-width:0}',
'tmf-viewer *{box-sizing:border-box}',
'tmf-viewer [hidden]{display:none!important}',
'tmf-viewer .tmfv-vp{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;display:flex}',
'tmf-viewer[fit-height] .tmfv-vp{position:relative;width:100%}',
'tmf-viewer .tmfv-stage{position:relative;flex:none;margin:auto;user-select:none;-webkit-user-select:none}',
'tmf-viewer .tmfv-img{display:block;width:100%;height:100%;max-width:none;-webkit-user-drag:none}',
'tmf-viewer .tmfv-stage>svg:not(.tmfv-ov){display:block;width:100%;height:100%}',
'tmf-viewer .tmfv-ov{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:visible}',
'tmf-viewer .tmfv-labels{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}',
// #122: aspetto completo dell'etichetta; le classi `cls` della pagina (es. bg-red-600 text-white) cambiano solo i colori
'tmf-viewer .tmfv-label{position:absolute;transform:translate(8px,-50%);white-space:nowrap;font:700 12px/1.3 system-ui,sans-serif;',
'padding:2px 6px;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.4);pointer-events:none}',
':where(tmf-viewer .tmfv-label){color:#fff;background:rgba(0,0,0,.6)}',
'tmf-viewer .tmfv-bar{position:absolute;top:6px;right:6px;z-index:3;display:flex;align-items:center;gap:2px;padding:2px;',
'background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
'tmf-viewer .tmfv-bar button{appearance:none;border:0;border-radius:4px;background:transparent;color:#fff;',
'padding:2px 8px;font:inherit;cursor:pointer;min-width:26px}',
'tmf-viewer .tmfv-bar button:hover{background:rgba(255,255,255,.22)}',
'tmf-viewer .tmfv-pct{min-width:46px;text-align:center;font-variant-numeric:tabular-nums}',
].join('\n');
// #122: stile iniettato una volta sola nel <head>
function injectCss() {
if (typeof document === 'undefined' || document.getElementById('tmf-viewer-css')) return;
var st = document.createElement('style');
st.id = 'tmf-viewer-css';
st.textContent = CSS;
(document.head || document.documentElement).appendChild(st);
}
function num(v) { var n = parseFloat(v); return n > 0 && isFinite(n) ? n : 0; }
// #122: dimensioni logiche di un <svg> dal viewBox, altrimenti da width/height
function svgDims(svg) {
var vb = svg.viewBox && svg.viewBox.baseVal;
if (vb && vb.width > 0 && vb.height > 0) return { w: vb.width, h: vb.height };
var a = (svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
if (a.length === 4 && a[2] > 0 && a[3] > 0) return { w: a[2], h: a[3] };
return { w: num(svg.getAttribute('width')), h: num(svg.getAttribute('height')) };
}
function defineViewer() {
var OBSERVED = ['src', 'width', 'height', 'fit-height', 'no-save', 'save-name',
'label-fit', 'label-save', 'title-in', 'title-out', 'title-save'];
class TmfViewer extends HTMLElement {
static get observedAttributes() { return OBSERVED; }
constructor() {
super();
this._built = false;
this._w = 0; this._h = 0; // dimensioni logiche correnti
this._nw = 0; this._nh = 0; // dimensioni naturali dell'immagine
this._z = 0; this._fit = 0; this._auto = true;
this._ov = []; this._lb = [];
this._pan = null;
this._content = null; // modo contenuto: l'<svg> adottato
this._lastEvt = '';
}
connectedCallback() {
injectCss();
if (this._built) return;
this._built = true;
// proprieta' impostate prima dell'upgrade dell'elemento: le riporto sugli accessor
var self = this;
['overlay', 'labels'].forEach(function (k) {
if (Object.prototype.hasOwnProperty.call(self, k)) { var v = self[k]; delete self[k]; self[k] = v; }
});
var kids = Array.prototype.slice.call(this.children);
var bar = document.createElement('div'); bar.className = 'tmfv-bar'; bar.hidden = true;
this._bOut = this._btn('−'); this._bIn = this._btn('+'); this._bFit = this._btn(''); this._bSave = this._btn('');
this._pctEl = document.createElement('span'); this._pctEl.className = 'tmfv-pct';
bar.appendChild(this._bOut); bar.appendChild(this._bIn); bar.appendChild(this._bFit);
bar.appendChild(this._bSave); bar.appendChild(this._pctEl);
var vp = document.createElement('div'); vp.className = 'tmfv-vp';
var stage = document.createElement('div'); stage.className = 'tmfv-stage';
var img = document.createElement('img'); img.className = 'tmfv-img'; img.draggable = false; img.alt = ''; img.hidden = true;
var ov = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
ov.setAttribute('class', 'tmfv-ov'); ov.setAttribute('preserveAspectRatio', 'none');
var labels = document.createElement('div'); labels.className = 'tmfv-labels';
stage.appendChild(img); stage.appendChild(ov); stage.appendChild(labels);
vp.appendChild(stage);
this._bar = bar; this._vp = vp; this._stage = stage; this._img = img; this._ovEl = ov; this._lbEl = labels;
this.appendChild(bar); this.appendChild(vp);
this._bOut.onclick = function () { self.zoomBy(1 / STEP); };
this._bIn.onclick = function () { self.zoomBy(STEP); };
this._bFit.onclick = function () { self.fit(); };
this._bSave.onclick = function () { self._save(); };
img.addEventListener('load', function () { self._onLoad(); });
// #122: errore di caricamento -> evento viewer-error (bubbling) con l'URL che non si e' caricato
img.addEventListener('error', function () {
if (!self._hasSrc()) return; // l'errore di un src appena tolto non conta
self.dispatchEvent(new CustomEvent('viewer-error', { bubbles: true, detail: { src: self.getAttribute('src') } }));
});
// Ctrl+rotella = zoom intorno al puntatore; senza Ctrl la pagina scorre come sempre
vp.addEventListener('wheel', function (ev) { self._wheel(ev); }, { passive: false });
vp.addEventListener('pointerdown', function (ev) { self._panDown(ev); });
vp.addEventListener('pointermove', function (ev) { self._panMove(ev); });
vp.addEventListener('pointerup', function () { self._panEnd(); });
vp.addEventListener('pointercancel', function () { self._panEnd(); });
// resize del viewport: in stato «adatta» rifa Adatta; altrimenti aggiorna solo il fit
if (typeof ResizeObserver !== 'undefined') {
// #122: la callback cambia l'altezza del viewport (fit-height): rimando a un task dopo, senza «ResizeObserver loop»
this._ro = new ResizeObserver(function () {
if (self._raf) return;
var run = function () { self._raf = 0; self._onResize(); };
self._raf = setTimeout(run, 0);
});
this._ro.observe(vp);
}
// contenuto aggiunto dopo (x-html, template): lo adotto
if (typeof MutationObserver !== 'undefined') {
this._mo = new MutationObserver(function () { self._adopt(); });
this._mo.observe(this, { childList: true });
this._mo2 = new MutationObserver(function () { self._refresh(); });
}
kids.forEach(function (k) { self._take(k); });
this._texts();
this._loadSrc();
this._refresh();
}
disconnectedCallback() {
// gli osservatori restano: se l'elemento viene ricollegato il viewport e' lo stesso
}
attributeChangedCallback(name, oldV, newV) {
if (!this._built || oldV === newV) return;
if (name === 'src') this._loadSrc();
else if (name === 'width' || name === 'height') this._refresh();
else if (name === 'fit-height') { if (!this.hasAttribute('fit-height')) this._vp.style.height = ''; this._onResize(true); }
else this._texts();
}
_btn(txt) {
var b = document.createElement('button');
b.type = 'button'; b.textContent = txt;
return b;
}
// #122: testi e titoli dei pulsanti dagli attributi
_texts() {
var g = function (el, n, d) { var v = el.getAttribute(n); return v === null || v === '' ? d : v; };
this._bFit.textContent = g(this, 'label-fit', 'Adatta');
this._bSave.textContent = g(this, 'label-save', 'Salva');
this._bIn.title = g(this, 'title-in', 'Ingrandisci');
this._bOut.title = g(this, 'title-out', 'Riduci');
this._bSave.title = g(this, 'title-save', 'Salva immagine');
this._bSave.hidden = this.hasAttribute('no-save');
}
// #122: adotta un figlio <svg> come contenuto (modo contenuto, DXF)
_take(el) {
if (!el || el === this._bar || el === this._vp || el.parentNode !== this) return;
if (String(el.tagName).toLowerCase() !== 'svg') return;
if (this._content && this._content !== el && this._content.parentNode) this._content.parentNode.removeChild(this._content);
this._content = el;
this._stage.insertBefore(el, this._ovEl);
if (this._mo2) this._mo2.observe(el, { attributes: true, attributeFilter: ['viewBox', 'width', 'height'] });
}
_adopt() {
var kids = Array.prototype.slice.call(this.children), self = this, found = false;
kids.forEach(function (k) { if (k !== self._bar && k !== self._vp) { self._take(k); found = true; } });
if (found) this._refresh();
}
// #122: (ri)carica l'immagine dall'attributo src
_loadSrc() {
var src = this.getAttribute('src');
if (src) {
this._img.hidden = false;
this._img.src = src;
} else {
this._img.removeAttribute('src');
this._img.hidden = true;
this._nw = 0; this._nh = 0;
this._refresh();
}
}
_onLoad() {
var img = this._img;
this._nw = img.naturalWidth; this._nh = img.naturalHeight;
this._refresh();
this.dispatchEvent(new CustomEvent('viewer-load', { bubbles: true, detail: { width: this._w, height: this._h } }));
}
_hasSrc() { return !!this.getAttribute('src'); }
// #122: ricalcola le dimensioni logiche; Adatta se cambiano (o se la vista era ad Adatta), zoom mantenuto altrimenti
_refresh() {
if (!this._built) return;
var w = num(this.getAttribute('width')), h = num(this.getAttribute('height'));
if (this._hasSrc()) {
if (!w) w = this._nw;
if (!h) h = this._nh;
if (!this._nw || !this._nh) { if (!(w && h)) { w = 0; h = 0; } }
} else if (this._content) {
var d = svgDims(this._content);
if (!w) w = d.w;
if (!h) h = d.h;
} else { w = 0; h = 0; }
if (!(w > 0 && h > 0)) { w = 0; h = 0; }
var changed = w !== this._w || h !== this._h;
this._w = w; this._h = h;
this._bar.hidden = !w;
this._img.hidden = !this._hasSrc();
if (this._content) this._content.style.display = this._hasSrc() ? 'none' : '';
if (!w) { this._z = 0; this._fit = 0; this._auto = true; this._apply(); return; }
if (changed || this._auto || !this._z) this.fit(); else this._apply();
if (!this._z) this._apply(true); // riquadro nascosto: Adatta rimandata, ma overlay ed etichette (in %) si disegnano gia'
}
// #122: area utile del viewport: offset meno i bordi (client* escludono le barre che spariranno dopo l'Adatta)
_area() {
var vp = this._vp, cw = vp.clientWidth, ch = vp.clientHeight, bx = 0, by = 0;
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
var cs = getComputedStyle(vp);
bx = (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)) || 0;
by = (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)) || 0;
cw = vp.offsetWidth - bx;
ch = vp.offsetHeight - by;
}
return { cw: cw, ch: ch, by: by };
}
// zoom di Adatta per l'area corrente; 0 se non calcolabile (riquadro nascosto o vuoto)
_calcFit() {
if (!this._w) return 0;
var a = this._area();
if (this.hasAttribute('fit-height')) return TmfViewerMath.fitZoom(a.cw, Infinity, this._w, this._h);
return TmfViewerMath.fitZoom(a.cw, a.ch, this._w, this._h);
}
// #122: altezza del viewport in fit-height: segue l'immagine adattata alla larghezza
_fitHeightBox(fit) {
if (!this.hasAttribute('fit-height')) return;
this._vp.style.height = fit > 0 ? (this._h * fit + this._area().by) + 'px' : '';
}
fit() {
if (!this._built || !this._w) return;
var f = this._calcFit();
if (!(f > 0)) return; // riquadro nascosto: ci pensa l'osservatore
this._fit = f; this._z = f; this._auto = true;
this._fitHeightBox(f);
this._vp.scrollLeft = 0; this._vp.scrollTop = 0;
this._apply();
}
// resize del viewport: Adatta se la vista non e' stata zoomata, altrimenti fit aggiornato e zoom nei limiti
_onResize(force) {
if (!this._built || !this._w) return;
var f = this._calcFit();
if (!(f > 0)) return;
if (!force && Math.abs(f - this._fit) < 1e-9 && this._z) return;
if (this._auto) { this.fit(); return; }
this._fit = f;
this._z = TmfViewerMath.clampZoom(this._z, f);
this._fitHeightBox(f);
this._apply();
}
// #122: zoom di un fattore f tenendo fermo il punto (px, py) del viewport (default: il centro)
zoomBy(f, px, py) {
if (!this._built || !this._w || !this._z) return;
var vp = this._vp, z0 = this._z;
var z1 = TmfViewerMath.clampZoom(z0 * f, this._fit);
if (z1 === z0) return;
if (px == null) px = vp.clientWidth / 2;
if (py == null) py = vp.clientHeight / 2;
var s = TmfViewerMath.zoomAround(z0, z1, vp.scrollLeft, vp.scrollTop, px, py);
this._z = z1; this._auto = false;
this._apply(); // la dimensione dello stage cambia subito, poi si puo' scorrere
vp.scrollLeft = s.scrollLeft; vp.scrollTop = s.scrollTop;
this._apply(true);
}
get zoom() { return this._z; }
// #122: applica zoom allo stage, ridisegna overlay ed etichette, aggiorna barra e cursore, avvisa con viewer-zoom
_apply(quiet) {
if (!this._built) return;
var z = this._z;
this._stage.style.width = this._w && z ? (this._w * z) + 'px' : '0px';
this._stage.style.height = this._w && z ? (this._h * z) + 'px' : '0px';
this._pctEl.textContent = TmfViewerMath.pct(z);
this._drawOverlay();
this._drawLabels();
this._cursor();
if (quiet) return;
var key = z + '|' + this._fit;
if (key !== this._lastEvt) {
this._lastEvt = key;
this.dispatchEvent(new CustomEvent('viewer-zoom', { bubbles: true, detail: { zoom: z, fit: this._fit } }));
}
}
_canPan() {
var vp = this._vp;
return this._w > 0 && (vp.scrollWidth > vp.clientWidth + 1 || vp.scrollHeight > vp.clientHeight + 1);
}
_cursor() {
var can = this._canPan();
this._vp.style.cursor = can ? (this._pan ? 'grabbing' : 'grab') : 'default';
this._vp.style.touchAction = can ? 'none' : 'auto'; // il dito trascina solo se c'e' qualcosa da scorrere
}
_wheel(ev) {
if (!ev.ctrlKey) return;
ev.preventDefault();
var r = this._vp.getBoundingClientRect();
this.zoomBy(ev.deltaY < 0 ? STEP : 1 / STEP, ev.clientX - r.left - this._vp.clientLeft, ev.clientY - r.top - this._vp.clientTop);
}
_panDown(ev) {
var vp = this._vp;
if (!this._canPan() || (ev.pointerType === 'mouse' && ev.button)) return;
ev.preventDefault(); // senza, Chromium manda pointercancel al primo movimento
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
this._cursor();
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
}
_panMove(ev) {
var p = this._pan;
if (!p) return;
this._vp.scrollLeft = p.left - (ev.clientX - p.x);
this._vp.scrollTop = p.top - (ev.clientY - p.y);
}
_panEnd() { this._pan = null; this._cursor(); }
// #122: overlay SVG a tratto costante; ridisegnato a ogni zoom perche' le croci dei points restano a dimensione fissa
_drawOverlay() {
var ov = this._ovEl, VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
if (!this._w) { ov.innerHTML = ''; return; }
ov.setAttribute('viewBox', '0 0 ' + this._w + ' ' + this._h);
ov.innerHTML = VO && this._ov && this._ov.length ? VO.svg(this._ov, { zoom: this._z || 1 }) : '';
}
// #122: etichette in px logici -> % dello stage, cosi' seguono lo zoom senza ingrandirsi
_drawLabels() {
var box = this._lbEl;
while (box.firstChild) box.removeChild(box.firstChild);
if (!this._w) return;
var self = this;
(this._lb || []).forEach(function (l) {
if (!l || !isFinite(l.x) || !isFinite(l.y)) return;
var d = document.createElement('div');
d.className = 'tmfv-label' + (l.cls ? ' ' + l.cls : '');
if (l.key != null) d.setAttribute('data-key', l.key);
d.style.left = (l.x / self._w * 100) + '%';
d.style.top = (l.y / self._h * 100) + '%';
d.textContent = l.text == null ? '' : String(l.text);
box.appendChild(d);
});
}
get overlay() { return this._ov; }
set overlay(v) { this._ov = Array.isArray(v) ? v : []; if (this._built) this._drawOverlay(); }
get labels() { return this._lb; }
set labels(v) { this._lb = Array.isArray(v) ? v : []; if (this._built) this._drawLabels(); }
// #122: Salva = immagine grezza (src o save-src) o, in modo contenuto, l'SVG rasterizzato
_save() {
var name = this.getAttribute('save-name') || 'immagine';
if (this._hasSrc()) {
if (typeof tmfSaveImage === 'function') return tmfSaveImage(this.getAttribute('save-src') || this.getAttribute('src'), name);
return Promise.resolve(false);
}
if (!this._content || typeof tmfSvgToPngBlob !== 'function' || typeof tmfSaveBlob !== 'function') return Promise.resolve(false);
var self = this;
return tmfSvgToPngBlob(this._content, this._w, this._h).then(function (blob) {
return tmfSaveBlob(blob, name);
}).catch(function (err) {
if (typeof console !== 'undefined') console.error('tmf-viewer salva:', err);
return false;
});
}
}
return TmfViewer;
}
// customElements.define una sola volta, anche se lo script e' caricato due volte
if (typeof customElements !== 'undefined' && typeof HTMLElement !== 'undefined' &&
!(typeof customElements.get === 'function' && customElements.get(TAG))) {
customElements.define(TAG, defineViewer());
if (typeof document !== 'undefined') injectCss();
}
if (typeof window !== 'undefined') window.TmfViewerMath = TmfViewerMath;
if (typeof module !== 'undefined' && module.exports) module.exports = { TmfViewerMath: TmfViewerMath };
})();
+102 -27
View File
@@ -8,6 +8,8 @@
* Come si usa: <script src=".../save_image.js"></script> poi @click="tmfSaveImage(url, 'task_14')".
* Dipende da: fetch, canvas, createImageBitmap e window del browser, toccati solo dentro le funzioni
* (il modulo si carica anche in node, con require, senza questi globali).
* #122: tmfSaveBlob(blob, baseName) salva un blob PNG gia' pronto; tmfSvgToPngBlob(svgEl, w, h) rasterizza un <svg>
* (sfondo bianco, lato lungo 1600-4096 px). Entrambi usati da <tmf-viewer> (image_viewer.js).
* Idea di conversione da image_to_png.js (#107).
*/
@@ -38,46 +40,119 @@ async function tmfBlobToPng(blob) {
return png;
}
// #122: «Salva con nome» (showSaveFilePicker) dove c'e'; null se manca (si ripiega su <a download>). Puo' lanciare AbortError
async function tmfPickPng(name) {
if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') {
return window.showSaveFilePicker({
suggestedName: name,
types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }],
});
}
return null;
}
// #122: scrive il blob sul file scelto (handle) oppure, senza handle, lo scarica con <a download>
async function tmfWritePng(handle, blob, name) {
if (handle) {
var w = await handle.createWritable();
await w.write(blob);
await w.close();
return;
}
// #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): <a download>
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
// #122: gestione comune dell'errore di salvataggio: l'annullo dell'utente non e' un errore
function tmfSaveFailed(err, tag) {
if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore
if (typeof console !== 'undefined') console.error(tag + ':', err);
return false;
}
// #122: salva un blob PNG gia' pronto (es. l'SVG del DXF rasterizzato) come <baseName>_<data>.png; true = salvato
async function tmfSaveBlob(blob, baseName) {
if (!blob) return false;
try {
var name = tmfSaveImageName(baseName, new Date());
var handle = await tmfPickPng(name);
await tmfWritePng(handle, blob, name);
return true;
} catch (err) {
return tmfSaveFailed(err, 'tmfSaveBlob');
}
}
// #122: rasterizza un <svg> in PNG (sfondo bianco). w,h = dimensioni logiche (default dal viewBox o dagli attributi);
// lato lungo portato fra 1600 e 4096 px. Ritorna una Promise<Blob> o lancia se il browser non riesce a disegnarlo.
async function tmfSvgToPngBlob(svgEl, w, h) {
var vb = svgEl.getAttribute && svgEl.getAttribute('viewBox');
var vbp = vb ? vb.trim().split(/[\s,]+/).map(Number) : [];
if (!(w > 0)) w = vbp.length === 4 ? vbp[2] : parseFloat(svgEl.getAttribute('width'));
if (!(h > 0)) h = vbp.length === 4 ? vbp[3] : parseFloat(svgEl.getAttribute('height'));
if (!(w > 0) || !(h > 0)) throw new Error('dimensioni SVG non valide');
var scale = Math.min(4096, Math.max(1600, Math.max(w, h))) / Math.max(w, h);
var cw = Math.max(1, Math.round(w * scale));
var ch = Math.max(1, Math.round(h * scale));
var clone = svgEl.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (!vb) clone.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
clone.setAttribute('width', cw); // dimensione in pixel del PNG: l'SVG isolato non ha il CSS della pagina
clone.setAttribute('height', ch);
if (clone.style) { clone.style.width = ''; clone.style.height = ''; }
var xml = new XMLSerializer().serializeToString(clone);
var url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
try {
var img = new Image();
await new Promise(function (resolve, reject) {
img.onload = resolve;
img.onerror = function () { reject(new Error('SVG non disegnabile')); };
img.src = url;
});
var canvas = document.createElement('canvas');
canvas.width = cw;
canvas.height = ch;
var g = canvas.getContext('2d');
g.fillStyle = '#ffffff';
g.fillRect(0, 0, cw, ch);
g.drawImage(img, 0, 0, cw, ch);
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
if (!png) throw new Error('conversione PNG fallita');
return png;
} finally {
URL.revokeObjectURL(url);
}
}
// #119: scarica `src`, lo porta a PNG e lo salva; true = salvato, false = annullato dall'utente o errore
async function tmfSaveImage(src, baseName) {
if (!src) return false; // #119: nessuna immagine mostrata
try {
var name = tmfSaveImageName(baseName, new Date());
// #119: «Salva con nome» per primo, finche' vale il gesto dell'utente (Chrome lo rifiuta dopo ~5 s di fetch e conversione)
var handle = null;
if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') {
handle = await window.showSaveFilePicker({
suggestedName: name,
types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }],
});
}
var handle = await tmfPickPng(name);
var resp = await fetch(src);
if (!resp.ok) return false;
var blob = await resp.blob();
if (blob.type !== 'image/png') blob = await tmfBlobToPng(blob);
if (handle) {
var w = await handle.createWritable();
await w.write(blob);
await w.close();
return true;
}
// #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): <a download>
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
await tmfWritePng(handle, blob, name);
return true;
} catch (err) {
if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore
if (typeof console !== 'undefined') console.error('tmfSaveImage:', err);
return false;
return tmfSaveFailed(err, 'tmfSaveImage');
}
}
// #119: globale nel browser, export per il test node
if (typeof window !== 'undefined') { window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName; }
if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName };
// #122: esportati anche tmfSaveBlob e tmfSvgToPngBlob (usati da <tmf-viewer>)
if (typeof window !== 'undefined') {
window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName;
window.tmfSaveBlob = tmfSaveBlob; window.tmfSvgToPngBlob = tmfSvgToPngBlob;
}
if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob };
@@ -0,0 +1,135 @@
<!doctype html>
<!-- #122: pagina di prova di <tmf-viewer> da aprire in Chrome (file:// va bene): immagine generata col canvas,
overlay vs-tools finto, etichette e modo contenuto (svg). Il riquadro #log riporta i controlli automatici. -->
<html lang="it">
<head>
<meta charset="utf-8">
<title>Prova tmf-viewer</title>
<style>
body { font: 14px system-ui, sans-serif; margin: 16px; background: #f3f4f6; }
.griglia { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.box { height: 320px; }
h2 { font-size: 13px; margin: 12px 0 4px; }
#log { white-space: pre; font: 12px ui-monospace, monospace; background: #fff; padding: 8px; margin-top: 12px; }
</style>
</head>
<body>
<h1>Prova di &lt;tmf-viewer&gt;</h1>
<div class="griglia">
<div><h2>Immagine 800x500 + overlay + etichette (box fisso, Adatta nel box)</h2>
<tmf-viewer id="a" class="box" save-name="demo_immagine" width="800" height="500"></tmf-viewer></div>
<div><h2>fit-height, no-save: alto quanto l'immagine</h2>
<tmf-viewer id="b" fit-height no-save label-fit="Intera"></tmf-viewer></div>
<div><h2>Modo contenuto: svg (DXF) senza src</h2>
<tmf-viewer id="c" class="box" save-name="demo_dxf">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect x="5" y="5" width="190" height="90" fill="none" stroke="#fff" stroke-width="1"/><circle cx="100" cy="50" r="30" fill="none" stroke="#0ff" stroke-width="1"/></svg>
</tmf-viewer></div>
<div><h2>Vuoto: barra nascosta</h2><tmf-viewer id="d" class="box"></tmf-viewer></div>
</div>
<div id="log">in corso...</div>
<script src="../../../static/js/vision_overlay.js"></script>
<script src="../../../static/js/save_image.js"></script>
<script src="../../../static/js/image_viewer.js"></script>
<script>
window.onerror = function (m, u, l) { document.getElementById('log').textContent += '\nERRORE ' + m + ' riga ' + l; }; // errori visibili nel riquadro
(function () {
var out = [], fails = 0;
function check(l, c, x) { if (!c) fails++; out.push((c ? 'ok - ' : 'FAIL - ') + l + (c || x === undefined ? '' : ' :: ' + x)); document.getElementById('log').textContent = out.join('\n'); } // log aggiornato a ogni controllo
function img(w, h, col) {
var c = document.createElement('canvas'); c.width = w; c.height = h;
var g = c.getContext('2d'); g.fillStyle = col; g.fillRect(0, 0, w, h);
g.fillStyle = '#fff'; for (var x = 0; x < w; x += 100) g.fillRect(x, 0, 2, h);
for (var y = 0; y < h; y += 100) g.fillRect(0, y, w, 2);
return c.toDataURL('image/png');
}
var a = document.getElementById('a'), b = document.getElementById('b'), c = document.getElementById('c'), d = document.getElementById('d');
var ev = { load: 0, zoom: 0 };
a.addEventListener('viewer-load', function (e) { ev.load++; ev.det = e.detail; });
a.addEventListener('viewer-zoom', function () { ev.zoom++; });
a.overlay = [{ type: 'segment', role: 'found', row1: 50, col1: 50, row2: 450, col2: 750 },
{ type: 'points', role: 'reference', rows: [100, 200], cols: [100, 300] }];
a.labels = [{ key: 'q1', x: 400, y: 250, text: 'Quota 1 12.345 mm', cls: 'ok' }];
a.setAttribute('src', img(800, 500, '#334155'));
b.setAttribute('src', img(1000, 400, '#475569'));
function btn(v, i) { return v.querySelectorAll('.tmfv-bar button')[i]; }
setTimeout(function () {
var vpa = a.querySelector('.tmfv-vp'), st = a.querySelector('.tmfv-stage');
var fitA = function () { return Math.min(vpa.clientWidth / 800, vpa.clientHeight / 500); };
check('a: viewer-load una volta con 800x500', ev.load === 1 && ev.det && ev.det.width === 800 && ev.det.height === 500, JSON.stringify(ev));
check('a: Adatta = min(box/img), stage entra nel box', Math.abs(a.zoom - fitA()) < 0.01 && st.offsetWidth <= vpa.clientWidth + 1 && st.offsetHeight <= vpa.clientHeight + 1,
a.zoom + ' ' + st.offsetWidth + 'x' + st.offsetHeight + ' vp ' + vpa.clientWidth + 'x' + vpa.clientHeight);
check('a: percentuale mostrata', /^\d+ %$/.test(a.querySelector('.tmfv-pct').textContent), a.querySelector('.tmfv-pct').textContent);
check('a: overlay con 2 forme e viewBox 0 0 800 500', a.querySelector('.tmfv-ov').children.length >= 2 && a.querySelector('.tmfv-ov').getAttribute('viewBox') === '0 0 800 500', a.querySelector('.tmfv-ov').innerHTML.slice(0, 80));
var lab = a.querySelector('.tmfv-label');
check('a: etichetta al 50 % / 50 %, testo, classe', lab && lab.style.left === '50%' && lab.style.top === '50%' && lab.textContent === 'Quota 1 12.345 mm' && lab.classList.contains('ok'));
check('a: barra visibile con -, +, Adatta, Salva', !a.querySelector('.tmfv-bar').hidden && a.querySelectorAll('.tmfv-bar button').length === 4 && btn(a, 3).textContent === 'Salva');
var z0 = a.zoom; btn(a, 1).click();
check('a: + ingrandisce di 1.25', Math.abs(a.zoom / z0 - 1.25) < 1e-9, a.zoom + '/' + z0);
for (var i = 0; i < 6; i++) a.zoomBy(1.25);
check('a: ingrandito -> scorrimento possibile e cursore grab', vpa.scrollWidth > vpa.clientWidth && vpa.style.cursor === 'grab', vpa.style.cursor);
var rect = vpa.getBoundingClientRect(), px = 120, py = 90;
var zb = a.zoom, lx = (vpa.scrollLeft + px) / zb, ly = (vpa.scrollTop + py) / zb;
vpa.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true, deltaY: -100, clientX: rect.left + vpa.clientLeft + px, clientY: rect.top + vpa.clientTop + py, bubbles: true, cancelable: true }));
var za = a.zoom, lx2 = (vpa.scrollLeft + px) / za, ly2 = (vpa.scrollTop + py) / za;
check('a: Ctrl+rotella zooma e tiene fermo il punto', za > zb && Math.abs(lx - lx2) < 1 && Math.abs(ly - ly2) < 1, [zb, za, lx, lx2, ly, ly2].join(' '));
for (i = 0; i < 40; i++) a.zoomBy(1.25);
check('a: zoom limitato a max(8, fit)', a.zoom <= 8.0000001, a.zoom);
var we = new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true }); vpa.dispatchEvent(we);
check('a: rotella senza Ctrl non intercettata', we.defaultPrevented === false);
btn(a, 2).click();
check('a: Adatta torna al fit e scroll a 0', Math.abs(a.zoom - fitA()) < 0.01 && vpa.scrollLeft === 0, a.zoom);
check('a: viewer-zoom emesso a ogni cambio', ev.zoom > 3, ev.zoom);
a.zoomBy(1.25); var zz = a.zoom; ev.load = 0;
a.setAttribute('src', img(800, 500, '#7f1d1d'));
setTimeout(function () {
check('a: nuovo src, stesse dimensioni -> zoom mantenuto', ev.load === 1 && Math.abs(a.zoom - zz) < 1e-9, a.zoom + ' vs ' + zz);
var vpb = b.querySelector('.tmfv-vp');
check('b: fit-height: viewport alto quanto l\'immagine adattata alla larghezza', Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5 && Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01,
vpb.offsetHeight + ' vs ' + 400 * b.zoom + ' zoom ' + b.zoom);
check('b: no-save nasconde Salva, label-fit "Intera"', btn(b, 3).hidden && btn(b, 2).textContent === 'Intera');
b.style.width = '300px';
// il resize e' consegnato dal ResizeObserver al prossimo frame: in una scheda in background puo' tardare, quindi attendo (max 5 s)
var t0 = Date.now();
(function attendi() { if (b.zoom < 0.5 || Date.now() - t0 > 5000) dopo(); else setTimeout(attendi, 100); })();
function dopo() {
check('b: resize in stato adatta -> rifa Adatta (300 px)', Math.abs(b.zoom - vpb.clientWidth / 1000) < 0.01 && Math.abs(vpb.offsetHeight - 400 * b.zoom) < 1.5, b.zoom + ' ' + vpb.offsetHeight);
var stc = c.querySelector('.tmfv-stage');
check('c: svg nello stage, dimensioni dal viewBox 200x100, Adatta', stc.querySelector('svg:not(.tmfv-ov)') && !c.querySelector(':scope > svg') && c.zoom > 0 && Math.abs(stc.offsetWidth / stc.offsetHeight - 2) < 0.05, c.zoom + ' ' + stc.offsetWidth + 'x' + stc.offsetHeight);
// #122: DXF in mm (fit ~ 10): si ingrandisce oltre Adatta; width/height cambiati dopo il connect rifanno Adatta
c.setAttribute('width', '50'); c.setAttribute('height', '30'); // contenuto 50 x 30 mm nel box ~ 560 x 320
var zc = c.zoom; c.zoomBy(1.25);
check('c: width/height 50x30 -> fit > 8 e zoom oltre Adatta', zc > 8 && c.zoom > zc * 1.2, zc + ' -> ' + c.zoom);
// #122: viewer-error su immagine non caricabile; src tolto -> componente vuoto senza errori
var e = document.createElement('tmf-viewer'); e.style.height = '100px'; document.body.appendChild(e);
var gotErr = null; e.addEventListener('viewer-error', function (x) { gotErr = x.detail; });
e.overlay = [{ type: 'segment', role: 'found', row1: 1, col1: 1, row2: 5, col2: 9 }]; e.labels = [{ x: 5, y: 5, text: 'x' }]; // prima del load
e.setAttribute('src', 'inesistente.png');
setTimeout(function () {
check('e: viewer-error con src', gotErr && gotErr.src === 'inesistente.png', JSON.stringify(gotErr));
e.setAttribute('src', img(100, 50, '#000'));
setTimeout(function () {
check('e: overlay ed etichette impostati prima del load disegnati dopo', e.querySelector('.tmfv-ov').children.length === 1 && e.querySelectorAll('.tmfv-label').length === 1);
var lb = getComputedStyle(e.querySelector('.tmfv-label'));
check('e: etichetta con aspetto proprio (absolute, bold, padding)', lb.position === 'absolute' && lb.fontWeight === '700' && lb.paddingLeft === '6px' && lb.pointerEvents === 'none');
e.removeAttribute('src');
check('e: src tolto -> vuoto, barra nascosta', e.querySelector('.tmfv-bar').hidden === true && e.zoom === 0);
}, 300);
}, 300);
check('d: vuoto -> barra nascosta', d.querySelector('.tmfv-bar').hidden === true);
check('stile iniettato una sola volta', document.querySelectorAll('#tmf-viewer-css').length === 1);
tmfSvgToPngBlob(c.querySelector('.tmfv-stage > svg:not(.tmfv-ov)'), 200, 100).then(function (bl) {
check('c: tmfSvgToPngBlob produce un PNG', bl && bl.type === 'image/png' && bl.size > 100, bl && (bl.type + ' ' + bl.size));
return createImageBitmap(bl);
}).then(function (bm) {
check('c: PNG 1600x800', bm.width === 1600 && bm.height === 800, bm.width + 'x' + bm.height);
}).catch(function (er) { check('c: rasterizzazione', false, String(er)); }).then(function () { return new Promise(function (r) { setTimeout(r, 900); }); }).then(function () {
document.getElementById('log').textContent = out.join('\n') + '\n\n' + (out.length - fails) + '/' + out.length + ' controlli ok';
document.title = fails ? 'FALLITO' : 'TUTTO OK';
});
}
}, 400);
}, 600);
})();
</script>
</body>
</html>
@@ -0,0 +1,99 @@
/**
* #122: static/js/image_viewer.js (<tmf-viewer>) con plain node: matematica pura (TmfViewerMath) e
* registrazione del Custom Element una sola volta. Il DOM vero si prova in Chrome (fixtures/image_viewer_demo.html).
*
* Il modulo si carica senza window/document/customElements.
* Run from src/frontend/flask_app: node tests/js/test_image_viewer.js
*/
'use strict';
const path = require('path');
const MOD = path.join(__dirname, '..', '..', 'static', 'js', 'image_viewer.js');
let failures = 0;
let total = 0;
function check(label, cond, extra) {
total++;
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const near = (a, b) => Math.abs(a - b) < 1e-9;
// --- caricamento in node senza globali del browser ---
check('node senza window/document/customElements/HTMLElement',
typeof window === 'undefined' && typeof document === 'undefined' &&
typeof customElements === 'undefined' && typeof HTMLElement === 'undefined');
const { TmfViewerMath: M } = require(MOD);
check('esporta TmfViewerMath con le quattro funzioni',
M && ['fitZoom', 'clampZoom', 'zoomAround', 'pct'].every((k) => typeof M[k] === 'function'));
// --- fitZoom ---
check('fitZoom: box largo, immagine alta -> limita l\'altezza', near(M.fitZoom(800, 400, 1000, 1000), 0.4));
check('fitZoom: box alto, immagine larga -> limita la larghezza', near(M.fitZoom(400, 800, 1000, 500), 0.4));
check('fitZoom: immagine piu\' piccola del box -> zoom > 1', near(M.fitZoom(800, 600, 400, 300), 2));
check('fitZoom: box esatto -> 1', near(M.fitZoom(640, 480, 640, 480), 1));
check('fitZoom: boxH Infinity (fit-height) -> solo larghezza', near(M.fitZoom(500, Infinity, 1000, 4000), 0.5));
check('fitZoom: 0 se box nascosto o immagine senza dimensioni',
M.fitZoom(0, 400, 10, 10) === 0 && M.fitZoom(400, 0, 10, 10) === 0 &&
M.fitZoom(400, 400, 0, 10) === 0 && M.fitZoom(400, 400, 10, 0) === 0);
check('fitZoom: 0 con NaN, negativi, undefined',
M.fitZoom(NaN, 400, 10, 10) === 0 && M.fitZoom(-5, 400, 10, 10) === 0 && M.fitZoom(400, 400, undefined, 10) === 0 &&
M.fitZoom(400, 400, 10, null) === 0);
// --- clampZoom ---
check('clampZoom: dentro i limiti resta uguale', near(M.clampZoom(1, 0.5), 1));
check('clampZoom: sotto fit*0.5 -> fit*0.5', near(M.clampZoom(0.01, 0.5), 0.25));
check('clampZoom: sopra 8 -> 8', near(M.clampZoom(20, 0.5), 8));
check('clampZoom: fit sopra 1 alza il massimo a fit*8 (DXF in mm, fit ~10)', near(M.clampZoom(20, 12), 20) && near(M.clampZoom(200, 12), 96) && near(M.clampZoom(11, 12), 11));
check('clampZoom: con fit piccolo il massimo resta 8', near(M.clampZoom(20, 0.5), 8) && near(M.clampZoom(20, 1), 8) && near(M.clampZoom(100, 2), 16));
check('clampZoom: limiti esatti', near(M.clampZoom(0.25, 0.5), 0.25) && near(M.clampZoom(8, 0.5), 8));
// --- zoomAround ---
{
// il punto del contenuto sotto (px,py) prima e dopo lo zoom deve essere lo stesso
const z0 = 0.5, z1 = 1.25, sl = 120, st = 40, px = 300, py = 200;
const r = M.zoomAround(z0, z1, sl, st, px, py);
const before = { x: (sl + px) / z0, y: (st + py) / z0 }; // coordinate logiche sotto il puntatore
const after = { x: (r.scrollLeft + px) / z1, y: (r.scrollTop + py) / z1 };
check('zoomAround: il punto sotto il puntatore resta fermo', near(before.x, after.x) && near(before.y, after.y),
JSON.stringify([before, after]));
const same = M.zoomAround(2, 2, 50, 60, 10, 10);
check('zoomAround: z1 == z0 non sposta lo scroll', near(same.scrollLeft, 50) && near(same.scrollTop, 60));
const c = M.zoomAround(1, 2, 0, 0, 100, 50);
check('zoomAround: da scroll 0 raddoppiando (100,50) -> scroll (100,50)', near(c.scrollLeft, 100) && near(c.scrollTop, 50));
const o = M.zoomAround(2, 1, 100, 100, 0, 0);
check('zoomAround: puntatore all\'origine, riduzione -> scroll dimezzato', near(o.scrollLeft, 50) && near(o.scrollTop, 50));
}
// --- pct ---
check('pct: 1 -> "100 %"', M.pct(1) === '100 %', M.pct(1));
check('pct: arrotonda 0.4567 -> "46 %"', M.pct(0.4567) === '46 %', M.pct(0.4567));
check('pct: 8 -> "800 %", 0 -> "0 %"', M.pct(8) === '800 %' && M.pct(0) === '0 %');
// --- define una sola volta, anche caricando due volte ---
{
const defined = {};
let defineCalls = 0;
globalThis.HTMLElement = class {};
globalThis.customElements = {
get: (n) => defined[n],
define: (n, c) => { defineCalls++; defined[n] = c; },
};
delete require.cache[require.resolve(MOD)];
require(MOD);
delete require.cache[require.resolve(MOD)];
require(MOD);
check('customElements.define("tmf-viewer") una sola volta con due caricamenti',
defineCalls === 1 && typeof defined['tmf-viewer'] === 'function', 'chiamate: ' + defineCalls);
const C = defined['tmf-viewer'];
check('osserva src, width, height, fit-height, no-save, save-name',
['src', 'width', 'height', 'fit-height', 'no-save', 'save-name'].every((a) => C.observedAttributes.includes(a)));
check('espone fit, zoomBy, overlay, labels, zoom sul prototipo',
typeof C.prototype.fit === 'function' && typeof C.prototype.zoomBy === 'function' &&
['overlay', 'labels', 'zoom'].every((k) => Object.getOwnPropertyDescriptor(C.prototype, k)));
delete globalThis.customElements;
delete globalThis.HTMLElement;
}
console.log('\n' + (total - failures) + '/' + total + ' controlli ok');
process.exit(failures ? 1 : 0);
@@ -8,7 +8,7 @@
const path = require('path');
const mod = require(path.join(__dirname, '..', '..', 'static', 'js', 'save_image.js'));
const { tmfSaveImage, tmfSaveImageName } = mod;
const { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob } = mod; // #122: + tmfSaveBlob, tmfSvgToPngBlob
let failures = 0;
let total = 0;
@@ -37,7 +37,7 @@ check('nome: data non valida -> ora corrente', /^x_\d{8}-\d{6}\.png$/.test(tmfSa
check('nome: non esiste doppia estensione', !/\.png\.png$/.test(tmfSaveImageName('foto.png', d)));
// --- finti del browser ---
const globals = ['fetch', 'window', 'document', 'URL', 'createImageBitmap', 'setTimeout'];
const globals = ['fetch', 'window', 'document', 'URL', 'createImageBitmap', 'setTimeout', 'Image', 'XMLSerializer']; // #122: + Image, XMLSerializer
const saved = {};
globals.forEach((k) => { saved[k] = Object.getOwnPropertyDescriptor(globalThis, k); });
function restore() {
@@ -149,10 +149,87 @@ function fakeEnv({ blobType = 'image/png', ok = true, picker, pickerErr, fetchEr
check('errori: fetch rotto, risposta non ok, picker che fallisce -> false senza eccezioni',
thrown === null && r1 === false && r2 === false && r3 === false, String(thrown) + [r1, r2, r3]);
}
// --- #122: tmfSaveBlob con picker ---
{
const { log } = fakeEnv({ picker: true });
const png = { type: 'image/png', size: 5, tag: 'blob-dxf' };
const r = await tmfSaveBlob(png, 'dxf ricetta');
check('saveBlob picker: true, scrive il blob dato senza fetch ne conversioni',
r === true && log.written.length === 1 && log.written[0] === png && log.closed === 1 &&
log.fetched.length === 0 && log.canvases.length === 0);
check('saveBlob picker: nome e tipo PNG proposti',
/^dxf_ricetta_\d{8}-\d{6}\.png$/.test(log.pickerOpts.suggestedName) && log.pickerOpts.types[0].accept['image/png'][0] === '.png');
}
// --- #122: tmfSaveBlob senza picker -> <a download> ---
{
const { log } = fakeEnv({ picker: false });
const png = { type: 'image/png', size: 5, tag: 'blob-dxf' };
const r = await tmfSaveBlob(png, 'dxf');
check('saveBlob a download: true, un click con href del blob e nome atteso, URL liberato',
r === true && log.clicks.length === 1 && log.clicks[0].href === 'blob:fake/blob-dxf' &&
/^dxf_\d{8}-\d{6}\.png$/.test(log.clicks[0].download) && log.revoked.length === 1);
}
// --- #122: tmfSaveBlob: blob mancante, annullo, errore ---
{
const errs = [];
console.error = (...a) => errs.push(a);
fakeEnv({ picker: true });
const rNull = await tmfSaveBlob(null, 'x');
const abort = Object.assign(new Error('annullato'), { name: 'AbortError' });
fakeEnv({ picker: true, pickerErr: abort });
const rAbort = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x');
const nAbort = errs.length;
fakeEnv({ picker: true, pickerErr: Object.assign(new Error('disco pieno'), { name: 'NotAllowedError' }) });
const rErr = await tmfSaveBlob({ type: 'image/png', tag: 'b' }, 'x');
check('saveBlob: null -> false; AbortError -> false senza log; altro errore -> false con log',
rNull === false && rAbort === false && nAbort === 0 && rErr === false && errs.length === 1);
console.error = () => {};
}
// --- #122: tmfSvgToPngBlob ---
{
const { log } = fakeEnv({ picker: false });
const svgs = [];
setGlobal('XMLSerializer', class { serializeToString(el) { svgs.push(el); return '<svg/>'; } });
setGlobal('Image', class { set src(v) { this._src = v; setImmediate(() => this.onload()); } });
const fills = [];
const cv = document.createElement;
document.createElement = (tag) => {
const e = cv(tag);
if (tag === 'canvas') e.getContext = () => ({ set fillStyle(v) { fills.push(v); }, fillRect() { fills.push('rect'); }, drawImage() { e.drawn = true; } });
return e;
};
const made = [];
const svgEl = {
attrs: { viewBox: '0 0 300 200' }, style: { width: '100%', height: '100%' },
getAttribute(k) { return this.attrs[k] === undefined ? null : this.attrs[k]; },
setAttribute(k, v) { this.attrs[k] = String(v); },
cloneNode() { const c = Object.assign({}, this, { attrs: Object.assign({}, this.attrs), style: Object.assign({}, this.style) }); made.push(c); return c; },
};
const png = await tmfSvgToPngBlob(svgEl, 300, 200);
const c = log.canvases[0];
check('svgToPng: ritorna il PNG del canvas, sfondo bianco, lato lungo portato a 1600 (300x200 -> 1600x1067)',
png && png.tag === 'blob-png' && c.width === 1600 && c.height === 1067 && fills[0] === '#ffffff' && fills[1] === 'rect' && c.drawn,
JSON.stringify([c.width, c.height, fills]));
check('svgToPng: clone con xmlns, width/height in pixel del PNG e stile 100% tolto; originale intatto',
made.length === 1 && made[0].attrs.xmlns === 'http://www.w3.org/2000/svg' && made[0].attrs.width === '1600' &&
made[0].attrs.height === '1067' && made[0].style.width === '' && svgEl.attrs.width === undefined && svgs[0] === made[0]);
check('svgToPng: URL del blob SVG liberato', log.revoked.length === 1);
// lato lungo oltre 4096 -> ridotto
const big = await tmfSvgToPngBlob(svgEl, 10000, 5000);
check('svgToPng: 10000x5000 -> 4096x2048', log.canvases[1].width === 4096 && log.canvases[1].height === 2048 && big);
// senza w/h usa il viewBox; senza dimensioni lancia
await tmfSvgToPngBlob(svgEl);
check('svgToPng: senza w/h usa il viewBox', log.canvases[2].width === 1600 && log.canvases[2].height === 1067);
let thrown = null;
try { await tmfSvgToPngBlob({ getAttribute: () => null }); } catch (e) { thrown = e; }
check('svgToPng: senza dimensioni lancia', thrown !== null);
document.createElement = cv;
}
// --- esposizione ---
{
fakeEnv({ picker: false });
check('esposto come funzione e export', typeof tmfSaveImage === 'function' && typeof tmfSaveImageName === 'function');
check('esposto come funzione e export', typeof tmfSaveImage === 'function' && typeof tmfSaveImageName === 'function' &&
typeof tmfSaveBlob === 'function' && typeof tmfSvgToPngBlob === 'function');
}
} finally {
console.error = origErr;
@@ -0,0 +1,35 @@
"""Runs the plain-node image viewer (TmfViewerMath) test (tests/js/test_image_viewer.js).
Same thin pytest wrapper pattern as test_vision_overlay_js.py: shells out to
``node`` and asserts the script's own exit code. Skips (does not fail) when
node is not installed.
Covers gitea:Adriano/TieMeasureFlow#122.
"""
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_viewer.js"
def test_image_viewer_js_behaviour():
node = shutil.which("node")
if not node:
pytest.skip("node non installato: test JS del visualizzatore immagine 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_viewer.js fallito (exit {result.returncode})\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)