From 724052ae28a8b26ca9b5627e87a59e854d6cf76a Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Fri, 11 Sep 2026 20:41:48 +0200 Subject: [PATCH] Version 2.6.0: Unterschreiben mit dem Finger, Ablage in eine andere Freigabe --- apps/tesm-license/pyproject.toml | 2 +- packages/tesm-core/pyproject.toml | 2 +- .../tesm-core/src/tesm_core/app_factory.py | 2 +- .../static/tesm_core/css/components.css | 55 +++ .../tesm_core/static/tesm_core/js/viewer.js | 316 +++++++++++++++++- packages/tesm-licensing-server/pyproject.toml | 4 +- packages/tesm-licensing/pyproject.toml | 2 +- 7 files changed, 371 insertions(+), 12 deletions(-) diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index 31996a7..ce46e8f 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.5.2" +version = "2.6.0" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index d57d785..01b7128 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.5.2" +version = "2.6.0" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py index c7e3efa..55a7993 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -148,7 +148,7 @@ def create_base_app( #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Anwendungen. auth_tagline: str = "", - app_version: str = "2.5.2", + app_version: str = "2.6.0", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css index 5873a4b..6a75b35 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css @@ -1384,3 +1384,58 @@ background: transparent; border-style: dashed; } + +/* -- Unterschrift --------------------------------------------------------- */ +/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die + Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das + Feld nicht. */ + +.viewer__sign { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--border); + background: var(--surface-2); +} + +.viewer__signfelder { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.viewer__signpad { + width: 100%; + height: 160px; + border: 1px dashed var(--border-strong, var(--border)); + border-radius: var(--r-sm); + background: #fff; + touch-action: none; + cursor: crosshair; +} + +.viewer__signaktionen { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + flex-wrap: wrap; +} + +.viewer__pdfscroll--setzen { + cursor: crosshair; +} + +.viewer__pdfscroll--setzen .page { + outline: 2px dashed var(--accent, #c8791f); + outline-offset: -2px; +} + +/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim + Zoomen an ihrer Stelle bleibt. */ +.viewer__unterschrift { + position: absolute; + pointer-events: none; + z-index: 3; +} diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js index 9b761ec..da9a7a8 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js @@ -228,7 +228,12 @@ async function pdflibLaden(adresse) { * ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt * ein PDF ohne Formular -- nichts mehr zu verstellen. */ -async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = {}) { +async function nachbearbeiten( + bytes, + bilder, + adresse, + { abschliessen = false, unterschriften = [], zeuge = "" } = {} +) { const PDFLib = await pdflibLaden(adresse); const dokument = await PDFLib.PDFDocument.load(bytes); const formular = dokument.getForm(); @@ -263,6 +268,40 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = } } + // Unterschriften: Bild und darunter der angegebene Name mit Zeitpunkt. Das + // Bild allein waere eine Zeichnung ohne Aussage -- auf Papier steht neben + // der Unterschrift auch der Name in Druckbuchstaben. + if (unterschriften.length) { + const schrift = await dokument.embedFont(PDFLib.StandardFonts.Helvetica); + for (const eintrag of unterschriften) { + const seite = dokument.getPage(eintrag.seite - 1); + const png = await dokument.embedPng(eintrag.bytes); + const breite = eintrag.breite; + const hoehe = (png.height / png.width) * breite; + seite.drawImage(png, { x: eintrag.x, y: eintrag.y, width: breite, height: hoehe }); + const zeilen = [`${eintrag.name} -- ${eintrag.zeit}`]; + if (zeuge) zeilen.push(`In TESM abgelegt durch ${zeuge}`); + let unten = eintrag.y - 2; + for (const zeile of zeilen) { + unten -= 9; + seite.drawText(zeile, { + x: eintrag.x, + y: unten, + size: 8, + font: schrift, + color: PDFLib.rgb(0.1, 0.1, 0.1), + }); + } + // Ein Strich wie auf dem Formular: er trennt Zeichnung und Klartext. + seite.drawLine({ + start: { x: eintrag.x, y: eintrag.y - 1 }, + end: { x: eintrag.x + breite, y: eintrag.y - 1 }, + thickness: 0.5, + color: PDFLib.rgb(0.4, 0.4, 0.4), + }); + } + } + if (abschliessen) { // Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr -- // gewollt: so kann niemand mehr etwas verstellen. @@ -271,6 +310,101 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = return dokument.save(); } +/** Die gezeichnete Unterschrift als PNG -- auf das Gezeichnete beschnitten. + * + * Ungeschnitten waere jede Unterschrift so gross wie die Zeichenflaeche, mit + * viel Luft ringsum. Im Dokument sieht das aus wie ein verrutschter Stempel. + */ +function unterschriftAlsPng(leinwand, kasten) { + const rand = 6; + const links = Math.max(0, Math.floor(kasten.x1 - rand)); + const oben = Math.max(0, Math.floor(kasten.y1 - rand)); + const breite = Math.min(leinwand.width - links, Math.ceil(kasten.x2 - kasten.x1 + rand * 2)); + const hoehe = Math.min(leinwand.height - oben, Math.ceil(kasten.y2 - kasten.y1 + rand * 2)); + const ziel = document.createElement("canvas"); + ziel.width = Math.max(1, breite); + ziel.height = Math.max(1, hoehe); + ziel + .getContext("2d") + .drawImage(leinwand, links, oben, ziel.width, ziel.height, 0, 0, ziel.width, ziel.height); + // Ueber eine data:-Adresse und nicht ueber blob: -- die Sicherheitsregeln + // der Anwendung erlauben blob: nicht, und ein stilles "img-src" waere ein + // Loch, das man fuer eine Unterschrift nicht aufmacht. + const adresse = ziel.toDataURL("image/png"); + const roh = atob(adresse.slice(adresse.indexOf(",") + 1)); + const bytes = new Uint8Array(roh.length); + for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i); + return { bytes, vorschau: adresse, breite: ziel.width, hoehe: ziel.height }; +} + +/** Zeichenflaeche fuer die Unterschrift -- Finger, Stift oder Maus. + * + * ``touch-action: none`` steht im Stylesheet: ohne das scrollt ein iPad die + * Seite, statt einen Strich zu zeichnen. + */ +function unterschriftPad(leinwand) { + const stift = leinwand.getContext("2d"); + let kasten = null; + let zeichnet = false; + + const punkt = (event) => { + const rechteck = leinwand.getBoundingClientRect(); + return { + x: ((event.clientX - rechteck.left) / rechteck.width) * leinwand.width, + y: ((event.clientY - rechteck.top) / rechteck.height) * leinwand.height, + }; + }; + + const merken = ({ x, y }) => { + kasten = kasten + ? { + x1: Math.min(kasten.x1, x), + y1: Math.min(kasten.y1, y), + x2: Math.max(kasten.x2, x), + y2: Math.max(kasten.y2, y), + } + : { x1: x, y1: y, x2: x, y2: y }; + }; + + stift.lineWidth = 2.5; + stift.lineCap = "round"; + stift.lineJoin = "round"; + stift.strokeStyle = "#111"; + + leinwand.addEventListener("pointerdown", (event) => { + event.preventDefault(); + leinwand.setPointerCapture(event.pointerId); + zeichnet = true; + const p = punkt(event); + merken(p); + stift.beginPath(); + stift.moveTo(p.x, p.y); + }); + leinwand.addEventListener("pointermove", (event) => { + if (!zeichnet) return; + event.preventDefault(); + const p = punkt(event); + merken(p); + stift.lineTo(p.x, p.y); + stift.stroke(); + }); + const beenden = () => { + zeichnet = false; + }; + leinwand.addEventListener("pointerup", beenden); + leinwand.addEventListener("pointercancel", beenden); + leinwand.addEventListener("pointerleave", beenden); + + return { + leeren: () => { + stift.clearRect(0, 0, leinwand.width, leinwand.height); + kasten = null; + }, + leer: () => kasten === null, + alsPng: () => (kasten ? unterschriftAlsPng(leinwand, kasten) : null), + }; +} + /* -- PDF ------------------------------------------------------------------- */ let pdfjsGeladen = null; @@ -448,6 +582,56 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true } }); await bildknoepfeAufsetzen(); + /* Unterschriften: gezeichnet wird ausserhalb, gesetzt wird hier. Die Stelle + bestimmt ein Tipp ins Dokument -- ein festes Feld gibt es in den wenigsten + Vorlagen, und "unten rechts" ist bei einem Formular mit drei + Unterschriftszeilen die falsche Annahme. */ + const unterschriften = []; + let wartendeUnterschrift = null; + + const platzieren = async (event) => { + if (!wartendeUnterschrift) return; + const seitenEl = event.target.closest(".page"); + if (!seitenEl) return; + event.preventDefault(); + const nummer = Number(seitenEl.dataset.pageNumber || 0); + if (!nummer) return; + const rechteck = seitenEl.getBoundingClientRect(); + const anteilX = (event.clientX - rechteck.left) / rechteck.width; + const anteilY = (event.clientY - rechteck.top) / rechteck.height; + const seite = await dokument.getPage(nummer); + const blick = seite.getViewport({ scale: 1 }); + const breite = Math.min(170, blick.width * 0.35); + const hoehe = + (wartendeUnterschrift.hoehe / wartendeUnterschrift.breite) * breite; + const eintrag = { + ...wartendeUnterschrift, + seite: nummer, + breite, + // Der Tipp meint die Mitte der Unterschrift, nicht ihre Ecke. + x: Math.max(4, anteilX * blick.width - breite / 2), + y: Math.max(24, (1 - anteilY) * blick.height - hoehe / 2), + }; + unterschriften.push(eintrag); + behaelter.classList.remove("viewer__pdfscroll--setzen"); + wartendeUnterschrift = null; + + // Sichtbar machen, wo sie gelandet ist -- sonst tippt man ins Blaue. + const marke = document.createElement("img"); + marke.className = "viewer__unterschrift"; + marke.src = eintrag.vorschau; + marke.alt = ""; + marke.style.left = `${(eintrag.x / blick.width) * 100}%`; + marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`; + marke.style.width = `${(breite / blick.width) * 100}%`; + seitenEl.append(marke); + behaelter.dispatchEvent(new CustomEvent("unterschrieben", { detail: eintrag })); + }; + + behaelter.addEventListener("click", (event) => { + platzieren(event).catch((fehler) => console.error("Unterschrift", fehler)); + }); + return { hinein: () => { viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2); @@ -463,12 +647,22 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true } * pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit * pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen. */ - speichern: async (pdflibAdresse, { abschliessen = false } = {}) => { + speichern: async (pdflibAdresse, { abschliessen = false, zeuge = "" } = {}) => { const bytes = await dokument.saveDocument(); - // Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht. - if (!bilder.size && !abschliessen) return bytes; - return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen }); + // Ohne Bild, ohne Unterschrift und ohne Abschluss braucht es pdf-lib nicht. + if (!bilder.size && !abschliessen && !unterschriften.length) return bytes; + return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { + abschliessen, + unterschriften, + zeuge, + }); }, + /** Wartet der Betrachter auf den Tipp, wohin die Unterschrift soll? */ + unterschriftAnnehmen: (daten) => { + wartendeUnterschrift = daten; + behaelter.classList.add("viewer__pdfscroll--setzen"); + }, + unterschriften: () => unterschriften.map(({ name, zeit }) => ({ name, zeit })), /** Wurde ueberhaupt etwas eingetragen? */ veraendert: () => { const s = dokument.annotationStorage; @@ -508,7 +702,33 @@ export function initFilePreview(wurzel = document) { const jetzt = stueck("jetzt"); const abbrechen = stueck("abbrechen"); const modusFeld = stueck("modus"); + const zielFeld = stueck("ziel"); + const unterschreibenKnopf = stueck("unterschreiben"); + const unterschriftPanel = stueck("unterschriftpanel"); + const vornameFeld = stueck("vorname"); + const nachnameFeld = stueck("nachname"); + const padLeinwand = stueck("signpad"); + const padLeeren = stueck("signleeren"); + const padSetzen = stueck("signsetzen"); + const padAbbrechen = stueck("signabbrechen"); + const padHinweis = stueck("signhinweis"); const basis = rumpf.dataset.viewerPdfjs || ""; + const konto = rumpf.dataset.viewerKonto || ""; + const bezeugen = rumpf.dataset.viewerZeuge === "1"; + // Die Uhr des Servers, nicht die des Tablets: eine verstellte Uhr am Geraet + // soll nicht das Datum unter einer Unterschrift bestimmen. + const serverStart = Date.parse(rumpf.dataset.viewerZeit || "") || Date.now(); + const geladenSeit = Date.now(); + const jetztText = () => { + const zeitpunkt = new Date(serverStart + (Date.now() - geladenSeit)); + return zeitpunkt.toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; // Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari), // soll nicht als leere Flaeche enden. @@ -535,6 +755,8 @@ export function initFilePreview(wurzel = document) { rahmen.hidden = art !== "text"; leiste.hidden = art === "text"; if (speichern) speichern.hidden = art !== "pdf"; + if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf"; + if (unterschriftPanel) unterschriftPanel.hidden = true; }; const aufraeumen = () => { @@ -628,10 +850,60 @@ export function initFilePreview(wurzel = document) { ersetzenFeld.checked = false; }; + /* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */ + + const pad = padLeinwand ? unterschriftPad(padLeinwand) : null; + + const panelZeigen = (offen) => { + if (!unterschriftPanel) return; + unterschriftPanel.hidden = !offen; + if (offen) { + pad?.leeren(); + if (padHinweis) padHinweis.textContent = ""; + vornameFeld?.focus(); + } + }; + + unterschreibenKnopf?.addEventListener("click", () => { + if (!pdf) return; + panelZeigen(unterschriftPanel?.hidden !== false); + }); + + padLeeren?.addEventListener("click", () => pad?.leeren()); + padAbbrechen?.addEventListener("click", () => panelZeigen(false)); + + padSetzen?.addEventListener("click", () => { + if (!pdf || !pad) return; + const name = [vornameFeld?.value, nachnameFeld?.value] + .map((wert) => (wert || "").trim()) + .filter(Boolean) + .join(" "); + // Beides ist noetig, und beides wird hier gesagt statt stumm abgelehnt. + if (!name) { + if (padHinweis) padHinweis.textContent = "Bitte Vor- und Nachnamen eintragen."; + vornameFeld?.focus(); + return; + } + const zeichnung = pad.alsPng(); + if (!zeichnung) { + if (padHinweis) padHinweis.textContent = "Bitte im Feld unterschreiben."; + return; + } + pdf.unterschriftAnnehmen({ ...zeichnung, name, zeit: jetztText() }); + panelZeigen(false); + window.tesm.showToast("Jetzt auf die Stelle im Dokument tippen.", "info"); + }); + speichern?.addEventListener("click", () => { if (!pdf || !aktuell) return; vorschlagen(); zeile.hidden = false; + if (modusFeld && pdf.unterschriften().length) { + // Mit Unterschrift gibt es nur einen Weg -- und das Feld soll nicht + // etwas anderes behaupten. + modusFeld.value = "fertig"; + modusFeld.disabled = true; + } nameFeld.focus(); nameFeld.select(); }); @@ -640,6 +912,31 @@ export function initFilePreview(wurzel = document) { zeile.hidden = true; }); + /* Zielfreigabe: die Ordnerliste gehoert zur gewaehlten Freigabe, nicht zur + geoeffneten. Geholt wird sie erst bei Bedarf -- alle Listen im + Seitenaufbau mitzuschicken waere Ballast fuer einen seltenen Fall. */ + const ordnerListe = ordnerFeld ? document.getElementById(ordnerFeld.getAttribute("list")) : null; + + zielFeld?.addEventListener("change", async () => { + if (!ordnerListe) return; + const vorlage = zielFeld.dataset.urlVorlage || ""; + if (!vorlage) return; + ordnerFeld.value = ""; + try { + const antwort = await window.tesm.api(vorlage.replace("__token__", zielFeld.value)); + ordnerListe.replaceChildren( + ...(antwort.folders || []).map((eintrag) => { + const option = document.createElement("option"); + option.value = eintrag; + return option; + }) + ); + } catch (fehler) { + // Kein Beinbruch: das Feld nimmt auch getippte Pfade. + console.error("Ordnerliste", fehler); + } + }); + jetzt?.addEventListener("click", async () => { if (!pdf || !aktuell) return; const ziel = speichern.dataset.url; @@ -647,13 +944,20 @@ export function initFilePreview(wurzel = document) { const vorher = jetzt.textContent; jetzt.textContent = "Speichern ..."; try { + const gesetzt = pdf.unterschriften(); const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", { - abschliessen: modusFeld?.value === "fertig", + // Unterschrieben heisst abgeschlossen: ein Dokument, in dem nach der + // Unterschrift noch Felder aenderbar waeren, ist keine Unterschrift + // wert. + abschliessen: modusFeld?.value === "fertig" || gesetzt.length > 0, + zeuge: bezeugen ? konto : "", }); const daten = new FormData(); daten.append("path", aktuell.pfad); daten.append("ordner", ordnerFeld.value.trim()); daten.append("name", nameFeld.value.trim()); + if (zielFeld?.value) daten.append("ziel", zielFeld.value); + if (gesetzt.length) daten.append("unterschriften", JSON.stringify(gesetzt)); if (ersetzenFeld.checked) daten.append("ersetzen", "1"); daten.append( "datei", diff --git a/packages/tesm-licensing-server/pyproject.toml b/packages/tesm-licensing-server/pyproject.toml index 1eec6bc..cb5bf31 100644 --- a/packages/tesm-licensing-server/pyproject.toml +++ b/packages/tesm-licensing-server/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing-server" -version = "2.5.2" +version = "2.6.0" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" requires-python = ">=3.11" -dependencies = ["tesm-licensing==2.5.2", "cryptography>=41"] +dependencies = ["tesm-licensing==2.6.0", "cryptography>=41"] [tool.setuptools.packages.find] where = ["src"] diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index a6d6562..a8f379e 100644 --- a/packages/tesm-licensing/pyproject.toml +++ b/packages/tesm-licensing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing" -version = "2.5.2" +version = "2.6.0" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"]