<tmf-viewer>: visualizzatore immagine standard, tmfSaveBlob e tmfSvgToPngBlob
Refs #122 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 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)] (come #115)
|
||||
clampZoom: function (z, fit) {
|
||||
var lo = fit * 0.5, hi = Math.max(8, fit);
|
||||
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}',
|
||||
'tmf-viewer .tmfv-label{position:absolute;transform:translate(8px,-50%);white-space:nowrap;font:600 12px/1.3 system-ui,sans-serif;',
|
||||
'color:#fff;background:rgba(0,0,0,.6);padding:1px 5px;border-radius:3px;pointer-events:none}',
|
||||
'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(); });
|
||||
// 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') {
|
||||
this._ro = new ResizeObserver(function () { self._onResize(); });
|
||||
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();
|
||||
}
|
||||
|
||||
// #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,98 @@
|
||||
/**
|
||||
* #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 8 alza il massimo a fit', near(M.clampZoom(20, 12), 12) && near(M.clampZoom(11, 12), 11));
|
||||
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