Compare commits

...
3 Commits
11 changed files with 237 additions and 48 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.3.1" version = "2.4.0"
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 = [
+19 -2
View File
@@ -86,7 +86,10 @@ def browse():
@bp.get("/<token>/herunterladen") @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): def download(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.args.get("pfad") or "" relative = request.args.get("pfad") or ""
@@ -184,7 +187,7 @@ def upload(token: str):
@bp.post("/<token>/formular") @bp.post("/<token>/formular")
@permission_required("fileshare.create") @permission_required("fileshare_pdf.create")
def save_form(token: str): def save_form(token: str):
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab. """Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
@@ -207,6 +210,17 @@ 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
# 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. # Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
ordner = (request.form.get("ordner") or "").strip().strip("/") ordner = (request.form.get("ordner") or "").strip().strip("/")
wunschname = (request.form.get("name") or "").strip() wunschname = (request.form.get("name") or "").strip()
@@ -224,6 +238,9 @@ def save_form(token: str):
if not zielordner.is_dir(): if not zielordner.is_dir():
return {"error": f"Den Ordner '{ordner}' gibt es in dieser Freigabe nicht."}, 400 return {"error": f"Den Ordner '{ordner}' gibt es in dieser Freigabe nicht."}, 400
if wunschname: 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) name = service.safe_filename(wunschname)
if not name.lower().endswith(".pdf"): if not name.lower().endswith(".pdf"):
name += ".pdf" name += ".pdf"
+21 -2
View File
@@ -85,11 +85,30 @@ DHCP = Resource(
FILESHARE = Resource( FILESHARE = Resource(
key="fileshare", key="fileshare",
label="Dateifreigaben", 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)", hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={ action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.", A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.", 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", key="files",
label="Dateifreigaben", label="Dateifreigaben",
icon="folder", icon="folder",
resources=(FILESHARE,), resources=(FILESHARE, FILESHARE_PDF),
) )
PERMISSIONS = PermissionTree( 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, ...] = ( TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1), Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3), Migration(version=3, name="restart-events", sql=V3),
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
) )
+47 -1
View File
@@ -21,6 +21,7 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
from __future__ import annotations from __future__ import annotations
import zlib
import logging import logging
import mimetypes import mimetypes
import os import os
@@ -548,6 +549,10 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
else: else:
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
record["preview_kind"] = preview_kind(entry.name) 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) files.append(record)
except OSError as exc: except OSError as exc:
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
@@ -618,6 +623,29 @@ def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[
return gefunden 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: 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``.
@@ -656,8 +684,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
def rename_entry(mount: Mount, relative: str, new_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) 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(): if target.exists():
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.") raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
path.rename(target) path.rename(target)
@@ -106,14 +106,22 @@
data-fill-Kind="{{ entry.preview_kind }}" data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}" data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}" 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 }} {{ icon('file-text', size=15) }} {{ entry.name }}
</button> </button>
{% else %} {% else %}
{% if current_user.has('fileshare.export') %}
<a class="link-icon" <a class="link-icon"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"> href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
{{ icon('file-text', size=15) }} {{ entry.name }} {{ icon('file-text', size=15) }} {{ entry.name }}
</a> </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 %} {% endif %}
</td> </td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td> <td class="numeric fs-sm">{{ entry.size | bytes }}</td>
@@ -126,19 +134,22 @@
data-fill-Kind="{{ entry.preview_kind }}" data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}" data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}" 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> aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %} {% endif %}
{% if current_user.has('fileshare.edit') %} {% if current_user.has('fileshare.edit') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-rename" data-dialog-open="file-rename"
data-fill-Pfad="{{ entry.relative }}" 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> aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
{% endif %} {% endif %}
{% if current_user.has('fileshare.export') %}
<a class="btn btn--sm btn--ghost" <a class="btn btn--sm btn--ghost"
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}" href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a> aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -209,6 +220,9 @@
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
{# 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-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"> data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}">
<div class="viewer"> <div class="viewer">
@@ -226,7 +240,9 @@
<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 {# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
eine Zeile Formular ueber jeder Bildvorschau. #} 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> <div class="viewer__save" data-viewer-speichernzeile hidden>
<label class="viewer__feld"> <label class="viewer__feld">
<span class="field__label">Ordner</span> <span class="field__label">Ordner</span>
@@ -234,9 +250,24 @@
placeholder="Wurzelverzeichnis der Freigabe" autocomplete="off"> placeholder="Wurzelverzeichnis der Freigabe" autocomplete="off">
</label> </label>
<label class="viewer__feld"> <label class="viewer__feld">
<span class="field__label">Dateiname</span> <span class="field__label">Dateiname (ohne Endung)</span>
<input class="input" data-viewer-name autocomplete="off"> <input class="input" data-viewer-name autocomplete="off">
</label> </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"> <label class="check">
<input type="checkbox" data-viewer-ersetzen> <input type="checkbox" data-viewer-ersetzen>
<span>Vorhandene ersetzen</span> <span>Vorhandene ersetzen</span>
@@ -244,6 +275,7 @@
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button> <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> <button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button>
</div> </div>
{% endif %}
<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>
@@ -255,10 +287,12 @@
</div> </div>
</div> </div>
<div class="modal__footer"> <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> <a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
{{ icon('download', size=15) }} Herunterladen {{ icon('download', size=15) }} Herunterladen
</a> </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 <button class="btn btn--sm" type="button" data-viewer-speichern hidden
data-url="{{ url_for('fileshare.save_form', token=token) }}"> data-url="{{ url_for('fileshare.save_form', token=token) }}">
{{ icon('save', size=15) }} Ausgefüllt speichern {{ icon('save', size=15) }} Ausgefüllt speichern
@@ -280,9 +314,12 @@
<input type="hidden" name="parent" value="{{ listing.relative }}"> <input type="hidden" name="parent" value="{{ listing.relative }}">
<input type="hidden" name="pfad" data-fill-target="Pfad" value=""> <input type="hidden" name="pfad" data-fill-target="Pfad" value="">
<div class="modal__body"> <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"> <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> </p>
</div> </div>
<div class="modal__footer"> <div class="modal__footer">
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.3.1" version = "2.4.0"
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.3.1", app_version: str = "2.4.0",
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,
@@ -720,6 +720,16 @@
min-height: 220px; min-height: 220px;
background: var(--surface-1); 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 { .viewer > .file-preview {
flex: 1; flex: 1;
height: auto; height: auto;
@@ -763,6 +773,11 @@
position: relative; position: relative;
flex: 1; flex: 1;
min-height: 0; 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 /* pdf.js rechnet fuer die Seite mit dem Standard "content-box": ihr
9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales 9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales
@@ -215,44 +215,59 @@ async function pdflibLaden(adresse) {
return pdflibGeladen; return pdflibGeladen;
} }
/** Stempelt die gewaehlten Fotos in die Bildfelder. /** Setzt die Fotos ein und schliesst das Formular auf Wunsch ab.
* *
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat -- * Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
* die Formularwerte bleiben also erhalten. * 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 fotosEinsetzen(bytes, bilder, adresse) { async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = {}) {
const PDFLib = await pdflibLaden(adresse); const PDFLib = await pdflibLaden(adresse);
const dokument = await PDFLib.PDFDocument.load(bytes); const dokument = await PDFLib.PDFDocument.load(bytes);
const formular = dokument.getForm(); const formular = dokument.getForm();
for (const eintrag of bilder) { for (const eintrag of bilder) {
const seite = dokument.getPage(eintrag.seite - 1);
const jpeg = await dokument.embedJpg(eintrag.foto.bytes); 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 [x1, y1, x2, y2] = eintrag.rechteck;
const kastenBreite = Math.abs(x2 - x1); const kastenBreite = Math.abs(x2 - x1);
const kastenHoehe = Math.abs(y2 - y1); const kastenHoehe = Math.abs(y2 - y1);
// Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt. // Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt.
const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height); const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height);
const breite = jpeg.width * faktor;
const hoehe = jpeg.height * faktor;
seite.drawImage(jpeg, { seite.drawImage(jpeg, {
x: Math.min(x1, x2) + (kastenBreite - breite) / 2, x: Math.min(x1, x2) + (kastenBreite - jpeg.width * faktor) / 2,
y: Math.min(y1, y2) + (kastenHoehe - hoehe) / 2, y: Math.min(y1, y2) + (kastenHoehe - jpeg.height * faktor) / 2,
width: breite, width: jpeg.width * faktor,
height: hoehe, height: jpeg.height * faktor,
}); });
// Den Knopf aus der fertigen Kopie nehmen: seine Flaeche liegt ueber dem if (knopf) {
// 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 { try {
const feld = formular.getFieldMaybe(eintrag.feldname); formular.removeField(knopf);
if (feld) formular.removeField(feld);
} catch (fehler) { } 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); 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(); return dokument.save();
} }
@@ -304,8 +319,13 @@ async function pdfjsLaden(basis) {
return pdfjsGeladen; return pdfjsGeladen;
} }
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */ /** Zeigt ein PDF -- mit ausfuellbaren Feldern, sofern das erlaubt ist.
async function pdfBetrachter(behaelter, seiten, url, basis) { *
* 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 { lib, komponenten } = await pdfjsLaden(basis);
const adressen = pdfjsAdressen(basis); const adressen = pdfjsAdressen(basis);
const eventBus = new komponenten.EventBus(); const eventBus = new komponenten.EventBus();
@@ -315,8 +335,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
viewer: seiten, viewer: seiten,
eventBus, eventBus,
linkService, linkService,
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern. // Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern --
annotationMode: lib.AnnotationMode.ENABLE_FORMS, // wer nicht ausfuellen darf, bekommt die Darstellung aus dem Dokument.
annotationMode: formulare
? lib.AnnotationMode.ENABLE_FORMS
: lib.AnnotationMode.ENABLE,
textLayerMode: 1, textLayerMode: 1,
}); });
linkService.setViewer(viewer); linkService.setViewer(viewer);
@@ -364,6 +387,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
const bilder = new Map(); const bilder = new Map();
const bildknoepfeAufsetzen = async (nurSeite) => { const bildknoepfeAufsetzen = async (nurSeite) => {
if (!formulare) return;
for (let nummer = 1; nummer <= dokument.numPages; nummer++) { for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
if (nurSeite && nummer !== nurSeite) continue; if (nurSeite && nummer !== nurSeite) continue;
const seite = await dokument.getPage(nummer); const seite = await dokument.getPage(nummer);
@@ -439,10 +463,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit * pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen. * pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
*/ */
speichern: async (pdflibAdresse) => { speichern: async (pdflibAdresse, { abschliessen = false } = {}) => {
const bytes = await dokument.saveDocument(); const bytes = await dokument.saveDocument();
if (!bilder.size) return bytes; // Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht.
return fotosEinsetzen(bytes, [...bilder.values()], pdflibAdresse); if (!bilder.size && !abschliessen) return bytes;
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen });
}, },
/** Wurde ueberhaupt etwas eingetragen? */ /** Wurde ueberhaupt etwas eingetragen? */
veraendert: () => { veraendert: () => {
@@ -482,6 +507,7 @@ export function initFilePreview(wurzel = document) {
const ersetzenFeld = stueck("ersetzen"); const ersetzenFeld = stueck("ersetzen");
const jetzt = stueck("jetzt"); const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen"); const abbrechen = stueck("abbrechen");
const modusFeld = stueck("modus");
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),
@@ -545,7 +571,9 @@ export function initFilePreview(wurzel = document) {
} }
if (hinweis) hinweis.textContent = "PDF wird geladen ..."; if (hinweis) hinweis.textContent = "PDF wird geladen ...";
try { try {
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis); pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
formulare: rumpf.dataset.viewerFormulare === "1",
});
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
@@ -593,9 +621,10 @@ export function initFilePreview(wurzel = document) {
const teile = aktuell.pfad.split("/"); const teile = aktuell.pfad.split("/");
const datei = teile.pop() || "formular.pdf"; const datei = teile.pop() || "formular.pdf";
ordnerFeld.value = teile.join("/"); ordnerFeld.value = teile.join("/");
// Der Name der Vorlage mit Zusatz -- die Vorlage selbst bleibt so heil, // Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
// auch wenn jemand blind auf Speichern drueckt. // oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt.pdf"; // Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
ersetzenFeld.checked = false; ersetzenFeld.checked = false;
}; };
@@ -618,7 +647,9 @@ export function initFilePreview(wurzel = document) {
const vorher = jetzt.textContent; const vorher = jetzt.textContent;
jetzt.textContent = "Speichern ..."; jetzt.textContent = "Speichern ...";
try { try {
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || ""); const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
abschliessen: modusFeld?.value === "fertig",
});
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("ordner", ordnerFeld.value.trim());
@@ -627,7 +658,7 @@ export function initFilePreview(wurzel = document) {
daten.append( daten.append(
"datei", "datei",
new Blob([bytes], { type: "application/pdf" }), new Blob([bytes], { type: "application/pdf" }),
nameFeld.value.trim() || "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");
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.3.1" version = "2.4.0"
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"]