Compare commits

..
2 Commits
15 changed files with 507 additions and 40 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.2.1" version = "2.3.1"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+22 -9
View File
@@ -353,19 +353,32 @@ def _start_fileshare_sweep(app: Flask) -> None:
weiter eingebunden. weiter eingebunden.
""" """
import threading import threading
import time
from .services import fileshare from .services import fileshare
def einmal() -> None:
with app.app_context():
geloest = fileshare.sweep_expired(core(app).database)
if geloest:
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
def schleife() -> None: def schleife() -> None:
while True: # Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
time.sleep(FILESHARE_SWEEP_SECONDS) # Durchlauf scheitert; das aeussere sorgt dafuer, dass sein Ende
try: # ueberhaupt auffaellt. Vorher fehlte der Import von "time" -- der
with app.app_context(): # NameError kam beim ersten sleep, also vor jedem try, und der Faden
geloest = fileshare.sweep_expired(core(app).database) # war weg, ohne eine Zeile zu hinterlassen.
if geloest: try:
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest) while True:
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben time.sleep(FILESHARE_SWEEP_SECONDS)
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen") try:
einmal()
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert
logger.exception("Der Aufraeumfaden der Dateifreigaben ist beendet")
raise
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start() threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
+32 -3
View File
@@ -80,6 +80,8 @@ def browse():
# vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen # vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen
# wurde. # wurde.
max_upload_mb=uploads.limit_mb(conn), max_upload_mb=uploads.limit_mb(conn),
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
verzeichnisse=(service.all_directories(mount) if listing else []),
) )
@@ -205,15 +207,42 @@ def save_form(token: str):
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen. # waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
return {"error": "Das ist keine PDF-Datei."}, 415 return {"error": "Das ist keine PDF-Datei."}, 415
# Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
ordner = (request.form.get("ordner") or "").strip().strip("/")
wunschname = (request.form.get("name") or "").strip()
ersetzen = request.form.get("ersetzen") == "1"
try: try:
original = service.resolve_path(mount, relative) original = service.resolve_path(mount, relative)
ziel = service.free_name(original.parent, original.stem, "-ausgefuellt", ".pdf") # Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
# es beim Ordner des Originals.
if wunschname:
zielordner = service.resolve_path(mount, ordner)
else:
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
if not zielordner.is_dir():
return {"error": f"Den Ordner '{ordner}' gibt es in dieser Freigabe nicht."}, 400
if wunschname:
name = service.safe_filename(wunschname)
if not name.lower().endswith(".pdf"):
name += ".pdf"
ziel = zielordner / name
if ziel.exists() and not ersetzen:
return {
"error": f"'{name}' gibt es dort schon. Anderen Namen waehlen oder "
"'Vorhandene ersetzen' ankreuzen."
}, 409
else:
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
ziel = service.free_name(original.parent, original.stem, "-ausgefuellt", ".pdf")
ziel.write_bytes(daten) ziel.write_bytes(daten)
except service.FileshareError as exc: except service.FileshareError as exc:
return {"error": str(exc)}, 400 return {"error": str(exc)}, 400
_audit("fileshare.form_saved", f"{mount.label}:{relative}", ziel.name, severity="notice") wohin = ziel.relative_to(mount.path.resolve()).as_posix()
return {"message": f"Als {ziel.name} gespeichert."} _audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice")
return {"message": f"Gespeichert als {wohin}.", "pfad": wohin}
@bp.post("/<token>/ordner") @bp.post("/<token>/ordner")
+30
View File
@@ -588,6 +588,36 @@ def safe_filename(name: str) -> str:
return cleaned return cleaned
def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[str]:
"""Die Ordner der Freigabe, relativ und alphabetisch -- fuer eine Auswahlliste.
Begrenzt in Tiefe und Anzahl: eine Netzwerkfreigabe kann zehntausende
Verzeichnisse haben, und eine Liste, die den Browser lahmlegt, hilft
niemandem. Wer tiefer speichern will, tippt den Pfad -- das Feld nimmt
beides.
"""
wurzel = mount.path.resolve()
gefunden: list[str] = []
def hinein(verzeichnis: Path, ebene: int) -> None:
if ebene > tiefe or len(gefunden) >= grenze:
return
try:
eintraege = sorted(verzeichnis.iterdir(), key=lambda e: e.name.lower())
except OSError:
return
for eintrag in eintraege:
if len(gefunden) >= grenze:
return
if not eintrag.is_dir() or eintrag.name.startswith("."):
continue
gefunden.append(eintrag.relative_to(wurzel).as_posix())
hinein(eintrag, ebene + 1)
hinein(wurzel, 1)
return gefunden
def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path: def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``. """Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
@@ -209,16 +209,41 @@
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #} nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
<div class="modal__body modal__body--flush" data-viewer <div class="modal__body modal__body--flush" data-viewer
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/') }}"> data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}">
<div class="viewer"> <div class="viewer">
<div class="viewer__stage" data-viewer-bildbuehne hidden> <div class="viewer__stage" data-viewer-bildbuehne hidden>
<img class="viewer__img" alt="" data-viewer-bild> <img class="viewer__img" alt="" data-viewer-bild>
</div> </div>
<div class="viewer__pdf" data-viewer-pdf hidden> <div class="viewer__pdf" data-viewer-pdf hidden>
<div class="pdfViewer" data-viewer-pdfseiten></div> {# Zwei Ebenen: aussen ein normaler Kasten, der sich die Hoehe mit
Leiste und Speicherzeile teilt, innen der absolute Scrollbereich,
den pdf.js verlangt. #}
<div class="viewer__pdfscroll" data-viewer-pdfscroll>
<div class="pdfViewer" data-viewer-pdfseiten></div>
</div>
</div> </div>
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen <iframe class="file-preview" title="Vorschau" data-viewer-rahmen
src="about:blank" referrerpolicy="same-origin" hidden></iframe> src="about:blank" referrerpolicy="same-origin" hidden></iframe>
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
eine Zeile Formular ueber jeder Bildvorschau. #}
<div class="viewer__save" data-viewer-speichernzeile hidden>
<label class="viewer__feld">
<span class="field__label">Ordner</span>
<input class="input" list="freigabe-ordner" data-viewer-ordner
placeholder="Wurzelverzeichnis der Freigabe" autocomplete="off">
</label>
<label class="viewer__feld">
<span class="field__label">Dateiname</span>
<input class="input" data-viewer-name autocomplete="off">
</label>
<label class="check">
<input type="checkbox" data-viewer-ersetzen>
<span>Vorhandene ersetzen</span>
</label>
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button>
</div>
<div class="viewer__bar" data-viewer-leiste hidden> <div class="viewer__bar" data-viewer-leiste hidden>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus" <button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button> aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
@@ -242,6 +267,10 @@
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button> <button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
</div> </div>
{% endcall %} {% endcall %}
{# Die Ordner der Freigabe als Vorschlag; getippt werden darf trotzdem. #}
<datalist id="freigabe-ordner">
{% for eintrag in verzeichnisse %}<option value="{{ eintrag }}"></option>{% endfor %}
</datalist>
{% endif %} {% endif %}
{% if listing and current_user.has('fileshare.edit') %} {% if listing and current_user.has('fileshare.edit') %}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.2.1" version = "2.3.1"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -135,7 +135,7 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen. #: Anwendungen.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.2.1", app_version: str = "2.3.1",
built_in_trusted_keys: tuple[str, ...] = (), built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True, enable_license: bool = True,
template_folder: str | None = None, template_folder: str | None = None,
@@ -754,11 +754,27 @@
user-select: none; user-select: none;
-webkit-user-drag: none; -webkit-user-drag: none;
} }
/* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter. */ /* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter --
der liegt deshalb *innen*. Aussen ein gewoehnlicher Flex-Kasten, damit
Zoomleiste und Speicherzeile ihren Platz behalten. Vorher rechnete der
Bereich mit "bottom: var(--sp-10)", also mit genau einer Leiste; die
zweite Zeile lag dann unter dem Dialogrand. */
.viewer__pdf { .viewer__pdf {
position: relative;
flex: 1;
min-height: 0;
}
/* pdf.js rechnet fuer die Seite mit dem Standard "content-box": ihr
9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales
"border-box" rechnete ihn hinein -- die Leinwand wurde 18 Pixel schmaler
als die Anmerkungsebene, und die Formularfelder wanderten nach rechts
unten, immer weiter je weiter unten sie lagen. */
.viewer__pdf .pdfViewer .page {
box-sizing: content-box;
}
.viewer__pdfscroll {
position: absolute; position: absolute;
inset: 0; inset: 0;
bottom: var(--sp-10);
overflow: auto; overflow: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@@ -773,6 +789,56 @@
.viewer__hint { .viewer__hint {
margin-inline-start: auto; margin-inline-start: auto;
} }
/* Bildfeld eines Formulars: der Kasten zeigt das gewaehlte Foto, die
Schaltflaeche liegt darin. Acrobats eigener Knopf tut im Browser nichts --
das hier ist der Ersatz. */
.viewer__bildfeld [data-viewer-bildvorschau] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #fff;
}
.viewer__bildwahl {
position: absolute;
inset: auto 0 4px 0;
display: flex;
justify-content: center;
cursor: pointer;
}
.viewer__bildwahl > span {
padding: 4px 10px;
border-radius: var(--r-sm, 6px);
background: var(--accent);
color: #fff;
font-size: var(--fs-xs);
font-weight: 600;
box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.3));
}
.viewer__bildfeld--belegt .viewer__bildwahl > span {
opacity: 0.85;
}
.viewer__save {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--border);
background: var(--surface-2, var(--surface-1));
}
.viewer__save .viewer__feld {
flex: 1 1 12rem;
margin: 0;
}
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
.viewer__feld {
display: flex;
flex-direction: column;
gap: 2px;
}
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein /* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
unruhig. */ unruhig. */
@@ -902,7 +902,11 @@ function boot() {
einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */ einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */
function initViewer() { function initViewer() {
if (!document.querySelector("[data-viewer]")) return; if (!document.querySelector("[data-viewer]")) return;
import("./viewer.js") // Dieselbe Version wie diese Datei: app.js wird als ".../app.js?v=<Zeit>"
// eingebunden, und nginx liefert /core-assets/ mit "expires 7d". Ohne das
// Anhaengsel bliebe der alte Betrachter nach einem Update im Cache liegen.
const version = new URL(import.meta.url).search;
import(`./viewer.js${version}`)
.then((modul) => modul.initFilePreview()) .then((modul) => modul.initFilePreview())
.catch((fehler) => console.error("Dateibetrachter", fehler)); .catch((fehler) => console.error("Dateibetrachter", fehler));
} }
@@ -142,25 +142,159 @@ function bildBetrachter(buehne, bild) {
}; };
} }
/* -- Bildfelder in Formularen ---------------------------------------------- */
//: Groesser muss ein Foto in einem Formular nicht sein.
const FOTO_KANTE = 1600;
/** Macht aus einer gewaehlten Datei ein JPEG in vertretbarer Groesse.
*
* Ueber eine Leinwand, nicht roh: damit wird aus allem, was der Browser
* anzeigen kann, ein JPEG -- auf einem iPad also auch aus HEIC, das pdf-lib
* nicht kennt. Und ein 12-Megapixel-Foto blaeht das Formular nicht auf.
*/
async function fotoAlsJpeg(datei) {
// Keine Objektadresse: die CSP erlaubt Bilder nur von "self" und als data:.
// createImageBitmap nimmt die Datei unmittelbar; wo es fehlt, tut es eine
// data:-Adresse.
let bild;
if (typeof createImageBitmap === "function") {
try {
bild = await createImageBitmap(datei);
} catch {
bild = null;
}
}
if (!bild) {
const daten = await new Promise((fertig, schiefgegangen) => {
const leser = new FileReader();
leser.onload = () => fertig(String(leser.result));
leser.onerror = () => schiefgegangen(new Error("Die Datei liess sich nicht lesen."));
leser.readAsDataURL(datei);
});
bild = await new Promise((fertig, schiefgegangen) => {
const el = new Image();
el.onload = () => fertig(el);
el.onerror = () => schiefgegangen(new Error("Dieses Bild kann der Browser nicht anzeigen."));
el.src = daten;
});
}
const breiteRoh = bild.width || bild.naturalWidth;
const hoeheRoh = bild.height || bild.naturalHeight;
const faktor = Math.min(1, FOTO_KANTE / Math.max(breiteRoh, hoeheRoh));
const leinwand = document.createElement("canvas");
leinwand.width = Math.max(1, Math.round(breiteRoh * faktor));
leinwand.height = Math.max(1, Math.round(hoeheRoh * faktor));
leinwand.getContext("2d").drawImage(bild, 0, 0, leinwand.width, leinwand.height);
bild.close?.();
const klecks = await new Promise((fertig) => leinwand.toBlob(fertig, "image/jpeg", 0.85));
return {
bytes: new Uint8Array(await klecks.arrayBuffer()),
breite: leinwand.width,
hoehe: leinwand.height,
vorschau: leinwand.toDataURL("image/jpeg", 0.6),
};
}
let pdflibGeladen = null;
/** pdf-lib nachladen -- nur wenn wirklich ein Bild eingesetzt wird. */
async function pdflibLaden(adresse) {
if (!pdflibGeladen) {
pdflibGeladen = new Promise((fertig, schiefgegangen) => {
const skript = document.createElement("script");
skript.src = adresse;
skript.onload = () => fertig(window.PDFLib);
skript.onerror = () => {
pdflibGeladen = null;
schiefgegangen(new Error("pdf-lib liess sich nicht laden."));
};
document.head.append(skript);
});
}
return pdflibGeladen;
}
/** Stempelt die gewaehlten Fotos in die Bildfelder.
*
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
* die Formularwerte bleiben also erhalten.
*/
async function fotosEinsetzen(bytes, bilder, adresse) {
const PDFLib = await pdflibLaden(adresse);
const dokument = await PDFLib.PDFDocument.load(bytes);
const formular = dokument.getForm();
for (const eintrag of bilder) {
const seite = dokument.getPage(eintrag.seite - 1);
const jpeg = await dokument.embedJpg(eintrag.foto.bytes);
const [x1, y1, x2, y2] = eintrag.rechteck;
const kastenBreite = Math.abs(x2 - x1);
const kastenHoehe = Math.abs(y2 - y1);
// Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt.
const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height);
const breite = jpeg.width * faktor;
const hoehe = jpeg.height * faktor;
seite.drawImage(jpeg, {
x: Math.min(x1, x2) + (kastenBreite - breite) / 2,
y: Math.min(y1, y2) + (kastenHoehe - hoehe) / 2,
width: breite,
height: hoehe,
});
// Den Knopf aus der fertigen Kopie nehmen: seine Flaeche liegt ueber dem
// Seiteninhalt und wuerde das Foto verdecken. Zu tun haette er dort auch
// nichts mehr -- seine Aktion fuehrt kein Browser aus, und das Bild steht
// jetzt in der Seite. Die Vorlage bleibt unberuehrt.
try {
const feld = formular.getFieldMaybe(eintrag.feldname);
if (feld) formular.removeField(feld);
} catch (fehler) {
// Bleibt der Knopf liegen, ist das Foto verdeckt -- aber die Datei ist
// heil. Das ist der bessere von zwei schlechten Ausgaengen.
console.warn("Bildknopf liess sich nicht entfernen", fehler);
}
}
return dokument.save();
}
/* -- PDF ------------------------------------------------------------------- */ /* -- PDF ------------------------------------------------------------------- */
let pdfjsGeladen = null; let pdfjsGeladen = null;
/** Die Adressen der pdf.js-Dateien -- alle mit derselben Version.
*
* Uebergeben wird die Adresse von pdf.min.js samt "?v=<Zeit>"; die
* Geschwister liegen daneben. Ohne die Version bliebe nach einem Update die
* alte Fassung im Cache von nginx ("expires 7d") haengen.
*/
function pdfjsAdressen(adresse) {
const url = new URL(adresse, location.href);
const verzeichnis = url.pathname.replace(/[^/]*$/, "");
const mit = (datei) => `${url.origin}${verzeichnis}${datei}${url.search}`;
return {
lib: mit("pdf.min.js"),
komponenten: mit("pdf_viewer.js"),
worker: mit("pdf.worker.min.js"),
stil: mit("pdf_viewer.css"),
schriften: `${url.origin}${verzeichnis}standard_fonts/`,
};
}
/** Laedt pdf.js einmalig nach; das Paket liegt neben dieser Datei. */ /** Laedt pdf.js einmalig nach; das Paket liegt neben dieser Datei. */
async function pdfjsLaden(basis) { async function pdfjsLaden(basis) {
const adressen = pdfjsAdressen(basis);
if (!pdfjsGeladen) { if (!pdfjsGeladen) {
pdfjsGeladen = (async () => { pdfjsGeladen = (async () => {
const lib = await import(`${basis}pdf.min.js`); const lib = await import(adressen.lib);
// Der Komponentenbau von pdf.js sucht die Bibliothek unter diesem Namen. // Der Komponentenbau von pdf.js sucht die Bibliothek unter diesem Namen.
globalThis.pdfjsLib = lib; globalThis.pdfjsLib = lib;
const komponenten = await import(`${basis}pdf_viewer.js`); const komponenten = await import(adressen.komponenten);
// Das Stylesheet von pdf.js zeichnet die Formularfelder. 100 KB gehoeren // Das Stylesheet von pdf.js zeichnet die Formularfelder. 100 KB gehoeren
// nicht in jeden Seitenaufruf -- also erst hier. // nicht in jeden Seitenaufruf -- also erst hier.
const stil = document.createElement("link"); const stil = document.createElement("link");
stil.rel = "stylesheet"; stil.rel = "stylesheet";
stil.href = `${basis}pdf_viewer.css`; stil.href = adressen.stil;
document.head.append(stil); document.head.append(stil);
lib.GlobalWorkerOptions.workerSrc = `${basis}pdf.worker.min.js`; lib.GlobalWorkerOptions.workerSrc = adressen.worker;
return { lib, komponenten }; return { lib, komponenten };
})().catch((fehler) => { })().catch((fehler) => {
pdfjsGeladen = null; pdfjsGeladen = null;
@@ -173,6 +307,7 @@ async function pdfjsLaden(basis) {
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */ /** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */
async function pdfBetrachter(behaelter, seiten, url, basis) { async function pdfBetrachter(behaelter, seiten, url, basis) {
const { lib, komponenten } = await pdfjsLaden(basis); const { lib, komponenten } = await pdfjsLaden(basis);
const adressen = pdfjsAdressen(basis);
const eventBus = new komponenten.EventBus(); const eventBus = new komponenten.EventBus();
const linkService = new komponenten.PDFLinkService({ eventBus }); const linkService = new komponenten.PDFLinkService({ eventBus });
const viewer = new komponenten.PDFViewer({ const viewer = new komponenten.PDFViewer({
@@ -190,7 +325,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
url, url,
// Ohne die Standardschriften bleibt jede Seite mit Text leer: Helvetica // Ohne die Standardschriften bleibt jede Seite mit Text leer: Helvetica
// und Verwandte liegen als Datei im Paket, nicht im Browser. // und Verwandte liegen als Datei im Paket, nicht im Browser.
standardFontDataUrl: `${basis}standard_fonts/`, standardFontDataUrl: adressen.schriften,
isEvalSupported: false, isEvalSupported: false,
}).promise; }).promise;
viewer.setDocument(dokument); viewer.setDocument(dokument);
@@ -223,6 +358,72 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
start = null; start = null;
}); });
/* Bildknoepfe: eigene Schaltflaeche darueber, denn Acrobats Aktion
("buttonImportIcon") fuehrt kein Browser aus. Gesammelt wird, was beim
Speichern in die Datei gestempelt werden soll. */
const bilder = new Map();
const bildknoepfeAufsetzen = async (nurSeite) => {
for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
if (nurSeite && nummer !== nurSeite) continue;
const seite = await dokument.getPage(nummer);
const annots = await seite.getAnnotations({ intent: "display" });
for (const annot of annots) {
const quelltext = (annot.actions?.Action || []).join(" ");
if (!quelltext.includes("buttonImportIcon")) continue;
const kasten = behaelter.querySelector(
`section[data-annotation-id="${annot.id}"]`
);
if (!kasten || kasten.querySelector("[data-viewer-bildwahl]")) continue;
kasten.classList.add("viewer__bildfeld");
const knopf = document.createElement("label");
knopf.className = "viewer__bildwahl";
knopf.setAttribute("data-viewer-bildwahl", "");
knopf.innerHTML =
'<input type="file" accept="image/*" hidden>' +
"<span>Bild einsetzen</span>";
const feld = knopf.querySelector("input");
feld.addEventListener("change", async () => {
const datei = feld.files?.[0];
if (!datei) return;
try {
const foto = await fotoAlsJpeg(datei);
bilder.set(annot.id, {
seite: nummer,
rechteck: annot.rect,
feldname: annot.fieldName,
foto,
});
// Als eigenes Bild und nicht als Hintergrund: der Kasten traegt
// eine Leinwand von pdf.js (das Aussehen des Knopfes aus dem PDF),
// und die liegt ueber jedem Hintergrund.
let vorschau = kasten.querySelector("[data-viewer-bildvorschau]");
if (!vorschau) {
vorschau = document.createElement("img");
vorschau.setAttribute("data-viewer-bildvorschau", "");
vorschau.alt = "";
kasten.append(vorschau);
}
vorschau.src = foto.vorschau;
kasten.classList.add("viewer__bildfeld--belegt");
knopf.querySelector("span").textContent = "Anderes Bild";
} catch (fehler) {
window.tesm.showToast(fehler.message || "Bild nicht lesbar.", "danger");
}
});
kasten.append(knopf);
}
}
};
// Die Knoepfe koennen erst auf eine gerenderte Anmerkungsebene -- die
// entsteht je Seite, wenn sie sichtbar wird.
eventBus.on("annotationlayerrendered", (ereignis) => {
bildknoepfeAufsetzen(ereignis.pageNumber).catch((fehler) =>
console.error("Bildfelder", fehler)
);
});
await bildknoepfeAufsetzen();
return { return {
hinein: () => { hinein: () => {
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2); viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
@@ -233,8 +434,16 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
anpassen: () => { anpassen: () => {
viewer.currentScaleValue = "page-width"; viewer.currentScaleValue = "page-width";
}, },
/** Das ausgefuellte Formular als Bytes -- pdf.js schreibt die Werte hinein. */ /** Das ausgefuellte Formular als Bytes.
speichern: () => dokument.saveDocument(), *
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
*/
speichern: async (pdflibAdresse) => {
const bytes = await dokument.saveDocument();
if (!bilder.size) return bytes;
return fotosEinsetzen(bytes, [...bilder.values()], pdflibAdresse);
},
/** Wurde ueberhaupt etwas eingetragen? */ /** Wurde ueberhaupt etwas eingetragen? */
veraendert: () => { veraendert: () => {
const s = dokument.annotationStorage; const s = dokument.annotationStorage;
@@ -261,11 +470,18 @@ export function initFilePreview(wurzel = document) {
const bildBuehne = stueck("bildbuehne"); const bildBuehne = stueck("bildbuehne");
const bild = stueck("bild"); const bild = stueck("bild");
const pdfBehaelter = stueck("pdf"); const pdfBehaelter = stueck("pdf");
const pdfScroll = stueck("pdfscroll");
const pdfSeiten = stueck("pdfseiten"); const pdfSeiten = stueck("pdfseiten");
const rahmen = stueck("rahmen"); const rahmen = stueck("rahmen");
const leiste = stueck("leiste"); const leiste = stueck("leiste");
const hinweis = stueck("hinweis"); const hinweis = stueck("hinweis");
const speichern = stueck("speichern"); const speichern = stueck("speichern");
const zeile = stueck("speichernzeile");
const ordnerFeld = stueck("ordner");
const nameFeld = stueck("name");
const ersetzenFeld = stueck("ersetzen");
const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen");
const basis = rumpf.dataset.viewerPdfjs || ""; const basis = rumpf.dataset.viewerPdfjs || "";
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari), // Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
@@ -309,6 +525,7 @@ export function initFilePreview(wurzel = document) {
} }
pdfSeiten.replaceChildren(); pdfSeiten.replaceChildren();
if (hinweis) hinweis.textContent = ""; if (hinweis) hinweis.textContent = "";
if (zeile) zeile.hidden = true;
aktuell = null; aktuell = null;
}; };
@@ -328,7 +545,7 @@ export function initFilePreview(wurzel = document) {
} }
if (hinweis) hinweis.textContent = "PDF wird geladen ..."; if (hinweis) hinweis.textContent = "PDF wird geladen ...";
try { try {
pdf = await pdfBetrachter(pdfBehaelter, pdfSeiten, quelle, basis); pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis);
if (hinweis) hinweis.textContent = ""; if (hinweis) hinweis.textContent = "";
} catch (fehler) { } catch (fehler) {
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige // Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
@@ -337,8 +554,12 @@ export function initFilePreview(wurzel = document) {
zeigen("text"); zeigen("text");
rahmen.src = quelle; rahmen.src = quelle;
if (hinweis) { if (hinweis) {
// Den Grund mitgeben: auf einem fremden Geraet ist die Meldung des
// Browsers die einzige Spur, die man hat.
const grund = String(fehler?.message || fehler || "").slice(0, 120);
hinweis.textContent = hinweis.textContent =
"Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige."; "Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige." +
(grund ? ` (${grund})` : "");
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade // Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und // verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
// es sah aus, als koennte die Vorschau einfach keine Formulare. // es sah aus, als koennte die Vorschau einfach keine Formulare.
@@ -365,33 +586,61 @@ export function initFilePreview(wurzel = document) {
dialog.addEventListener("close", aufraeumen); dialog.addEventListener("close", aufraeumen);
/* Ausgefuelltes Formular zurueck in die Freigabe. */ /* Ausgefuelltes Formular zurueck in die Freigabe -- erst fragen, wohin. */
speichern?.addEventListener("click", async () => {
const vorschlagen = () => {
if (!aktuell) return;
const teile = aktuell.pfad.split("/");
const datei = teile.pop() || "formular.pdf";
ordnerFeld.value = teile.join("/");
// Der Name der Vorlage mit Zusatz -- die Vorlage selbst bleibt so heil,
// auch wenn jemand blind auf Speichern drueckt.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt.pdf";
ersetzenFeld.checked = false;
};
speichern?.addEventListener("click", () => {
if (!pdf || !aktuell) return;
vorschlagen();
zeile.hidden = false;
nameFeld.focus();
nameFeld.select();
});
abbrechen?.addEventListener("click", () => {
zeile.hidden = true;
});
jetzt?.addEventListener("click", async () => {
if (!pdf || !aktuell) return; if (!pdf || !aktuell) return;
const ziel = speichern.dataset.url; const ziel = speichern.dataset.url;
speichern.disabled = true; jetzt.disabled = true;
const vorher = speichern.textContent; const vorher = jetzt.textContent;
speichern.textContent = "Speichern ..."; jetzt.textContent = "Speichern ...";
try { try {
const bytes = await pdf.speichern(); const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "");
const daten = new FormData(); const daten = new FormData();
daten.append("pfad", aktuell.pfad); daten.append("pfad", aktuell.pfad);
daten.append("ordner", ordnerFeld.value.trim());
daten.append("name", nameFeld.value.trim());
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
daten.append( daten.append(
"datei", "datei",
new Blob([bytes], { type: "application/pdf" }), new Blob([bytes], { type: "application/pdf" }),
(aktuell.pfad.split("/").pop() || "formular.pdf") nameFeld.value.trim() || "formular.pdf"
); );
const antwort = await window.tesm.api(ziel, { method: "POST", body: daten }); const antwort = await window.tesm.api(ziel, { method: "POST", body: daten });
window.tesm.showToast(antwort.message || "Gespeichert.", "success"); window.tesm.showToast(antwort.message || "Gespeichert.", "success");
dialog.close(); dialog.close();
window.location.reload(); window.location.reload();
} catch (fehler) { } catch (fehler) {
// Die Meldung fuer den Benutzer, die Einzelheiten fuer die Konsole. // Die Meldung fuer den Benutzer, die Einzelheiten fuer die Konsole. Die
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
console.error("Formular speichern", fehler); console.error("Formular speichern", fehler);
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger"); window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
} finally { } finally {
speichern.disabled = false; jetzt.disabled = false;
speichern.textContent = vorher; jetzt.textContent = vorher;
} }
}); });
} }
@@ -0,0 +1,9 @@
pdf-lib 1.17.1 -- https://github.com/Hopding/pdf-lib (MIT, siehe LICENSE)
Aus dem CDN-Bau "pdf-lib.min.js" (UMD, setzt window.PDFLib).
Wofuer: pdf.js kann Formulare ausfuellen, aber nichts in ein PDF zeichnen. Fuer
den Bildknopf in Formularen (Acrobats "buttonImportIcon", das kein Browser
ausfuehren kann) wird das gewaehlte Foto damit in das Feld gestempelt.
Geladen wird die Datei erst, wenn wirklich ein Bild eingesetzt wird.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Andrew Dillon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1 @@
1.17.1
@@ -0,0 +1,16 @@
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).PDFLib={})}(this,(function(t){"use strict";
/*! *****************************************************************************
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */var e=function(t,n){return(e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,n)};function n(t,n){function r(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(r.prototype=n.prototype,new r)}var r=function(){return(r=Object.assign||function(t){for(var e,n=1,r=arguments.length;n<r;n++)for(var i in e=arguments[n])Object.prototype.hasOwnProperty.call(e,i)&&(t[i]=e[i]);return t}).apply(this,arguments)};function i(t,e,n,r){return new(n||(n=Promise))((function(i,o){function a(t){try{l(r.next(t))}catch(t){o(t)}}function s(t){try{l(r.throw(t))}catch(t){o(t)}}function l(t){var e;t.done?i(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(a,s)}l((r=r.apply(t,e||[])).next())}))}function o(t,e){var n,r,i,o,a={label:0,sent:function(){if(1&i[0])throw i[1];return i[1]},trys:[],ops:[]};return o={next:s(0),throw:s(1),return:s(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function s(o){return function(s){return function(o){if(n)throw new TypeError("Generator is already executing.");for(;a;)try{if(n=1,r&&(i=2&o[0]?r.return:o[0]?r.throw||((i=r.return)&&i.call(r),0):r.next)&&!(i=i.call(r,o[1])).done)return i;switch(r=0,i&&(o=[2&o[0],i.value]),o[0]){case 0:case 1:i=o;break;case 4:return a.label++,{value:o[1],done:!1};case 5:a.label++,r=o[1],o=[0];continue;case 7:o=a.ops.pop(),a.trys.pop();continue;default:if(!(i=a.trys,(i=i.length>0&&i[i.length-1])||6!==o[0]&&2!==o[0])){a=0;continue}if(3===o[0]&&(!i||o[1]>i[0]&&o[1]<i[3])){a.label=o[1];break}if(6===o[0]&&a.label<i[1]){a.label=i[1],i=o;break}if(i&&a.label<i[2]){a.label=i[2],a.ops.push(o);break}i[2]&&a.ops.pop(),a.trys.pop();continue}o=e.call(t,a)}catch(t){o=[6,t],r=0}finally{n=i=0}if(5&o[0])throw o[1];return{value:o[0]?o[1]:void 0,done:!0}}([o,s])}}}function a(){for(var t=0,e=0,n=arguments.length;e<n;e++)t+=arguments[e].length;var r=Array(t),i=0;for(e=0;e<n;e++)for(var o=arguments[e],a=0,s=o.length;a<s;a++,i++)r[i]=o[a];return r}for(var s="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",l=new Uint8Array(256),h=0;h<s.length;h++)l[s.charCodeAt(h)]=h;var u,c=function(t){for(var e="",n=t.length,r=0;r<n;r+=3)e+=s[t[r]>>2],e+=s[(3&t[r])<<4|t[r+1]>>4],e+=s[(15&t[r+1])<<2|t[r+2]>>6],e+=s[63&t[r+2]];return n%3==2?e=e.substring(0,e.length-1)+"=":n%3==1&&(e=e.substring(0,e.length-2)+"=="),e},d=function(t){var e,n,r,i,o,a=.75*t.length,s=t.length,h=0;"="===t[t.length-1]&&(a--,"="===t[t.length-2]&&a--);var u=new Uint8Array(a);for(e=0;e<s;e+=4)n=l[t.charCodeAt(e)],r=l[t.charCodeAt(e+1)],i=l[t.charCodeAt(e+2)],o=l[t.charCodeAt(e+3)],u[h++]=n<<2|r>>4,u[h++]=(15&r)<<4|i>>2,u[h++]=(3&i)<<6|63&o;return u},f=/^(data)?:?([\w\/\+]+)?;?(charset=[\w-]+|base64)?.*,/i,p=function(t){var e=t.trim(),n=e.substring(0,100).match(f);if(!n)return d(e);var r=n[0],i=e.substring(r.length);return d(i)},g=function(t){return t.charCodeAt(0)},v=function(t){return t.codePointAt(0)},y=function(t,e){return x(t.toString(16),e,"0").toUpperCase()},m=function(t){return y(t,2)},b=function(t){return String.fromCharCode(t)},w=function(t){return b(parseInt(t,16))},x=function(t,e,n){for(var r="",i=0,o=e-t.length;i<o;i++)r+=n;return r+t},k=function(t,e,n){for(var r=t.length,i=0;i<r;i++)e[n++]=t.charCodeAt(i);return r},F=function(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")},S=function(t){return t.replace(/\t|\u0085|\u2028|\u2029/g," ").replace(/[\b\v]/g,"")},C=["\\n","\\f","\\r","\\u000B"],A=function(t){return/^[\n\f\r\u000B]$/.test(t)},T=function(t){return t.split(/[\n\f\r\u000B]/)},P=function(t){return t.replace(/[\n\f\r\u000B]/g," ")},z=function(t,e){var n,r=t.charCodeAt(e),i=e+1,o=1;return r>=55296&&r<=56319&&t.length>i&&(n=t.charCodeAt(i))>=56320&&n<=57343&&(o=2),[t.slice(e,e+o),o]},R=function(t){for(var e=[],n=0,r=t.length;n<r;){var i=z(t,n),o=i[0],a=i[1];e.push(o),n+=a}return e},O=function(t,e,n,r){for(var i=function(t){for(var e=C.join("|"),n=["$"],r=0,i=t.length;r<i;r++){var o=t[r];if(A(o))throw new TypeError("`wordBreak` must not include "+e);n.push(""===o?".":F(o))}var a=n.join("|");return new RegExp("("+e+")|((.*?)("+a+"))","gm")}(e),o=S(t).match(i),a="",s=0,l=[],h=function(){""!==a&&l.push(a),a="",s=0},u=0,c=o.length;u<c;u++){var d=o[u];if(A(d))h();else{var f=r(d);s+f>n&&h(),a+=d,s+=f}}return h(),l},D=/^D:(\d\d\d\d)(\d\d)?(\d\d)?(\d\d)?(\d\d)?(\d\d)?([+\-Z])?(\d\d)?'?(\d\d)?'?$/,B=function(t){var e=t.match(D);if(e){var n=e[1],r=e[2],i=void 0===r?"01":r,o=e[3],a=void 0===o?"01":o,s=e[4],l=void 0===s?"00":s,h=e[5],u=void 0===h?"00":h,c=e[6],d=void 0===c?"00":c,f=e[7],p=void 0===f?"Z":f,g=e[8],v=void 0===g?"00":g,y=e[9];return new Date(n+"-"+i+"-"+a+"T"+l+":"+u+":"+d+("Z"===p?"Z":""+p+v+":"+(void 0===y?"00":y)))}},N=function(t,e){for(var n,r,i=0;i<t.length;){var o=t.substring(i).match(e);if(!o)return{match:r,pos:i};r=o,i+=(null!==(n=o.index)&&void 0Line truncated
//# sourceMappingURL=pdf-lib.min.js.map
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.2.1" version = "2.3.1"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["cryptography>=42"] dependencies = ["cryptography>=42"]