Compare commits

..
6 Commits
21 changed files with 719 additions and 49 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.2.0"
version = "2.4.0"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+17 -4
View File
@@ -353,19 +353,32 @@ def _start_fileshare_sweep(app: Flask) -> None:
weiter eingebunden.
"""
import threading
import time
from .services import fileshare
def schleife() -> None:
while True:
time.sleep(FILESHARE_SWEEP_SECONDS)
try:
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:
# Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
# Durchlauf scheitert; das aeussere sorgt dafuer, dass sein Ende
# ueberhaupt auffaellt. Vorher fehlte der Import von "time" -- der
# NameError kam beim ersten sleep, also vor jedem try, und der Faden
# war weg, ohne eine Zeile zu hinterlassen.
try:
while True:
time.sleep(FILESHARE_SWEEP_SECONDS)
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()
+50 -4
View File
@@ -80,11 +80,16 @@ def browse():
# vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen
# wurde.
max_upload_mb=uploads.limit_mb(conn),
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
verzeichnisse=(service.all_directories(mount) if listing else []),
)
@bp.get("/<token>/herunterladen")
@permission_required("fileshare.view")
# Herunterladen traegt Daten aus dem System heraus -- in der Rechtematrix ist
# das "Exportieren", nicht "Ansehen". Eine Vorschau bleibt im Haus, eine
# heruntergeladene Datei nicht.
@permission_required("fileshare.export")
def download(token: str):
mount = _mount_or_404(token)
relative = request.args.get("pfad") or ""
@@ -182,7 +187,7 @@ def upload(token: str):
@bp.post("/<token>/formular")
@permission_required("fileshare.create")
@permission_required("fileshare_pdf.create")
def save_form(token: str):
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
@@ -205,15 +210,56 @@ def save_form(token: str):
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
return {"error": "Das ist keine PDF-Datei."}, 415
# Geprueft wird die **Datei**, nicht ein mitgeschicktes Auswahlfeld: wer nur
# anlegen darf, legt endgueltige Fassungen ab. Traegt die Datei noch
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
# niemandem weiter.
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"):
return {
"error": "Sie duerfen ausgefuellte Formulare nur abgeschlossen speichern "
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' waehlen."
}, 403
# 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:
original = service.resolve_path(mount, relative)
# 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:
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
# tippt, bekommt trotzdem eine Datei, die sich oeffnen laesst.
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)
except service.FileshareError as exc:
return {"error": str(exc)}, 400
_audit("fileshare.form_saved", f"{mount.label}:{relative}", ziel.name, severity="notice")
return {"message": f"Als {ziel.name} gespeichert."}
wohin = ziel.relative_to(mount.path.resolve()).as_posix()
_audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice")
return {"message": f"Gespeichert als {wohin}.", "pfad": wohin}
@bp.post("/<token>/ordner")
+21 -2
View File
@@ -85,11 +85,30 @@ DHCP = Resource(
FILESHARE = Resource(
key="fileshare",
label="Dateifreigaben",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXPORT),
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.",
# Genau die Bedeutung aus der Legende: Daten aus dem System
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
# bleibt im Haus, eine heruntergeladene Datei nicht.
A.EXPORT: "Dateien herunterladen.",
},
)
#: Eigene Zeile statt Anhaengsel von "Dateifreigaben": ein Formular auszufuellen
#: ist ein anderer Vorgang als eine Datei hochzuladen, und wer umbenennen darf,
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
FILESHARE_PDF = Resource(
key="fileshare_pdf",
label="PDF-Formulare",
actions=(A.VIEW, A.CREATE, A.EDIT),
hint="Formulare in der Vorschau ausfuellen und in der Freigabe ablegen",
action_hints={
A.VIEW: "In der Vorschau ausfuellen (ohne Speichern).",
A.CREATE: "Ausgefuellt speichern -- abgeschlossen, Felder gesperrt.",
A.EDIT: "Ausgefuellt speichern -- weiter ausfuellbar, inklusive Bild.",
},
)
@@ -119,7 +138,7 @@ FILES_AREA = Area(
key="files",
label="Dateifreigaben",
icon="folder",
resources=(FILESHARE,),
resources=(FILESHARE, FILESHARE_PDF),
)
PERMISSIONS = PermissionTree(
+24
View File
@@ -250,8 +250,32 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
"""
# Neue Rechte sind nach einem Update bei niemandem gesetzt -- ohne diesen
# Schritt koennte niemand mehr herunterladen oder ein Formular speichern,
# obwohl sich an den Gruppen nichts geaendert hat. Die Zuordnung bildet ab, was
# vorher galt.
V4 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare.export' FROM group_permissions
WHERE permission = 'fileshare.view';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.view' FROM group_permissions
WHERE permission = 'fileshare.view';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
WHERE permission = 'fileshare.create';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions
WHERE permission = 'fileshare.edit';
"""
TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3),
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
)
+77 -1
View File
@@ -21,6 +21,7 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
from __future__ import annotations
import zlib
import logging
import mimetypes
import os
@@ -548,6 +549,10 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
else:
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
record["preview_kind"] = preview_kind(entry.name)
# Getrennt, damit die Oberflaeche nur den Namen zum Bearbeiten
# gibt und die Endung danebenstellt.
record["stem"] = entry.stem
record["suffix"] = entry.suffix
files.append(record)
except OSError as exc:
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
@@ -588,6 +593,59 @@ def safe_filename(name: str) -> str:
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 hat_formularfelder(daten: bytes) -> bool:
"""Steckt in diesen PDF-Bytes noch ein ausfuellbares Formular?
Gesucht wird nach Formularfeldern (``/Subtype /Widget``) -- auch in
gepackten Objektstroemen, denn moderne Schreiber legen fast alles dort ab.
Ein flach gerechnetes Formular hat keine mehr.
Bewusst ohne Zusatzpaket: die Frage ist einfach genug, und eine
PDF-Bibliothek auf jedem Zielsystem waere ein hoher Preis dafuer.
"""
text = bytearray(daten)
for treffer in re.finditer(rb"stream\r?\n", daten):
start = treffer.end()
ende = daten.find(b"endstream", start)
if ende == -1:
continue
try:
text += b"\n" + zlib.decompress(daten[start:ende])
except zlib.error:
continue # kein zlib-Strom -- Bilder, Schriften, alles moegliche
return re.search(rb"/Subtype\s*/Widget", bytes(text)) is not None
def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
@@ -626,8 +684,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
"""Benennt um und behaelt die Endung der Datei.
Eingegeben wird nur der Name ohne Endung. Eine Datei, die danach auf
``.pdf.txt`` oder auf gar nichts endet, waere auf einer Windows-Freigabe
praktisch kaputt -- sie oeffnet sich nicht mehr mit dem richtigen
Programm. Verzeichnisse haben keine Endung; dort bleibt der Name, wie er
eingegeben wurde.
Wer die Endung trotzdem mittippt, bekommt sie nicht doppelt.
"""
path = resolve_path(mount, relative)
target = path.parent / safe_filename(new_name)
endung = path.suffix if path.is_file() else ""
gewuenscht = (new_name or "").strip()
if endung and gewuenscht.lower().endswith(endung.lower()):
gewuenscht = gewuenscht[: -len(endung)]
if not gewuenscht:
raise FileshareError("Der Name darf nicht leer sein.")
target = path.parent / safe_filename(gewuenscht + endung)
if target == path:
return path
if target.exists():
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
path.rename(target)
@@ -106,14 +106,22 @@
data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"{% endif %}>
{{ icon('file-text', size=15) }} {{ entry.name }}
</button>
{% else %}
{% if current_user.has('fileshare.export') %}
<a class="link-icon"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{{ icon('file-text', size=15) }} {{ entry.name }}
</a>
{% else %}
{# Ohne Exportrecht bleibt der Name Text: es gibt hier
nichts zu holen, und ein toter Link waere Spott. #}
<span class="link-icon faint">
{{ icon('file-text', size=15) }} {{ entry.name }}
</span>
{% endif %}
{% endif %}
</td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
@@ -126,19 +134,22 @@
data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"{% endif %}
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %}
{% if current_user.has('fileshare.edit') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-rename"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}"
data-fill-Name="{{ entry.stem }}"
data-fill-Endung="{{ entry.suffix or '' }}"
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
{% endif %}
{% if current_user.has('fileshare.export') %}
<a class="btn btn--sm btn--ghost"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -209,16 +220,62 @@
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
<div class="modal__body modal__body--flush" data-viewer
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/') }}">
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}"
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__stage" data-viewer-bildbuehne hidden>
<img class="viewer__img" alt="" data-viewer-bild>
</div>
<div class="viewer__pdf" data-viewer-pdf hidden>
{# 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>
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare
ablegen darf, sieht sie überhaupt. #}
{% if current_user.has('fileshare_pdf.create') %}
<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 (ohne Endung)</span>
<input class="input" data-viewer-name autocomplete="off">
</label>
<label class="viewer__feld">
<span class="field__label">Speichern als</span>
{# Zwei Wege, beide gewollt: weiter ausfüllbar (das Bild wird das
Aussehen des Knopfes und lässt sich später austauschen) oder
abgeschlossen (alle Felder werden Teil der Seite). #}
{# Nur, was das Recht hergibt: "weiter ausfüllbar" hängt an
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
<select class="select" data-viewer-modus>
{% if current_user.has('fileshare_pdf.edit') %}
<option value="formular">Formular – weiter ausfüllbar</option>
{% endif %}
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
</select>
</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>
{% endif %}
<div class="viewer__bar" data-viewer-leiste hidden>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
@@ -230,10 +287,12 @@
</div>
</div>
<div class="modal__footer">
{% if current_user.has('fileshare.export') %}
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
{{ icon('download', size=15) }} Herunterladen
</a>
{% if current_user.has('fileshare.create') %}
{% endif %}
{% if current_user.has('fileshare_pdf.create') %}
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
data-url="{{ url_for('fileshare.save_form', token=token) }}">
{{ icon('save', size=15) }} Ausgefüllt speichern
@@ -242,6 +301,10 @@
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
</div>
{% 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 %}
{% if listing and current_user.has('fileshare.edit') %}
@@ -251,9 +314,12 @@
<input type="hidden" name="parent" value="{{ listing.relative }}">
<input type="hidden" name="pfad" data-fill-target="Pfad" value="">
<div class="modal__body">
{{ field('name', 'Neuer Name', required=True, attrs='data-fill-target="Name"') }}
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
attrs='data-fill-target="Name"') }}
<p class="fs-xs faint">
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
Die Endung <code data-fill-target="Endung"></code> bleibt, wie sie ist --
eine Datei ohne passende Endung öffnet auf der Freigabe nichts mehr.
Umbenannt wird dort direkt; einen Papierkorb gibt es nicht.
</p>
</div>
<div class="modal__footer">
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.2.0"
version = "2.4.0"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -135,7 +135,7 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen.
auth_tagline: str = "",
app_version: str = "2.2.0",
app_version: str = "2.4.0",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
@@ -720,6 +720,16 @@
min-height: 220px;
background: var(--surface-1);
}
/* Hochkant -- Tablet oder Monitor im Hochformat -- gibt es unter dem Dialog
viel Platz, den die Obergrenze verschenkt. Beim Ausfuellen eines
Formulars zaehlt jede sichtbare Zeile, also nehmen wir dort die Hoehe,
die da ist. Der Rest (Kopf, Dateiname, Fusszeile) braucht rund 200 Pixel;
die 240 lassen etwas Luft. */
@media (orientation: portrait) {
.viewer {
height: calc(100dvh - 240px);
}
}
.viewer > .file-preview {
flex: 1;
height: auto;
@@ -754,11 +764,32 @@
user-select: 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 {
position: relative;
flex: 1;
min-height: 0;
/* Ein PDF ist weisses Papier. pdf.js malt Formularfelder mit Systemfarben
und ueberlaesst Ankreuzfelder dem Browser -- im dunklen Design heisst
CanvasText weiss, und die Haken waren auf dem Papier unsichtbar. Fuer
diesen Ausschnitt gilt deshalb immer das helle Schema. */
color-scheme: light;
}
/* 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;
inset: 0;
bottom: var(--sp-10);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
@@ -773,6 +804,56 @@
.viewer__hint {
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
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
unruhig. */
@@ -902,7 +902,11 @@ function boot() {
einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */
function initViewer() {
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())
.catch((fehler) => console.error("Dateibetrachter", fehler));
}
@@ -16,6 +16,11 @@
*
* pdf.js liegt im Paket (static/vendor/pdfjs) und wird erst geladen, wenn
* wirklich ein PDF geoeffnet wird: 1,7 MB gehoeren nicht in jeden Seitenaufruf.
*
* Die Dateien heissen ".js" und nicht ".mjs": die mime.types von nginx kennen
* ".mjs" nicht und liefern "application/octet-stream" -- damit lehnt jeder
* Browser ein Modul ab, und zwar ohne dass der Benutzer erfaehrt, warum. Am
* Inhalt aendert die Endung nichts.
* ========================================================================== */
const MIN_ZOOM = 0.1;
@@ -137,25 +142,174 @@ 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;
}
/** Setzt die Fotos ein und schliesst das Formular auf Wunsch ab.
*
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
* die Feldwerte bleiben also erhalten.
*
* Das Foto wird das **Aussehen des Bildknopfes** und nicht in die Seite
* gestempelt: so bleibt der Knopf erhalten, das Bild ist trotzdem zu sehen,
* und beim naechsten Oeffnen laesst es sich austauschen. Genau das tut
* Acrobat mit "buttonImportIcon".
*
* ``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 } = {}) {
const PDFLib = await pdflibLaden(adresse);
const dokument = await PDFLib.PDFDocument.load(bytes);
const formular = dokument.getForm();
for (const eintrag of bilder) {
const jpeg = await dokument.embedJpg(eintrag.foto.bytes);
const knopf = eintrag.feldname ? formular.getFieldMaybe(eintrag.feldname) : null;
if (knopf && typeof knopf.setImage === "function") {
knopf.setImage(jpeg);
continue;
}
// Kein Knopf gefunden: dann eben in die Seite. Damit nichts darueber
// liegt, muss das Feld weichen.
const seite = dokument.getPage(eintrag.seite - 1);
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);
seite.drawImage(jpeg, {
x: Math.min(x1, x2) + (kastenBreite - jpeg.width * faktor) / 2,
y: Math.min(y1, y2) + (kastenHoehe - jpeg.height * faktor) / 2,
width: jpeg.width * faktor,
height: jpeg.height * faktor,
});
if (knopf) {
try {
formular.removeField(knopf);
} catch (fehler) {
console.warn("Bildknopf liess sich nicht entfernen", fehler);
}
}
}
if (abschliessen) {
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
// gewollt: so kann niemand mehr etwas verstellen.
formular.flatten();
}
return dokument.save();
}
/* -- PDF ------------------------------------------------------------------- */
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. */
async function pdfjsLaden(basis) {
const adressen = pdfjsAdressen(basis);
if (!pdfjsGeladen) {
pdfjsGeladen = (async () => {
const lib = await import(`${basis}pdf.min.mjs`);
const lib = await import(adressen.lib);
// Der Komponentenbau von pdf.js sucht die Bibliothek unter diesem Namen.
globalThis.pdfjsLib = lib;
const komponenten = await import(`${basis}pdf_viewer.mjs`);
const komponenten = await import(adressen.komponenten);
// Das Stylesheet von pdf.js zeichnet die Formularfelder. 100 KB gehoeren
// nicht in jeden Seitenaufruf -- also erst hier.
const stil = document.createElement("link");
stil.rel = "stylesheet";
stil.href = `${basis}pdf_viewer.css`;
stil.href = adressen.stil;
document.head.append(stil);
lib.GlobalWorkerOptions.workerSrc = `${basis}pdf.worker.min.mjs`;
lib.GlobalWorkerOptions.workerSrc = adressen.worker;
return { lib, komponenten };
})().catch((fehler) => {
pdfjsGeladen = null;
@@ -165,9 +319,15 @@ async function pdfjsLaden(basis) {
return pdfjsGeladen;
}
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */
async function pdfBetrachter(behaelter, seiten, url, basis) {
/** Zeigt ein PDF -- mit ausfuellbaren Feldern, sofern das erlaubt ist.
*
* Ohne ``formulare`` bleibt es bei der Darstellung aus dem Dokument: pdf.js
* malt dann die Marken, die im PDF stehen (etwa rotes Kreuz und gruener
* Haken), statt Bedienelemente des Browsers einzusetzen.
*/
async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true } = {}) {
const { lib, komponenten } = await pdfjsLaden(basis);
const adressen = pdfjsAdressen(basis);
const eventBus = new komponenten.EventBus();
const linkService = new komponenten.PDFLinkService({ eventBus });
const viewer = new komponenten.PDFViewer({
@@ -175,8 +335,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
viewer: seiten,
eventBus,
linkService,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern.
annotationMode: lib.AnnotationMode.ENABLE_FORMS,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern --
// wer nicht ausfuellen darf, bekommt die Darstellung aus dem Dokument.
annotationMode: formulare
? lib.AnnotationMode.ENABLE_FORMS
: lib.AnnotationMode.ENABLE,
textLayerMode: 1,
});
linkService.setViewer(viewer);
@@ -185,7 +348,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
url,
// Ohne die Standardschriften bleibt jede Seite mit Text leer: Helvetica
// und Verwandte liegen als Datei im Paket, nicht im Browser.
standardFontDataUrl: `${basis}standard_fonts/`,
standardFontDataUrl: adressen.schriften,
isEvalSupported: false,
}).promise;
viewer.setDocument(dokument);
@@ -218,6 +381,73 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
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) => {
if (!formulare) return;
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 {
hinein: () => {
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
@@ -228,8 +458,17 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
anpassen: () => {
viewer.currentScaleValue = "page-width";
},
/** Das ausgefuellte Formular als Bytes -- pdf.js schreibt die Werte hinein. */
speichern: () => dokument.saveDocument(),
/** Das ausgefuellte Formular als Bytes.
*
* 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 } = {}) => {
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 });
},
/** Wurde ueberhaupt etwas eingetragen? */
veraendert: () => {
const s = dokument.annotationStorage;
@@ -256,11 +495,19 @@ export function initFilePreview(wurzel = document) {
const bildBuehne = stueck("bildbuehne");
const bild = stueck("bild");
const pdfBehaelter = stueck("pdf");
const pdfScroll = stueck("pdfscroll");
const pdfSeiten = stueck("pdfseiten");
const rahmen = stueck("rahmen");
const leiste = stueck("leiste");
const hinweis = stueck("hinweis");
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 modusFeld = stueck("modus");
const basis = rumpf.dataset.viewerPdfjs || "";
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
@@ -304,6 +551,7 @@ export function initFilePreview(wurzel = document) {
}
pdfSeiten.replaceChildren();
if (hinweis) hinweis.textContent = "";
if (zeile) zeile.hidden = true;
aktuell = null;
};
@@ -323,7 +571,9 @@ export function initFilePreview(wurzel = document) {
}
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
try {
pdf = await pdfBetrachter(pdfBehaelter, pdfSeiten, quelle, basis);
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
formulare: rumpf.dataset.viewerFormulare === "1",
});
if (hinweis) hinweis.textContent = "";
} catch (fehler) {
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
@@ -332,8 +582,16 @@ export function initFilePreview(wurzel = document) {
zeigen("text");
rahmen.src = quelle;
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 =
"Dieses Geraet kann die Formularansicht 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
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
// es sah aus, als koennte die Vorschau einfach keine Formulare.
leiste.hidden = false;
}
}
};
@@ -356,33 +614,64 @@ export function initFilePreview(wurzel = document) {
dialog.addEventListener("close", aufraeumen);
/* Ausgefuelltes Formular zurueck in die Freigabe. */
speichern?.addEventListener("click", async () => {
/* Ausgefuelltes Formular zurueck in die Freigabe -- erst fragen, wohin. */
const vorschlagen = () => {
if (!aktuell) return;
const teile = aktuell.pfad.split("/");
const datei = teile.pop() || "formular.pdf";
ordnerFeld.value = teile.join("/");
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
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;
const ziel = speichern.dataset.url;
speichern.disabled = true;
const vorher = speichern.textContent;
speichern.textContent = "Speichern ...";
jetzt.disabled = true;
const vorher = jetzt.textContent;
jetzt.textContent = "Speichern ...";
try {
const bytes = await pdf.speichern();
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
abschliessen: modusFeld?.value === "fertig",
});
const daten = new FormData();
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(
"datei",
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 });
window.tesm.showToast(antwort.message || "Gespeichert.", "success");
dialog.close();
window.location.reload();
} 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);
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
} finally {
speichern.disabled = false;
speichern.textContent = vorher;
jetzt.disabled = false;
jetzt.textContent = vorher;
}
});
}
@@ -1,6 +1,11 @@
pdf.js 4.10.38 -- https://github.com/mozilla/pdf.js
Aus dem npm-Paket pdfjs-dist; nur build/pdf.min.mjs, build/pdf.worker.min.mjs,
web/pdf_viewer.mjs, web/pdf_viewer.css und web/images. Apache-2.0, siehe LICENSE.
web/pdf_viewer.mjs, web/pdf_viewer.css, web/images und standard_fonts.
Apache-2.0, siehe LICENSE.
Die drei Skriptdateien sind von .mjs auf .js umbenannt: die mime.types von
nginx kennen .mjs nicht, und mit application/octet-stream lehnt jeder Browser
ein Modul ab. Der Inhalt ist unveraendert.
Warum eingebettet und nicht von einem CDN: TESM laeuft in abgeschotteten
Netzen. Was die Oberflaeche braucht, muss im Paket liegen.
@@ -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]
name = "tesm-licensing"
version = "2.2.0"
version = "2.4.0"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]