Unisce lavoro/daitarn-tmf-visore: visore standard <tmf-viewer> (Refs #122)
This commit is contained in:
@@ -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 };
|
||||
})();
|
||||
@@ -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 <tmf-viewer></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}"
|
||||
)
|
||||
Reference in New Issue
Block a user