diff --git a/.forgejo/workflows/tests.yml b/.forgejo/workflows/tests.yml new file mode 100644 index 0000000..9e1e201 --- /dev/null +++ b/.forgejo/workflows/tests.yml @@ -0,0 +1,42 @@ +name: Tests + +# Forgejo Actions: runs the test suites only. The standalone executables and +# the releases are built on GitHub Actions (.github/workflows/build.yml and +# release.yml); .gitlab-ci.yml is the GitLab twin of this file. +# +# Three steps, as on GitHub: the headless suite, a GUI canary, then the +# guarded Gui* suites under Xvfb (the end-to-end layer of this Tk app). +on: + push: + branches: [develop, main] + pull_request: + workflow_dispatch: + +jobs: + tests: + runs-on: docker # adapt to the instance's runner label + timeout-minutes: 30 # a stalled Tk suite must not hold the runner + container: + image: python:3.13-bookworm # full image: tkinter + libtk included + env: + XMODIFIERS: "@im=none" # Tk must not wait on the ibus XIM bridge + PIP_DISABLE_PIP_VERSION_CHECK: "1" + steps: + # nodejs: JS actions (checkout) run inside the job container on Forgejo + - name: System deps + run: | + apt-get update + apt-get install -y --no-install-recommends nodejs git xvfb xauth fonts-dejavu-core + - uses: actions/checkout@v4 + - name: Install dependencies + run: pip install -r requirements.txt + - name: Unit tests (headless) + run: env -u DISPLAY python -m unittest -v + - name: GUI canary (broken Tk or broken gui module must fail, not skip) + run: xvfb-run -a -s "-screen 0 1920x1200x24" python -c "import tkinter, customtkinter, gui; tkinter.Tk().destroy()" + - name: GUI suites (Xvfb, skips are failures) + timeout-minutes: 20 + env: + CACHET_REQUIRE_GUI: "1" + PYTHONFAULTHANDLER: "1" # with timeout -s ABRT: a stalled suite dumps its stacks + run: xvfb-run -a -s "-screen 0 1920x1200x24" timeout -s ABRT 900 python -m unittest -v -k Gui diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c91f91..e467171 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,9 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} + # A stalled Tk suite or build must not hold the runner (and, for a + # release, its concurrency group) for GitHub's 6-hour default. + timeout-minutes: 45 steps: - uses: actions/checkout@v4 @@ -35,37 +38,72 @@ jobs: # poppler-utils: only to enable a future smoke-test of the GUI preview; # not bundled into the binary (runtime dependency on the user's side). + # xvfb/xauth + Tcl/Tk: the virtual display and the Tk runtime of the GUI + # suites below. - name: Linux system deps if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y poppler-utils + run: sudo apt-get update && sudo apt-get install -y poppler-utils xvfb xauth libtk8.6 libtcl8.6 - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt -r requirements-build.txt - # Headless: the GUI tests skip without a display. Linux only — on the - # Windows runner Tk could open real windows and hang the job. - - name: Unit tests + # Tests run on Linux only — on the Windows runner Tk could open real + # windows and hang the job. Three steps: the headless suite (the Gui* + # classes skip themselves without a display), a canary, then the Gui* + # suites under Xvfb — the end-to-end layer of this Tk app. + - name: Unit tests (headless) if: runner.os == 'Linux' - run: python -m unittest -v + run: env -u DISPLAY python -m unittest -v + + # The canary imports gui on purpose: the display guard of the GUI tests + # swallows an ImportError / NameError / SyntaxError of gui.py exactly + # like a missing display. + - name: GUI canary (a broken Tk or a broken gui module must fail, not skip) + if: runner.os == 'Linux' + env: + XMODIFIERS: "@im=none" + run: xvfb-run -a -s "-screen 0 1920x1200x24" python -c "import tkinter, customtkinter, gui; tkinter.Tk().destroy()" + + # CACHET_REQUIRE_GUI=1 turns the guard's skip into a failure, so "GUI + # suites green" can never mean "everything skipped". + - name: GUI suites (Xvfb, skips are failures) + if: runner.os == 'Linux' + timeout-minutes: 20 + env: + XMODIFIERS: "@im=none" + CACHET_REQUIRE_GUI: "1" + PYTHONFAULTHANDLER: "1" # with timeout -s ABRT: a stalled suite dumps its stacks + run: xvfb-run -a -s "-screen 0 1920x1200x24" timeout -s ABRT 900 python -m unittest -v -k Gui - name: Build with PyInstaller run: pyinstaller --noconfirm --clean cachet.spec # Smoke-test WITHOUT a card: we generate the fixtures with the project's # generators (make_pdf writes a valid raw PDF; make_png a PNG), then we - # exercise the CLI binary's image mode end to end. We do NOT use Pillow's - # Image.save(".pdf"): its PDF writer requires the JPEG encoder, which is - # absent from some Pillow wheels (KeyError: 'JPEG'). eID mode requires - # hardware -> not tested here. - - name: Smoke test (CLI image mode, no eID hardware) + # exercise the CLI binary's visual signatures end to end: the legacy + # single image, a --text signature and a --signatures file. The last two + # prove that fonts/ and Pillow's FreeType renderer (PIL._imagingft) are + # in the CLI binary. We do NOT use Pillow's Image.save(".pdf"): its PDF + # writer requires the JPEG encoder, which is absent from some Pillow + # wheels (KeyError: 'JPEG'). eID mode requires hardware -> not tested + # here. + - name: Smoke test (CLI visual signatures, no eID hardware) shell: bash run: | python - <<'PY' + import json from test_sign_pdfs_beid import make_pdf, make_png - make_pdf("smoke.pdf", [(595, 842)]) + make_pdf("smoke.pdf", [(595, 842), (595, 842)]) make_png("sig.png") + with open("smoke_sigs.json", "w", encoding="utf-8") as fh: + json.dump([ + {"kind": "text", "text": "JD {date}", "font": "caveat", "font_size": 14, + "all_pages": True, "x": 500, "y": 20}, + {"kind": "image", "image_path": "sig.png", "width_pt": 120, + "page": 1, "x": 60, "y": 700}, + ], fh) print("fixtures written") PY CLI=$(ls dist/cachet-cli* | head -1) @@ -75,6 +113,12 @@ jobs: --output smoke_out --page 1 --x 100 --y 100 test -n "$(ls smoke_out/*.pdf 2>/dev/null)" || { echo "FAIL: no output PDF"; exit 1; } echo "OK: image mode works in the frozen binary." + "$CLI" --mode image --text "Jane Doe" --font great-vibes --color "#1A3FB4" \ + --input smoke.pdf --output smoke_text --page 1 --x 100 --y 100 + test -n "$(ls smoke_text/*.pdf 2>/dev/null)" || { echo "FAIL: no text-mode output"; exit 1; } + "$CLI" --mode image --signatures smoke_sigs.json --input smoke.pdf --output smoke_multi + test -n "$(ls smoke_multi/*.pdf 2>/dev/null)" || { echo "FAIL: no --signatures output"; exit 1; } + echo "OK: bundled fonts + text rendering work in the frozen binary." - name: Verify both binaries exist shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24952ec..7f1723b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,6 +58,9 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} + # A stalled Tk suite or build must not hold the runner (and, for a + # release, its concurrency group) for GitHub's 6-hour default. + timeout-minutes: 45 steps: - uses: actions/checkout@v4 @@ -68,28 +71,65 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} cache: pip + # xvfb/xauth + Tcl/Tk: the virtual display and the Tk runtime of the GUI + # suites below. + - name: Linux system deps + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y poppler-utils xvfb xauth libtk8.6 libtcl8.6 + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt -r requirements-build.txt - # Gate the release on the test suite (Linux only: headless, the GUI - # tests skip without a display; on the Windows runner Tk could open - # real windows and hang the job). - - name: Unit tests + # Gate the release on the test suite (Linux only: on the Windows runner + # Tk could open real windows and hang the job). Same three steps as + # build.yml: headless suite (the Gui* classes skip themselves), a + # canary, then the Gui* suites under Xvfb. + - name: Unit tests (headless) + if: runner.os == 'Linux' + run: env -u DISPLAY python -m unittest -v + + # The canary imports gui on purpose: the display guard of the GUI tests + # swallows an ImportError / NameError / SyntaxError of gui.py exactly + # like a missing display. + - name: GUI canary (a broken Tk or a broken gui module must fail, not skip) if: runner.os == 'Linux' - run: python -m unittest -v + env: + XMODIFIERS: "@im=none" + run: xvfb-run -a -s "-screen 0 1920x1200x24" python -c "import tkinter, customtkinter, gui; tkinter.Tk().destroy()" + + # CACHET_REQUIRE_GUI=1 turns the guard's skip into a failure, so "GUI + # suites green" can never mean "everything skipped". + - name: GUI suites (Xvfb, skips are failures) + if: runner.os == 'Linux' + timeout-minutes: 20 + env: + XMODIFIERS: "@im=none" + CACHET_REQUIRE_GUI: "1" + PYTHONFAULTHANDLER: "1" # with timeout -s ABRT: a stalled suite dumps its stacks + run: xvfb-run -a -s "-screen 0 1920x1200x24" timeout -s ABRT 900 python -m unittest -v -k Gui - name: Build with PyInstaller run: pyinstaller --noconfirm --clean cachet.spec - - name: Smoke test (CLI image mode, no eID hardware) + # Legacy single image, then --text and --signatures: the last two prove + # that fonts/ and PIL._imagingft are in the CLI binary. + - name: Smoke test (CLI visual signatures, no eID hardware) shell: bash run: | python - <<'PY' + import json from test_sign_pdfs_beid import make_pdf, make_png - make_pdf("smoke.pdf", [(595, 842)]) + make_pdf("smoke.pdf", [(595, 842), (595, 842)]) make_png("sig.png") + with open("smoke_sigs.json", "w", encoding="utf-8") as fh: + json.dump([ + {"kind": "text", "text": "JD {date}", "font": "caveat", "font_size": 14, + "all_pages": True, "x": 500, "y": 20}, + {"kind": "image", "image_path": "sig.png", "width_pt": 120, + "page": 1, "x": 60, "y": 700}, + ], fh) print("fixtures written") PY CLI=$(ls dist/cachet-cli* | head -1) @@ -98,6 +138,12 @@ jobs: --output smoke_out --page 1 --x 100 --y 100 test -n "$(ls smoke_out/*.pdf 2>/dev/null)" || { echo "FAIL: no output PDF"; exit 1; } echo "OK: image mode works in the frozen binary." + "$CLI" --mode image --text "Jane Doe" --font great-vibes --color "#1A3FB4" \ + --input smoke.pdf --output smoke_text --page 1 --x 100 --y 100 + test -n "$(ls smoke_text/*.pdf 2>/dev/null)" || { echo "FAIL: no text-mode output"; exit 1; } + "$CLI" --mode image --signatures smoke_sigs.json --input smoke.pdf --output smoke_multi + test -n "$(ls smoke_multi/*.pdf 2>/dev/null)" || { echo "FAIL: no --signatures output"; exit 1; } + echo "OK: bundled fonts + text rendering work in the frozen binary." - name: Package (Linux) if: runner.os == 'Linux' diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..2ff107e --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,32 @@ +# GitLab CI: runs the test suites only. The standalone executables and the +# releases are built on GitHub Actions (.github/workflows/build.yml and +# release.yml); .forgejo/workflows/tests.yml is the Forgejo twin of this file. +# +# Three steps, as on GitHub: the headless suite, a GUI canary, then the +# guarded Gui* suites under Xvfb (the end-to-end layer of this Tk app). +stages: [test] + +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + PIP_DISABLE_PIP_VERSION_CHECK: "1" + XMODIFIERS: "@im=none" # Tk must not wait on the ibus XIM bridge + +tests: + stage: test + timeout: 30m # a stalled Tk suite must not hold the runner + image: python:3.13-bookworm # full image: tkinter + libtk included + cache: + key: pip-py313 + paths: [.cache/pip] + before_script: + - apt-get update + - apt-get install -y --no-install-recommends xvfb xauth fonts-dejavu-core + - pip install -r requirements.txt + script: + # 1. headless: the Gui* suites skip themselves + - env -u DISPLAY python -m unittest -v + # 2. canary: a broken Tk OR a broken gui module must FAIL the job instead of silently skipping + - xvfb-run -a -s "-screen 0 1920x1200x24" python -c "import tkinter, customtkinter, gui; tkinter.Tk().destroy()" + # 3. guarded GUI suites (the e2e layer), one run, sequential; a skipped GUI test is a failure + # (timeout -s ABRT + faulthandler: a stalled suite dumps its stacks) + - CACHET_REQUIRE_GUI=1 PYTHONFAULTHANDLER=1 xvfb-run -a -s "-screen 0 1920x1200x24" timeout -s ABRT 900 python -m unittest -v -k Gui diff --git a/BUILD.md b/BUILD.md index c1f7c07..14bd111 100644 --- a/BUILD.md +++ b/BUILD.md @@ -54,18 +54,40 @@ This is the recommended route for a production deliverable: it produces a native ## 3. Windows — via GitHub Actions (CI, reproducible) The `.github/workflows/build.yml` workflow compiles **Windows + Linux** on the -GitHub runners on every `push`/tag, runs a smoke test (image mode, no card) and -publishes the binaries as artifacts. +GitHub runners (pushes to `develop`, pull requests, manual runs) and publishes +the binaries as artifacts. Before building, the Linux job runs the tests in +three steps: + +1. the **headless suite** (`env -u DISPLAY python -m unittest -v`; the `Gui*` + classes skip themselves); +2. a **GUI canary** under Xvfb — `import tkinter, customtkinter, gui` and a + `Tk()` window — so a broken Tk or a `gui.py` that no longer imports fails + the job instead of making every GUI test skip; +3. the **`Gui*` suites** under Xvfb with `CACHET_REQUIRE_GUI=1`, which turns + any remaining skip into a failure (they are the end-to-end layer of this + desktop app). + +After the build, a **smoke test** runs the frozen CLI without a card, on both +OSes: the legacy single image (`--image-path`), a text signature (`--text`) +and a `--signatures` file. The last two prove that the bundled fonts and +Pillow's FreeType renderer are in the CLI binary. ```bash -# once: push the repository to GitHub +# once: push the repository to GitHub (build.yml runs on `develop`; a push to +# `main` runs the release workflow instead — see "Release process" below) git remote add origin git@github.com:/.git -git push -u origin main +git push -u origin develop ``` Then: **Actions** tab → run → **Artifacts** → `cachet-windows-latest` / `cachet-ubuntu-latest`. Can also be triggered manually (*workflow_dispatch*). +**GitLab CI** (`.gitlab-ci.yml`) and **Forgejo Actions** +(`.forgejo/workflows/tests.yml`) run the same three test steps in a +`python:3.13-bookworm` container. They run the tests only: builds and releases +stay on GitHub. On Forgejo, adapt `runs-on: docker` to your instance's runner +label. + ## Release process (develop → main) Branch model: day-to-day work lands on **`develop`** (CI builds + tests every @@ -76,8 +98,10 @@ publishes a release** (`.github/workflows/release.yml`): truth — the CLI `--version` and the GUI title read it). 2. Open a PR `develop` → `main` and merge it. 3. The release workflow then: reads the version → checks the tag `v{version}` - does not already exist → runs the unit tests → builds **both** executables - on Windows AND Linux → smoke-tests the frozen CLI → packages + does not already exist → runs the tests (Linux: headless suite, GUI canary, + `Gui*` suites under Xvfb with `CACHET_REQUIRE_GUI=1`) → builds **both** + executables on Windows AND Linux → smoke-tests the frozen CLI (legacy + image, `--text`, `--signatures`) → packages `cachet-{v}-linux-x86_64.tar.gz` + `cachet-{v}-windows-x86_64.zip` → creates the tag and a **GitHub Release** with auto-generated notes and the two archives attached. @@ -133,7 +157,7 @@ These components are loaded dynamically and **cannot** be packaged: and the CAs' **OCSP/CRL** endpoints. `azure` mode additionally needs **`login.microsoftonline.com`** (Entra ID) and the **Key Vault URL**. Offline machines can only sign at `--pades-level b-b` in beid mode (or - stamp images); on failure the app names the unreachable endpoint and + stamp visual signatures); on failure the app names the unreachable endpoint and **never silently downgrades the level**. - **Azure per-user provisioning** (azure mode only) — each user needs a Key Vault key + certificate (internal CA) named after the key template @@ -144,8 +168,27 @@ The GUI's **page preview** (step 6) is rendered by **pypdfium2** (PDFium engine `poppler` (`pdftoppm`) is now only an **optional fallback** used only if it is already present on the machine (see `core.render_page_image`). -The **image mode** depends on none of these components: it is fully functional -and testable without hardware. +**Bundled fonts** — the six OFL fonts of the text signatures (`fonts/*.ttf`) +and their licence texts (`fonts/licenses/`, which the OFL requires to travel +with the fonts) are bundled in **both** binaries (`common_datas` in +`cachet.spec`): the CLI stamps text too. `stamps.asset_path` finds them next +to the module in a checkout and under `sys._MEIPASS` in a frozen binary. A +font the user designates by path (`.ttf`/`.otf`) is read from that path at +run time and is never bundled. + +**User profile** (GUI only — the CLI never reads it) — the GUI keeps the +signature library, the last placements and the wizard settings in +`profile.json` under the user config dir, and a copy of the imported/drawn +images in `signatures/` under the user data dir (Linux: `~/.config/Cachet/` +and `~/.local/share/Cachet/`; Windows: `%LOCALAPPDATA%\Cachet\` for both; +macOS: `~/Library/Application Support/Cachet/` for both). Override with +`CACHET_CONFIG_DIR` / `CACHET_DATA_DIR`. The files are **unencrypted** and +never contain a PIN, a token or any other credential. Nothing has to be +installed or created beforehand: the folders appear at the first save. + +The **visual signatures** (image mode, and the stamps added in beid/azure +mode) depend on none of the components listed above: they are fully +functional and testable without hardware or network. --- @@ -169,6 +212,8 @@ and testable without hardware. | GUI: `Can't find a usable init.tcl` / empty window | Tcl/Tk data not collected | rebuild with a Python that has a complete `tkinter`; the `_tkinter` hook collects it into `_tcl_data`/`_tk_data` | | GUI: `FileNotFoundError` on a `.json` theme | missing customtkinter assets | make sure `pyinstaller-hooks-contrib` is installed (it is); the spec also does `collect_all('customtkinter')` | | `ModuleNotFoundError: pkcs11._pkcs11` | native extension not bundled | already handled (`collect_dynamic_libs('pkcs11')` + hiddenimport); check the build log | +| Text signature: `cannot load font '…': cannot open resource` / `bundled font file missing: …` | `fonts/` not in the bundle | check that `common_datas` in `cachet.spec` still carries `("fonts/*.ttf", "fonts")` (it must be in the **common** list, not only the GUI's) and that `fonts/` is present in the checkout | +| Text signature: `The _imagingft C module is not installed` | Pillow's FreeType renderer not bundled | `PIL._imagingft` must stay in `common_hidden` (`cachet.spec`); check the build log | | `ZoneInfoNotFoundError` at signing time **on Windows** | zoneinfo database missing | `tzdata` (installed via `requirements-build.txt` on Windows; collected by the spec) | | OpenSSL error from `oscrypto` | version parsing on certain OpenSSL builds | not triggered by this app's image/eID modes (the Linux trust-list reads PEM files); otherwise `pip install` a fixed oscrypto or pyhanko-certvalidator ≥ 0.41 | | `beid mode` "fails" on a clean machine | eID middleware/reader/card missing | **expected**: install the eID middleware (see above); this is not a packaging bug | @@ -195,6 +240,18 @@ test on real hardware** before distribution: 4. Cross-check with the pyHanko CLI: `pyhanko sign validate --pretty-print --ltv-profile pades-lta ` must report the expected level and a sound timestamp chain. +5. **Visual signatures + eID signature.** Sign a multi-page PDF with the card + AND two visual signatures, one of them on every page (`--mode beid + --signatures sigs.json`, or the GUI's step 6), at B-LTA. In Adobe Acrobat + Reader the signature must be valid, "LTV enabled", and must **not** report + that the document was modified after signing: the visual signatures are + stamped before the signature, in the same revision. +6. **Already-signed input.** Take the output of step 5 and + (a) countersign it with the card, with another field base name + (`--field Countersign`) and **no** visual signature — both signatures must + be valid in Acrobat; (b) try again **with** a visual signature — Cachet + must refuse the document (`the document is already signed…`) **before** + asking for the PIN, and write nothing. Reminder: with the default **free TSA** the timestamps are technically valid but **not qualified**; for eIDAS-qualified preservation, run the acceptance @@ -218,6 +275,11 @@ path must be validated manually against a provisioned tenant: 5. Negative check: pass `--azure-key-name ` — the run must print the override warning, and Key Vault must deny the `sign` operation unless your account was explicitly granted it. +6. Visual signatures: repeat step 1 with `--signatures sigs.json` (two visual + signatures, one on every page). Acrobat must show the signature as valid + and must **not** report a modification after signing. Then run it again on + that signed output: Cachet must refuse the document + (`the document is already signed…`) and write nothing. Reminder: azure mode produces an **advanced** signature (AES) with the internal CA — appropriate for internal documents, not a qualified (QES) diff --git a/CLAUDE.md b/CLAUDE.md index 0284dd9..b2bd5b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,316 +4,455 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -A batch PDF-signing app for Belgian electronic identity cards (eID), with two -signature modes, template-based input validation, and a CustomTkinter GUI that +A batch PDF-signing app for Belgian electronic identity cards (eID), with +three modes, template-based input validation, and a CustomTkinter GUI that wraps the same logic as the headless CLI. -- **`beid` mode** — cryptographic eID signature (pyHanko over the eID PKCS#11 - middleware), with a visible **vignette** (cardholder photo + "Signed by:" / - name / date) bottom-right of the last page. Signatures are **PAdES, - default level B-LTA** (timestamp + embedded revocation info + archival - timestamp chain), selectable via `--pades-level`; levels ≥ b-t need network. -- **`azure` mode** — personal **AES (advanced, not qualified)** signature with - the signed-in user's own certificate + non-exportable key in **Azure Key - Vault**, after ONE interactive Microsoft Entra ID login per batch. Only the - digest is sent to Azure. Reuses the PAdES level pipeline; LTV trust comes - from the **internal CA chain** (`--azure-trust-anchors`), NEVER the EU LOTL. -- **`image` mode** — stamps a user-supplied image onto a chosen page at a chosen - position. See the design note below: **image mode does NOT use the card** and - is not a cryptographic signature; it is the alternative to `beid`. - -No build system, no linter config. Tests use stdlib `unittest`. -Code comments and CLI text are in English; the GUI is **localized** -(EN/FR/NL/DE/ES/PT) through `i18n.py` — every user-visible GUI string goes -through `i18n.tr(key, **fmt)`, never a hard-coded literal. +- **`beid`** — cryptographic eID signature (pyHanko over the eID PKCS#11 + middleware) with a visible **vignette** (cardholder photo + "Signed by:" / + name / date), bottom-right of the last page by default. **PAdES, default + level B-LTA** (`--pades-level`); levels ≥ b-t need network. +- **`azure`** — personal **AES (advanced, not qualified)** signature with the + signed-in user's certificate + non-exportable key in **Azure Key Vault**, + after ONE Microsoft Entra ID login per batch. LTV trust comes from the + **internal CA chain** (`--azure-trust-anchors`), NEVER the EU LOTL. +- **`image`** — **visual signatures only**: typed texts, images or hand-drawn + signatures stamped on chosen pages. **No card**, not a cryptographic + signature. (Name kept for backward compatibility.) + +`beid`/`azure` can carry the same N **visual stamps** (`RunConfig.stamps`) +plus exactly ONE cryptographic signature per document. The GUI **persists** +the signature library, placements and wizard settings in a user profile +(`profile_store.py`); the CLI never reads it. + +Vocabulary trap: "visual signature" (user wording) = `Stamp` (`stamps.py`). +The older `_STAMP_*` constants and `build_stamp_style` in the core mean the +**vignette** of the cryptographic signature and were NOT renamed. + +No build system, no linter config; tests use stdlib `unittest`. Comments and +CLI text are in English; the GUI is **localized** (EN/FR/NL/DE/ES/PT): every +GUI string goes through `i18n.tr(key, **fmt)`, in ALL six languages. ## Modules -- `sign_pdfs_beid.py` — **core + CLI entry point**. All business logic lives - here (eID signing, vignette, image insertion, validation, placement math, - `RunConfig`/`process_batch`, arg parsing). Imports cleanly **without tkinter**. -- `gui.py` — CustomTkinter GUI: a **landing page + 8-step wizard** (see "GUI - workflow"). Imported **only** when `--gui` is passed (lazy), so the - CLI/core/tests never require a display. It is a thin façade over the core; - all non-widget logic it needs is imported from the core, all its text from - `i18n.py`. -- `i18n.py` — GUI localization: `LANGUAGES` (en/fr/nl/de/es/pt), - `tr(key, **fmt)`, `set_language`, `system_language` and the UI `CATALOG` - (key → per-language strings). Texts may carry a light **`**bold**`** - markup; `split_markup(text)` (pure) turns it into `(segment, is_bold)` - pairs that the GUI renders through a text tag. `DOC_SECTIONS` / - `DOC_SOURCES` (title key → URL) drive the "Full documentation" popup. - Import-safe **without tkinter**; `test_i18n.py` enforces that every key - exists in every language, that placeholders match the English reference, - that bold markers are balanced with the same count per language, and that - the documentation is genuinely translated — add new GUI strings in ALL - six languages. -- `i18n_docs.py` — the long-form documentation of the popup (`DOCS_CATALOG`: - `docs.modes/levels/tiers/glossary/glance`, `docs.sources_*`, `docs.src.*` - link titles) in six languages, merged into `i18n.CATALOG` at import so - `tr()` and the test invariants cover it. Kept apart so `i18n.py` stays - readable; bundled into the GUI binary through the import chain. -- `trust.py` — EU trusted-list (LOTL, ETSI TS 119 612) trust provider for LTV: - LOTL → Belgian list → granted CA/QC-for-eSignatures certs as - `ValidationContext` anchors; JSON cache under `platformdirs` (24 h TTL, - `--refresh-trust-list`); raises actionable `TrustListError` offline. - Import-safe without tkinter; network injectable (`fetcher=`) for tests. - **beid-mode only** (azure uses the internal CA instead). -- `azure_signer.py` — azure mode: Entra credential factory + process-wide - cache (`get_cached_credential` — one login per batch, shared with the GUI - sign-in), token-claims user resolution (UPN/oid, decoded locally, never - logged), per-user key/cert name template (`sig-{upn}`, sanitised; explicit - override flagged), `AzureKeyVaultSigner` (pyHanko `Signer`: hashes locally, - sends ONLY the digest to `CryptographyClient.sign`, maps key type + - `--digest` to RS256/…/ES256/…, converts ECDSA r||s → DER). Azure SDK - imports are lazy; clients injectable for tests. -- `test_sign_pdfs_beid.py`, `test_trust.py`, `test_azure.py`, `test_i18n.py` - — headless `unittest` suites (no card, no network; the `Gui*` classes need - a display and skip themselves otherwise). -- `pdfs/` (`../pdfs`), `signes/` (`../signes`) — sample inputs / output dir. -- `venv/` — Python virtualenv (see "tkinter in this environment"). +- `sign_pdfs_beid.py` — **core + CLI entry point** (signing, vignette, + validation, placement math, `RunConfig`/`process_batch`, arg parsing). +- `stamps.py` — visual signatures: the frozen `Stamp` dataclass, bundled + fonts, text / freehand rendering, image normalisation, `stamp_content` (THE + single source of pixels + size for the PDF and the GUI preview), validation, + the `--signatures` JSON, `apply_stamps(writer, …)`. Every failure is a + `StampError` (a `ValueError`). +- `profile_store.py` — the GUI's user profile (see "Persistence"). +- `gui.py` — CustomTkinter **landing page + 8-step wizard**, imported + **only** with `--gui` (lazy), so the CLI/core/tests never need a display. +- `i18n.py` — `tr`, `set_language`, `system_language`, the UI `CATALOG`; + texts may carry a light `**bold**` markup (`split_markup`). `i18n_docs.py` + holds the long documentation-popup texts, merged into the catalog at + import. `test_i18n.py` enforces: every key in all six languages, + placeholders matching English, balanced bold markers with equal counts. +- `trust.py` — EU trusted-list (LOTL, ETSI TS 119 612) anchors for LTV: LOTL → + Belgian list → CA/QC-for-eSignatures certs; JSON cache (24 h TTL, + `--refresh-trust-list`); actionable `TrustListError` offline; network + injectable (`fetcher=`). **beid only**. +- `azure_signer.py` — Entra credential + process-wide cache + (`get_cached_credential`: one login per batch, shared with the GUI sign-in), + user from token claims (decoded locally, never logged), per-user key/cert + name template (`sig-{upn}`; explicit override flagged), + `AzureKeyVaultSigner` (hashes locally, sends ONLY the digest, ECDSA r||s → + DER). Azure SDK imports are lazy; clients injectable for tests. +- `test_*.py` — headless suites (no card, no network); the `Gui*` classes + skip themselves without a display. + +Import graph (no cycles — keep it that way): + +``` +stamps -> stdlib, PIL, pyhanko (nothing of this app) +profile_store -> stdlib, stamps, platformdirs (lazy) +sign_pdfs_beid -> stamps (NEVER profile_store) +gui -> sign_pdfs_beid, stamps, profile_store, i18n +``` + +- Only `gui.py` / `gui_main.py` need tkinter. `stamps` and + `profile_store` never import the core: it is the CLI entry script + (`__main__`), a module importing it would load it twice. +- The core never imports `profile_store`: headless runs stay deterministic. +- Name shadowing: `stamps` is also a `RunConfig` field and a `sign_one` + keyword, so the core imports **names** (`from stamps import Stamp, …`); + `gui.py` and `profile_store.py` use `import stamps as stamplib`. ## Commands ```bash -# eID signing (vignette), new flat flags — PAdES B-LTA by default: +# eID signing (PAdES B-LTA by default); legacy positionals still work: ./venv/bin/python sign_pdfs_beid.py --input ../pdfs --output ../signes --mode beid - -# eID signing, offline basic level; legacy positional form (still supported) -./venv/bin/python sign_pdfs_beid.py ../pdfs ../signes --pades-level b-b - -# azure mode (personal Key Vault cert; one Microsoft login per batch): -./venv/bin/python sign_pdfs_beid.py --mode azure \ - --azure-vault-url https://myorg-sign.vault.azure.net \ - --azure-trust-anchors ./internal-ca-chain.pem \ +./venv/bin/python sign_pdfs_beid.py ../pdfs ../signes --pades-level b-b # offline + +# azure mode (one Microsoft login per batch): +./venv/bin/python sign_pdfs_beid.py --mode azure --azure-vault-url https://x.vault.azure.net \ + --azure-trust-anchors ./internal-ca-chain.pem --input ../pdfs --output ../signes + +# visual signatures, no card: one text (or --image-path img.png) / a JSON list +./venv/bin/python sign_pdfs_beid.py --mode image --template ../pdfs/MODELE.pdf \ + --text "Jane Doe\nApproved, {date}" --font great-vibes --color "#1A2B8C" \ + --page last --x 360 --y 120 --input ../pdfs --output ../signes +# --signatures also works with --mode beid|azure (stamped BEFORE the signature): +./venv/bin/python sign_pdfs_beid.py --mode image --signatures sigs.json \ --input ../pdfs --output ../signes -# image stamp (no card), with template validation: -./venv/bin/python sign_pdfs_beid.py --mode image \ - --template ../pdfs/MODELE.pdf --input ../pdfs --output ../signes \ - --image-path signature.png --page 1 --x 360 --y 150 - -# GUI: +# GUI — it loads and AUTO-SAVES the user profile: for development export +# CACHET_CONFIG_DIR / CACHET_DATA_DIR to scratch folders first. ./venv/bin/python sign_pdfs_beid.py --gui -# Tests (headless, no card): -./venv/bin/python -m unittest -v +# Tests, headless (the Gui* classes skip themselves): +env -u DISPLAY ./venv/bin/python -m unittest -v +# GUI suites — ONE run at a time; skips are failures with CACHET_REQUIRE_GUI=1 +# (no xvfb-run on this workstation: use DISPLAY=:0 instead of `xvfb-run -a`): +CACHET_REQUIRE_GUI=1 XMODIFIERS=@im=none PYTHONFAULTHANDLER=1 timeout -s ABRT 900 \ + xvfb-run -a ./venv/bin/python -m unittest -v -k Gui -# Syntax check everything: -./venv/bin/python -m py_compile sign_pdfs_beid.py gui.py gui_main.py trust.py azure_signer.py i18n.py i18n_docs.py test_sign_pdfs_beid.py test_trust.py test_azure.py test_i18n.py +# Syntax check everything (all modules and tests sit at the repo root): +./venv/bin/python -m py_compile *.py ``` -### CLI flags - -| flag | meaning | -|------|---------| -| `--gui` | launch the GUI; otherwise run headless. Running without `--gui` never opens a window. | -| `--input ` | files and/or directories to process (dirs are globbed for `*.pdf`). | -| `--output ` | output folder; files are written `{stem}_signe.pdf`, **never overwriting** (collisions get ` - 1`, ` - 2`, …). | -| `--template ` | model PDF; if given, inputs are validated against it (CLI **and** GUI). | -| `--mode beid\|image\|azure` | signature mode (default `beid`). | -| `--azure-vault-url` / `--azure-key-name` / `--azure-key-name-template` / `--azure-cert-name` / `--azure-auth` / `--azure-trust-anchors` / `--azure-graph` | azure mode config; each falls back to its `CACHET_AZURE_*` env var. Vault URL required; key derived per-user from the template (default `sig-{upn}`; explicit override flagged); auth `interactive`/`device-code` (CLI default)/`default` (CI only, breaks per-user model); trust anchors = internal CA PEM/DER file-or-dir, required when LTV/verification needs them. | -| `--image-path ` | image to stamp (**required** for `--mode image`). | -| `--page ` | target page. A number is **1-based**; `first`/`last` (→ `RunConfig.page_anchor`) are resolved **per document** (index 0 / -1) and, with `--template`, accept files whose page count differs from the template (see validation rules). Image: insertion page. beID: vignette page (with `--x/--y`; with `first`/`last` and no position the default bottom-right vignette moves to that page). | -| `--x --y ` | lower-left position, points from the page's bottom-left. Image: image corner. beID: vignette corner — **omit both → default bottom-right of last page**. | -| `--pades-level {b-b,b-t,b-lt,b-lta}` | PAdES baseline level, **default `b-lta`**. b-t+ needs a TSA; b-lt+ embeds OCSP/CRL (LTV) using EU-trusted-list anchors. Never silently downgraded on network failure. | -| `--timestamp-url` / `--trust-list-url` | RFC 3161 TSA and EU LOTL overrides. Precedence: flag > env (`CACHET_TSA_URL` / `CACHET_LOTL_URL`) > default (DigiCert free TSA / official EU LOTL). Free TSA = technically valid but NOT qualified timestamps. | -| `--digest {sha256,sha384,sha512}` | digest, pinned as `md_algorithm` (default sha256). | -| `--no-verify` | skip post-signing self-verification (levels ≥ b-t); also skips the trusted-list fetch at b-t. | -| `--refresh-trust-list` | bypass the 24 h LOTL cache. | -| `--pades` | deprecated no-op alias (warns); PAdES is the default now. | -| `--legacy-cms` | deprecated; old `adbe.pkcs7.detached` path, only combinable with level b-b. | -| `--lib` / `--field` | eID options (as before): PKCS#11 lib path, field name. | - -Backward compatibility: `--input`/`--output` take precedence; if absent, the -legacy positional form `inputs… output_dir` is used. `resolve_config()` does -this resolution and `validate_config()` rejects bad combos (e.g. `image` mode -without `--image-path`). Both are pure and unit-tested. +### CLI flags (the non-obvious parts; `--help` has the full list) + +- Without `--gui` no window is ever opened. +- `--input` takes files and/or directories (globbed for `*.pdf`); `--output` + writes `{stem}_signe.pdf` and **never overwrites** (` - 1`, ` - 2`, …). + If both are absent the legacy positional form `inputs… output_dir` is used. +- `--page `: a number is **1-based**; `first`/`last` + (→ `RunConfig.page_anchor`) are resolved **per document**. `--x/--y`: + lower-left corner in points. In beid/azure they place the vignette — + **omit both → default bottom-right box** (on the last page, or on the + anchor page with `--page first|last`). +- `--signatures `: JSON **list** of visual signatures, in EVERY + mode. UTF-8 (BOM accepted); relative `image_path` / font paths resolve + against the JSON file's directory; unknown keys ignored; `"enabled": false` + entries skipped. +- `--text` (+ `--font`, `--color`, `--font-size`, which need it): ONE text + signature, **image mode only**; the two characters `\n` become a newline. + `--image-path`: ONE image (legacy, 150 pt wide), mutually exclusive with + `--text`, silently ignored in beid/azure (as before). +- `--pades-level {b-b,b-t,b-lt,b-lta}`, default `b-lta`: b-t+ needs a TSA, + b-lt+ embeds OCSP/CRL. Never silently downgraded on network failure. + `--no-verify` skips the post-signing self-verification (and the + trusted-list fetch at b-t). +- `--timestamp-url` / `--trust-list-url`: flag > env (`CACHET_TSA_URL` / + `CACHET_LOTL_URL`) > default (DigiCert free TSA — valid but NOT qualified + timestamps — / official EU LOTL). +- `--azure-*`: each falls back to its `CACHET_AZURE_*` env var. `--azure-auth` + `default` is for CI only (it breaks the per-user model); trust anchors + (internal CA PEM/DER file or directory) are required when LTV/verification + needs them. +- Deprecated: `--pades` (no-op, warns); `--legacy-cms` (old + `adbe.pkcs7.detached`, level b-b and beid only). + +`resolve_config()` / `validate_config()` are pure (they read files, never +write). + +**Who owns `--page/--x/--y`**: in image mode the single *convenience* stamp +(`--text`, or the legacy `--image-path`); in beid/azure the **vignette**. The +entries of `--signatures` carry their own placement, so in image mode +`--page/--x/--y` with `--signatures` alone is an error. `--signatures` is +additive with at most one convenience stamp, which comes FIRST. The legacy +image is NOT put in `cfg.stamps`: `image_path/page/x/y/page_anchor` stay +populated as before and `effective_stamps(cfg)` turns them into the first +stamp. `--font-size` is tested with `is not None`: an explicit 0 is rejected, +not replaced by the default. + +`main()` reconfigures stdout/stderr with `errors="backslashreplace"`: texts +and file names are echoed, and redirected output on Windows is the ANSI code +page — a character outside it must not kill the run before any document. ## Shared core: `process_batch` -Both the CLI (`main`) and the GUI call `process_batch(cfg: RunConfig, on_progress=…)`: -1. If `cfg.template` is set, read its per-page dimensions once. -2. For `beid` mode, open **one** PKCS#11 session, build `BEIDSigner`, read the - identity once (no PIN), and build the network material once via - `build_signing_material(cfg)` → `SigningMaterial` (an `HTTPTimeStamper` - for levels ≥ b-t; trust anchors + a fetching `ValidationContext` for - b-lt/b-lta — anchors are passed as `extra_trust_roots` because pyHanko - validates the TSA chain against the same context). The trust source is - **mode-dependent**: beid → EU LOTL (`trust.py`); azure → internal CA - (`load_trust_anchor_certs`). A `TrustListError`/`ValueError` here fails - the whole batch — the level is NEVER silently downgraded. - For `azure` mode the setup-once path is: `get_cached_credential` → - `acquire_user` (ONE login per batch) → `resolve_key_names` → - `build_azure_signer` → `read_cert_identity` (vignette name from the cert - subject or Graph displayName; `photo=None`). -3. Per input: validate against the template (if any) → reject+report on failure; - else `sign_one()` (beid) or `insert_image_one()` (image). Returns a - `DocResult` per file (`ok`, `detail`). `on_progress` fires per document so - the GUI can stream rows into its summary table. -4. beid + levels ≥ b-t (unless `--no-verify`): `verify_signed_pdf()` re-opens - the output, validates it (pyHanko validation API + EU anchors) and detects - the **achieved** level structurally (timestamp → b-t, DSS revinfo → b-lt, - doc-timestamp → b-lta); the result lands in `DocResult.detail` - (e.g. `PAdES-B-LTA, LTV ok`); `SelfVerificationError` ⇒ document FAILED. +Both the CLI and the GUI call `process_batch(cfg, on_progress=…, today=…)`: +1. Resolved ONCE per batch: the template's page dimensions; + `effective_stamps(cfg)`; ONE run date for `{date}` (`today`, injectable); + ONE cache of stamp content; `anchor_requirements(cfg)`. +2. `beid`: **one** PKCS#11 session, `BEIDSigner`, identity read once (no PIN), + network material built once by `build_signing_material(cfg)` (an + `HTTPTimeStamper` for levels ≥ b-t; trust anchors + a fetching + `ValidationContext` for b-lt/b-lta — anchors go in `extra_trust_roots` + because pyHanko validates the TSA chain against the same context). Trust + source is **mode-dependent**: beid → EU LOTL; azure → internal CA + (`load_trust_anchor_certs`). A `TrustListError`/`ValueError` here fails the + whole batch — the level is NEVER silently downgraded. + `azure`: `get_cached_credential` → `acquire_user` (ONE login) → + `resolve_key_names` → `build_azure_signer` → `read_cert_identity` + (`photo=None`). +3. Per input: validate against the template (if any) → reject+report; else + `sign_one(…, stamps=, stamp_date=, stamp_cache=)` (beid/azure) or + `apply_stamps_one()` (image). One `DocResult` (`ok`, `detail`) per file; + `on_progress` fires per document. Any `StampError` (bad page, unreadable + image, font, oversized text, already-signed input) fails THAT document + (`failed — …`, no output file) and the batch continues. +4. beid/azure, levels ≥ b-t (unless `--no-verify`): `verify_signed_pdf()` + re-opens the output, validates it and detects the **achieved** level + structurally (timestamp → b-t, DSS revinfo → b-lt, doc-timestamp → b-lta); + `SelfVerificationError` ⇒ document FAILED. The level → `PdfSignatureMetadata` mapping is the pure -`signature_meta_kwargs(level, digest, legacy_cms=…, validation_context=…)` -(unit-tested for all four levels + legacy CMS). - -The **same** image/vignette + page + (x, y) is applied to every document — -validation guarantees the files are geometrically identical, so one placement -fits all. With `cfg.page_anchor` (`"first"`/`"last"`, exclusive with -`cfg.page`) the page is resolved **per document** (`anchor_page_index`: 0 / --1) and validation only pins the anchor page, so page counts may differ. -Output paths come from `unique_output_path()`: `{stem}_signe.pdf`, -then `{stem}_signe - 1.pdf`, ` - 2`, … on collision — **existing files are never -overwritten**. +`signature_meta_kwargs(…)`. + +**Visual signatures in the core.** The legacy `RunConfig` fields keep their +meaning — in beid/azure `page/x/y/page_anchor` place the VIGNETTE, in image +mode (with `image_path`) they describe one legacy image stamp. Exactly two +writers of stamps, no third one: +- `sign_one(…, stamps=…)` — beid/azure: `apply_stamps(writer, …)` then + `sign_pdf(writer)` on the SAME writer (workaround 4). +- `apply_stamps_one(src, dst, stamp_list, …) -> bool` — image mode: ONE + incremental update, `dst` written only when everything succeeded; an empty + list is an error, never a silent no-op; returns True when the input was + already signed. + +The **same** elements (vignette and/or stamps, each with its own page target +and (x, y)) are applied to every document — validation guarantees the files +are geometrically identical, so one placement fits all. A page anchor +(`cfg.page_anchor` for the vignette, `Stamp.page_anchor` for a stamp; +exclusive with a page number) is resolved **per document** +(`anchor_page_index`: 0 / -1), so page counts may differ. ## Template validation rules `validate_against_template(template_dims, pdf)` rejects a file unless it has **(a)** the same page count and **(b)** EXACTLY identical per-page dimensions — -exact float equality of `(width, height)` from each page's MediaBox, **no -tolerance**. Failures are returned as `ValidationResult(ok=False, reason=…)` and -surfaced (CLI summary / GUI table); they are never silently signed. Dimensions -come from the MediaBox (inherited through the page tree); rotation is not -considered. - -With `page_anchor="first"|"last"` (CLI `--page first|last`, GUI validation-step -selector), a file whose **page count differs** from the template is accepted -**iff its anchor page exactly matches the template's anchor page** — that is -the page the signature lands on, so the chosen (x, y) is guaranteed to fit it. -Files with the template's page count keep the full strict check; a count -mismatch whose anchor page differs is rejected with both facts in the reason. -Accepted mismatches carry an informative `reason` ("N page(s), the template -has M — signed on the last page") even though `ok=True`. - -## Image placement convention - -`(x, y)` is the **lower-left** corner of the placed image, in PDF points from -the page's **bottom-left** corner (MediaBox origin is added internally, so it is -correct even for non-zero-origin MediaBoxes). Page numbers are **1-based** at the -CLI/GUI boundary and converted to pyHanko's 0-based (`-1` = last) internally. -The image is scaled to a fixed width `_IMG_TARGET_W_PT` (150 pt) preserving -aspect, with **no border** — `insert_image_one` sets `border_width=0` on the -`StaticStampStyle` (whose default is 3 pt black, which would frame the image). -The GUI and CLI share this convention exactly. An out-of-range `--page` is -reported as a clear per-document failure ("page N out of range…"), not a raw -pyHanko error. - -**beID vignette placement.** In `beid` mode, supplying `--x/--y` (or clicking in -the GUI) places the vignette on `--page` at that lower-left point, sized to a -**3:1 landscape box of width = page_w/5** (`vignette_size_pt`). With no position, -the vignette keeps its default bottom-right corner box -(`_default_vignette_box(writer, page_index)` — the last page by default, the -anchor page when `--page first|last` is set). -`build_stamp_style(identity, box_w, box_h)` makes the photo band proportional to -the box width (`_PHOTO_BAND_FRAC`, 0.2 → 42 pt for the default 210 pt box, -unchanged), so the same layout fits both the default box and the smaller placed -box without overflowing pyHanko's layout margins. - -Placement math is in pure, tkinter-free functions (`fit_frame`, -`frame_click_to_pdf_xy`, `pdf_rect_to_frame_rect`) so it is unit-tested and -reused by the GUI canvas. +exact float equality of each page's MediaBox `(width, height)` (inherited +through the page tree), **no tolerance**, rotation ignored. Failures come back +as `ValidationResult(ok=False, reason=…)` and are surfaced, never signed. + +A file whose **page count differs** is accepted **iff EVERY placed element +targets a page anchor** (`first`/`last`) **and each of those anchor pages +exactly matches the template's anchor page** — the pages the elements land +on, so the chosen positions fit. `anchor_requirements(cfg)` → `(anchors, +blocker)`: the anchors in use, ordered `("first", "last")`, and None or a +phrase naming the FIRST element that is not anchored (it ends up in the +rejection reason). Deliberate: in beid/azure the default bottom-right +vignette (no `--page first|last`) is NOT auto-anchored (`the vignette has no +first/last page target`) — a safety check is never relaxed silently. + +The GUI's step-3 `_validate` passes the single batch anchor as +`page_anchor=`; `process_batch` passes `anchors=` + `blocker=`. The GUI must +therefore hand the batch the SAME anchor for the vignette and every stamp, or +files shown as accepted at step 3 are rejected at run time. Accepted +mismatches carry an informative `reason` even though `ok=True`. + +## Stamp placement convention + +`(x, y)` is the **lower-left** corner of the placed element, in PDF points +(floats, never rounded) from the page's **bottom-left** corner — the MediaBox +origin is added per page inside `apply_stamps` (non-zero origins work). Page +numbers are **1-based** at the CLI/GUI/JSON boundary, 0-based (`-1` = last) +inside. A placed stamp has exactly ONE page target: `page`, `page_anchor` +(per document) or `all_pages`. An out-of-range page is a clear per-document +failure, not a raw pyHanko error. + +- **Image stamps**: scaled to `width_pt`. `load_image` normalises once (EXIF + orientation; any mode other than RGB/RGBA/L/LA → RGBA); the embedded + bitmap is capped at `IMAGE_MAX_DPI`. +- **Text stamps are rasters** (no font embedded, no shaping library): Pillow + at `TEXT_DPI` into a **tightly cropped** RGBA image, then the image + pipeline. `ImageFont.Layout.BASIC` is pinned so sizes do not depend on + raqm. Because of the crop, `(x, y)` is the corner of the INK box, and with + `{filename}` / `{date}` the box (and baseline) can vary per document. A + character the font lacks is drawn as a box or (Sacramento) not at all — + accepted limitation, in README. +- **Placeholders**: `resolve_text` replaces `{date}` (dd/mm/YYYY, run date) + then `{filename}` (source stem) with `str.replace` — never `str.format`. +- **`apply_stamps` paints the image XObject directly** (`w 0 0 h x y cm /Name + Do`), NOT through a pyHanko `StaticStampStyle`: pyHanko's `BoxConstraints` + truncates the box to whole points, so the PDF differed from the preview and + anything under 1 pt high (a `______` line) raised `ZeroDivisionError`. ONE + image per stamp, shared by its pages; a FRESH resource name per page (pages + may share one `/Resources`; pyHanko refuses a duplicate name); numbers via + `_pdf_number` (`%g` can emit an exponent, invalid in PDF). No border. +- **Every stamped page is `mark_update`d**: when `/Contents` is an indirect + array pyHanko updates the array only, and the `/Resources` it adds to the + page would never be written (invisible stamp, reported as success). Such + an array is first COPIED onto the page: pyHanko appends in place, and a + page sharing it would get the stamp too. A page without `/Contents` first + gets an empty stream (pyHanko raises `KeyError`). +- **No half-stamped writer**: pages and content are resolved for ALL stamps + before the first write. +- **Memory is bounded**: `text_layout` MEASURES (1×1 probe) and refuses a + text above `MAX_TEXT_PIXELS` BEFORE any allocation; `validate_stamp` runs + the same probe, so no entry point (CLI, JSON, dialog, hand-edited profile) + can trigger a huge raster. Freehand drawings: same budget. +- **Caching**: `stamp_content(stamp, cache=…)` keys on `content_key`; a text + with `{filename}` is never cached (unbounded growth over a batch). + +JSON schema (`--signatures` and profile entries, `Stamp.to_dict/from_dict`): +`kind`; text → `text`, `font`, `color`, `font_size`; image → `image_path`, +`width_pt`; then `page` | `page_anchor` | `all_pages: true`, `x`, `y`; +optional `enabled`. `from_dict` only type-checks (a `bool` is never a +number); `validate_stamp(stamp, placed=True)` checks content then placement, +`placed=False` content only. A relative font PATH is joined to the JSON +folder, but a bare word that is no file there stays as typed, so a mistyped +bundled id is reported as an id. + +**Vignette placement (beid/azure).** With `--x/--y` (or a click in the GUI) +it goes on `--page` at that point, sized to a **3:1 box of width page_w/5** +(`vignette_size_pt`). Without a position it keeps its default bottom-right +box (`_default_vignette_box`, from the pure `default_vignette_rect` that the +GUI also draws). `build_stamp_style(identity, box_w, box_h)` makes the photo +band proportional to the box width (`_PHOTO_BAND_FRAC`), so one layout fits +both boxes without overflowing pyHanko's layout margins. The canvas ↔ PDF +math is pure and tkinter-free (`fit_frame`, `frame_click_to_pdf_xy`, +`pdf_rect_to_frame_rect`). ## GUI workflow (`gui.py`) -`CachetApp` (a `ctk.CTk`) opens on a **landing page** (overview text, top -bar, Start bottom-right — no stepper there); Start builds the **wizard**. Both -screens share the same **top bar** (`_build_top_bar`): the brand on the left -(`logo.png` + "Cachet" — `_load_logo` caches a `CTkImage`, `_asset_path` -resolves the file next to `gui.py` or under `sys._MEIPASS` in the frozen -binary via `gui_datas` in cachet.spec; a missing logo degrades to the name -alone), and on the right the language selector (`_wizard_lang_menu` in the -wizard) with the **support link** to its right (`support.button` → -`_SUPPORT_URL`, the Stripe payment page, opened in the browser). The wizard -adds a stepper bar, a split body (form on the left inside a `CTkScrollableFrame`, -per-step contextual help `i18n` text on the right, rendered with -`_fill_textbox` so `**bold**` markup shows as bold), and a footer whose -Previous/Next labels **name the target step** (`nav.next`/`nav.previous`). -Cancel opens a confirm modal; confirming (and Finish on the last step) calls -`_reset_state()` and returns to the landing page. All texts come from -`i18n.tr`; the language defaults to `system_language()` and can be switched -at any step: `_on_wizard_language_change` sets the language, closes the -(language-bound) docs popup, then rebuilds the chrome and the current step -in place (`_build_wizard()` + `_goto_step(min(step, _first_incomplete()))`) -— state lives on the app, so nothing the user entered is lost. The selector -is disabled while a batch runs. - -The 8 steps (`_STEP_KEYS` → `_build_step_`): (1) template, (2) files, -(3) validation — auto-runs on first entry, pass/fail table, plus a localized -**first/last-page selector** (`anchor_row`, default "last") that appears only -when some files' page count differs from the template; it maps to -`RunConfig.page_anchor` for the whole batch and re-validates on change, -(4) output folder, (5) signature type (beid/azure/image radios + per-input -hints + **PAdES level selector**, default `b-lta`; azure panel appears only in -azure mode; the **"Full documentation" popup is localized**: `_show_docs_popup` -renders `i18n.DOC_SECTIONS` with bold markup, then `i18n.DOC_SOURCES` as -clickable links — `tag_bind` → `webbrowser.open`), (6) placement — when -`count_mismatch` is set, a **mirror of the step-3 first/last selector** sits -at the top (`place_anchor_row`, same shared `anchor_choice`, re-validates on -change and shows "N/M accepted" via `_update_place_anchor_widgets`), then the -page preview + click for **all** modes (image mode adds the image picker) -plus a manual **target-page field** (`page_text`) that follows the preview -when in range and only warns when beyond the template; while a page anchor -applies the field and Prev/Next are **disabled**, the preview is **locked** -onto the template's first/last page (label says "locked") and a position -clicked on another page is dropped (`_sync_anchor_page`) — `page` and -`page_anchor` stay mutually exclusive in the built `RunConfig`, (7) signing — -config summary (names the anchor when active), in beid mode a **green -"insert your eID card" box** (`card_box`, hidden by `_launch`, shown again by -`_show_card_box` when the batch fails to start), Start + progress bar, -(8) results report table + an **"Open output folder"** button -(`core.open_in_file_manager`: `os.startfile` / `open` / `xdg-open`; failures -are shown inline, never raised into the Tk loop). - -Step state drives the chrome (`_refresh_chrome`): per-step predicates -`_step_complete` / `_step_error` color the stepper chips — current = accent, -passed & clean = light-green border (`_COL_DONE`), problems = light-red -(`_COL_ERROR`, e.g. rejected files on step 3, missing azure vault/anchors on -step 5), reachable-pending = gray, beyond the first incomplete step = disabled -(`_COL_LOCKED`) — a step is never shown green just because its *defaults* are -valid. Next is enabled only while the current step **and every step before -it** are complete (`i < _first_incomplete()` — the step-6 selector can -re-validate step 3 down to zero accepted files); ALL navigation (stepper, -Prev/Next, Cancel, language selector) locks while `_running`. Editing upstream state -invalidates downstream results (`validation_results`, `run_results`) so the -gating recomputes. Step content is **rebuilt on every entry** (state lives on -the app, widgets are disposable) — update helpers guard widget access with -`_alive(...)`. - -Step 6's `tkinter.Canvas` shows the **actual rendered template page** as its -background (`core.render_page_image`: **pypdfium2** primary, `pdftoppm` fallback; -cached per page; falls back to a white frame only if both are unavailable). -Prev/Next change the preview page; a click sets the position AND syncs the -target-page field, drawing a to-scale placeholder — the image (image mode) or a -**3:1 box of width page_w/5** (beid/azure). The canvas **resizes with the -window**: `` on the toplevel → `_draw_page` recomputes the canvas -size from the window (`_canvas_target_size`) and refits the page, preserving -proportions (cached full-res page image is just rescaled, so no re-render on -resize). Tables use `rowheight=30` + an explicit font so full text lines show. - -Tkinter is **not thread-safe**, so the worker thread never touches widgets: it -pushes `("row"/"done"/"error", payload)` onto a `queue.Queue`, and the main -thread drains it via a periodic `self.after(100, self._poll_results)` (progress -bar + status on step 7; "done" auto-advances to the report). Calling -`self.after(...)` *from* the worker raises `main thread is not in main loop` — -do not reintroduce that. The worker catches `(Exception, SystemExit)`: -`open_eid_session()` raises **`SystemExit`** (no reader/card), which is *not* an -`Exception`, so a bare `except Exception` would let the worker die silently and -hang the GUI on "Processing…". `_poll_results` also no-ops if the -window was closed mid-batch. Guarded end-to-end tests (`GuiImageEndToEnd`, -`GuiWizardChrome`, …, skipped without a display) cover these paths. +`CachetApp` (a `ctk.CTk`) opens on a **landing page**; Start builds the +**wizard**: top bar, stepper, a split body (form in a `CTkScrollableFrame`, +per-step help), and a footer whose Previous/Next labels **name the target +step**. Cancel opens +`_confirm_modal` (shared with Delete — its button row must stay the window's +LAST child, a test finds the buttons that way); confirming (and Finish) calls +`_reset_state()`, which resets the SESSION only and RELOADS the profile — the +library and the settings are never wiped. + +Language: `launch_gui` applies the saved language, else `system_language()`; +`CachetApp.__init__` never changes it (tests build apps directly). A switch +closes the docs popup and any editor dialog (language-bound), then rebuilds +the chrome and the current step in place. + +The 8 steps (`_STEP_KEYS` → `_build_step_`): template, files, +validation, output folder, signature type, placement (the element editor), +signing, report. Step 3 auto-validates on first entry; its **first/last-page +selector** (`anchor_row`, default "last") appears only when some files' page +count differs from the template: ONE anchor for the whole batch, +re-validating on change. Step 7's "insert your eID card" box (beid) is hidden +by `_launch` and shown again by `_show_card_box` when the batch fails to +start. The step-3 and step-8 tables (`_make_table` / `_fill_table`) keep +their first columns fixed; the last one (detail) takes the remaining width, +follows the window and is never narrower than its longest text — a +horizontal scrollbar shows up only while that does not fit. + +**Step 6 — the element editor.** Elements = the vignette (beid/azure, id +`VIGNETTE_ID`) + the library `profile.signatures` (ids are 12 hex digits, so +never `"vignette"`). Top to bottom: a mirror of the step-3 selector when +`count_mismatch`; the navigation row at FULL width; then the +`elements_panel` (stacked add buttons, element list, actions) beside the +preview column (canvas, red `place_warn_lbl`). +- Selection happens in the LIST only; a canvas click always PLACES the + selected element (no hit-testing). +- Every library/placement mutation ends with `_commit_elements()` (save → + invalidate run → refresh); the helpers are `_alive`-guarded, so element + operations also work while step 6 is not built. +- **Placement is never destroyed** by a template or anchor change: an element + keeps its position plus `placed_on`, the `(w, h)` of the page it was placed + on; whether it is *in force* is a pure predicate (`_sig_placed`, + `_vignette_placed` → `profile_store`: exact page-size equality; under an + anchor the element must sit on the anchor's template page). Not in force = + "not placed", and it comes back when the template/anchor does. Code that + re-targets an element (e.g. unticking "On every page") must ask + `_sig_placed`, never "has coordinates", or a placement saved for another + page size is applied without a click. +- `_place_error()` (first problem or None): page field invalid or beyond the + template (BLOCKING), an enabled signature that cannot be rendered, image + mode without any enabled signature, an enabled signature not placed. + beid/azure with no enabled signature is complete without a click (default + vignette); disabled signatures never block. +- The page field is parsed with `str.isdecimal()`: `isdigit()` accepts "²" + (one key on AZERTY), `int()` rejects it, and the exception would hit every + navigation callback. `_sync_page_to_selection()` is the ONLY place deriving + `page_text` from an element; `_pick_template` empties the field. +- **Anchor lock** (page counts differ): the batch anchor forces EVERY element + onto each document's first/last page; the preview is locked on that + template page and no position is dropped. `_launch` rewrites every enabled + stamp to `page_anchor=anchor, page=None, all_pages=False` and passes + `page_anchor` for the vignette in beid/azure (None in image mode) — `page` + and `page_anchor` stay exclusive. `_launch` never passes `image_path`: + `cfg.stamps` = the enabled signatures; the legacy `page/x/y/page_anchor` + carry the VIGNETTE only. +- Layout contract at the default 1180 px window in every language (asserted + by a GUI test in `pt` and `nl`): nav row above the editor row, panel no + wider than `_ELEMENTS_PANEL_W` + 12 px, canvas inside the visible column. + +**Editor dialogs** (`_open_dialog`): ONE `self._dialog` at a time, a +non-blocking `CTkToplevel` with a delayed guarded `grab_set`, never +`wait_window`; closed on step change, reset and language switch; widgets are +`dlg_*` attributes, plain values live in `_dialog_state`. The footer (error +label + Cancel/Save) is packed FIRST from the bottom so it never leaves the +fixed-size window. Text dialog: live preview through `stamps.stamp_content`; +the "enter some text" error stays hidden until the user typed or tried to +save (`_dialog_state["edited"]`); editing KEEPS the placement. Draw dialog: +the strokes are the model, the canvas only their echo; points are clamped to +the canvas. An image that cannot be read or copied into the store is NOT +added — no fallback to the original file. A picker opened from a dialog gets +`parent=self._dialog`, and its handler re-checks `_alive(self._dialog)`. + +Chrome (`_refresh_chrome`): `_step_complete` / `_step_error` colour the +stepper chips — a step is never green just because its *defaults* are valid. +Next is enabled only while the current step **and every step before it** are +complete (the step-6 selector can re-validate step 3 down to zero accepted +files); ALL navigation locks while `_running`. Editing upstream state +invalidates downstream results. Step content is **rebuilt on every entry** +(state lives on the app, widgets are disposable): update helpers guard widget +access with `_alive(...)`. + +The step-6 canvas shows the **rendered template page** +(`core.render_page_image`: **pypdfium2**, `pdftoppm` fallback, white frame if +neither) and every element of that page to scale, the selected one LAST. +`_content(sig)` caches a thumbnail of `stamps.stamp_content` per +`content_key`; it runs inside `_refresh_chrome` on every navigation, so it +**never raises** (`except Exception`; the message is cached instead) and +never renders the same failing content twice. Every `ImageTk.PhotoImage` of a +draw is kept in the list `_canvas_imgs` (Tk only keeps a name). On resize +cached images are rescaled, never re-rendered. + +**Persistence hooks.** `CachetApp(args, store=None)`; `_save_profile()` never +raises (an `OSError` is shown as `place.save_failed`), runs on the main +thread only, and is called on every change. The vault URL is saved only on a +REAL edit of the entry (a focus-out must not turn the env/default value into +a saved choice). **`_loading` guard**: `_reset_state` sets the tk +variables from the loaded profile, which fires their traces — the save +handlers return at once while `_loading`, or a reset would write defaults +back over the saved settings. Loading precedence: saved choice > +`CACHET_AZURE_*` env > built-in default. A saved output folder / anchors file +is probed with `os.path.isdir/exists`, NEVER `Path.is_dir()/exists()`: pathlib +raises on EACCES / ENAMETOOLONG / an unreachable share, and the app could no +longer start. + +Tkinter is **not thread-safe**: the worker thread never touches widgets, the +profile or the GUI caches. It pushes `("row"/"done"/"error", payload)` onto a +`queue.Queue`, drained on the main thread by a periodic +`self.after(100, self._poll_results)` ("done" auto-advances to the report). +Calling `self.after(...)` *from* the worker raises `main thread is not in main +loop` — do not reintroduce that. The worker catches `(Exception, SystemExit)`: +`open_eid_session()` raises **`SystemExit`** (no reader/card), which is *not* +an `Exception`, so a bare `except Exception` would let the worker die silently +and hang the GUI on "Processing…". `_poll_results` no-ops if the window was +closed mid-batch. **CTkEntry + StringVar pitfall**: `CTkEntry.destroy()` (CustomTkinter 5.2.2) does NOT remove the trace it adds on its textvariable (radio buttons and option menus do). Since step content is rebuilt constantly, entry-backed state is kept in **plain strings** (`page_text`, `azure_vault`, `azure_key`) synced via key bindings — do not "simplify" these back to shared `StringVar`s, or -every later `var.set()` fires callbacks on dead widgets (TclError spam). - -## Three deliberate workarounds (beid mode) — do not "simplify" away +every later `var.set()` fires callbacks on dead widgets (TclError spam). Rule +for new code: **no variable on any `CTkEntry`**, dialogs included (seed with +`.insert`, read with `.get()`); the step-6 check boxes and the dialog option +menu are used WITHOUT a variable. + +**Other CustomTkinter 5.2.2 pitfalls** (each covered by a GUI test): +- Never `CTkLabel.configure(image=None)` (nor `""`): the old picture stays on + screen and, once freed, the next `configure` raises `TclError: image + "pyimageN" doesn't exist`. "No preview" = the 1×1 transparent image of + `_blank_ctk_image()`; every preview image is pinned on its label. +- An empty `CTkFrame` keeps a 200×200 default size and does not shrink when + its last child is destroyed: `elements_list` always keeps one child. +- `CTkButton` has no `justify` and its `width` is a minimum: list rows are + two stacked `CTkLabel`s, and the panel's buttons are stacked. +- `CTkTextbox` wraps visually by default: the text dialog uses `wrap="none"` + (the stamp breaks lines only at real newlines). +- `CTkCheckBox.select()/.deselect()` never call `command`, and there is no + `invoke()` (tests use `toggle()`): handlers flip the MODEL and the list + refresh re-syncs the widget, never the other way round. +- A `CTkScrollableFrame` inside the scrollable content column double-scrolls: + `elements_list` is a plain frame. + +## Deliberate workarounds and invariants — do not "simplify" away 1. **`open_eid_session()` replaces `pyhanko_beid.open_beid_session()`** — the plugin hard-codes a `BELPIC` token label; the same opaque error also appears @@ -321,13 +460,25 @@ every later `var.set()` fires callbacks on dead widgets (TclError spam). token and gives distinct "no reader" / "no card" / "other label" messages. 2. **`IncrementalPdfFileWriter(inf, strict=False)`** — inputs use *hybrid* xref sections; strict mode refuses them (`hybrid cross-reference sections - while hybrid xrefs are disabled`). `strict=False` is the intended escape - hatch. Image insertion uses the same `strict=False` writer. -3. **Vignette via `signers.PdfSigner(stamp_style=…, new_field_spec=…)`** — field - box defaults to a bottom-right corner box on the target page - (`_default_vignette_box`, `on_page=-1` unless a page anchor moves it), or, - when `sign_one(..., pos=(x, y))` is given, a 3:1 box of width page_w/5 on the - chosen page; background image and text positioned independently. + while hybrid xrefs are disabled`). Same writer for the visual-only path. +3. **Vignette via `signers.PdfSigner(stamp_style=…, new_field_spec=…)`** — the + field box is the default bottom-right box (`on_page=-1` unless a page + anchor moves it) or, with `sign_one(..., pos=(x, y))`, the placed 3:1 box; + background image and text are positioned independently. +4. **Visual stamps go into the SAME writer BEFORE `sign_pdf`, never after** — + a stamp added to a signed output is an incremental update that validators + flag as a modification (`verify_signed_pdf` raises). For the same reason, + in beid/azure, stamps are never applied to an INPUT that is already + signed: `sign_one` checks `existing_signature_count(writer)` (FILLED + signature fields + document timestamps — an empty field prepared for + signing does not count) right after opening the writer and raises + `StampError` before any PIN prompt; self-verification only validates the + LAST signature and would not notice the broken earlier one. Without stamps + the guard is not evaluated (plain countersignature; use another `--field`, + pyHanko refuses a filled one). Image mode keeps its historical outcome (it + stamps, invalidating the old signature) but appends a WARNING to the + detail; there a malformed `/AcroForm` counts as "not signed", while + `sign_one` fails closed. Do not add a code path that stamps `dst`. ## Where the signer's identity comes from (beid mode) @@ -339,114 +490,180 @@ national register number is embedded in every signature — mind PDF distributio ## Runtime requirements -- **beid mode**: eID middleware (`libbeidpkcs11.so`), reader + inserted card, - `pcscd` running. Prompts for the **PIN once per document**, so a full eID run - needs hardware + a human and cannot be exercised headlessly. Levels ≥ b-t - (default b-lta) additionally need **network**: TSA, EU trusted list - (cached 24 h) and OCSP/CRL endpoints; `--pades-level b-b` is offline. -- **azure mode**: no hardware; outbound network to `login.microsoftonline.com`, - the vault URL, the TSA (≥ b-t) and the internal CA's CRL/OCSP (≥ b-lt); - per-user Key Vault key/cert provisioned by an Azure admin (README). Tokens - and key material are never logged; only the digest leaves the machine. -- **image mode**: nothing special — pure PDF stamping, fully testable headless. -- **GUI**: `customtkinter` (pip) **and** a Python with `tkinter` + a display. - The step-6 page preview is rendered by **pypdfium2** (bundled PDFium, no - external binary); `pdftoppm` (poppler-utils) is only an optional fallback if - already present. Without either, the canvas falls back to a blank white frame. +- **beid**: eID middleware (`libbeidpkcs11.so`), reader + inserted card, + `pcscd` running. The **PIN is requested once per document**, so a full eID + run needs hardware + a human and cannot be exercised headlessly. Levels + ≥ b-t need **network** (TSA, EU trusted list, OCSP/CRL); `b-b` is offline. +- **azure**: no hardware; network to `login.microsoftonline.com`, the vault, + the TSA (≥ b-t) and the internal CA's CRL/OCSP (≥ b-lt). +- **visual signatures**: no network; text needs Pillow's FreeType renderer + (`PIL._imagingft`) and the bundled `fonts/`. ### tkinter in this environment -The system Python (3.12 here) lacks the `_tkinter` C extension, so `tkinter` -(and thus `customtkinter`) cannot import out of the box. The clean fix is -`sudo apt install python3-tk`. In this checkout the venv has been -**provisioned** without root: `tkinter/` + `_tkinter*.so` extracted from the -`python3-tk` .deb into `venv/lib/python3.12/site-packages/`, plus -`libBLT.2.5.so.8.6` (from the `tk8.6-blt2.5` .deb — Ubuntu's `_tkinter` links -against it) preloaded by `_blt_preload.pth` (a `sitecustomize.py` would be -shadowed by Ubuntu's own). Recreating the venv requires redoing that (or the -apt install). +The system Python (3.12 here) lacks `_tkinter` (clean fix: `sudo apt install +python3-tk`). The venv was **provisioned** without root: `tkinter/` + +`_tkinter*.so` extracted from the `python3-tk` .deb into +`venv/lib/python3.12/site-packages/`, plus `libBLT.2.5.so.8.6` (from the +`tk8.6-blt2.5` .deb — Ubuntu's `_tkinter` links against it) preloaded by +`_blt_preload.pth` (a `sitecustomize.py` would be shadowed by Ubuntu's own). +Recreating the venv requires redoing that. +Packaging side effect: PyInstaller cannot see that `.pth` preload, so a GUI +binary built from THIS venv lacks libBLT — launch it with +`LD_LIBRARY_PATH=venv/lib/python3.12/site-packages` to check it here. ## Validating changes without hardware -- **image mode & validation**: fully end-to-end via the CLI or `process_batch` - (no card). The `unittest` suite builds synthetic PDFs (`make_pdf`) to assert - page-count / dimension validation, image insertion, placement math, and arg - resolution. +- **Never touch the real user profile.** The GUI loads and AUTO-SAVES one. + Non-GUI tests pass explicit temp dirs to `ProfileStore(config_dir=…, + data_dir=…)`; GUI tests get `CACHET_CONFIG_DIR` / `CACHET_DATA_DIR` from + `_GuiTestBase.setUp`, with a `setUpModule` sandbox underneath. The same + holds for anything that is NOT a unittest (screenshot script, manual + `--gui`, ad-hoc `gui.CachetApp(args)`): export both variables to scratch + directories first, or pass `store=`. +- **Visual signatures & validation**: fully end-to-end via the CLI or + `process_batch`. Fixtures: `make_pdf` (optional MediaBox `origin`), + `write_pdf_objects` for unusual page shapes, `make_png`. Do not assert + pixel-exact text sizes (hinting differs between FreeType builds): use + ratios, bounding boxes, colours, or the paint matrix. - **PAdES levels & self-verification**: `SelfVerification` signs real PDFs *offline* with `SimpleSigner` + pyHanko's `DummyTimeStamper` (RSA-only) and a pre-loaded CRL, then asserts `verify_signed_pdf()` detects B-T/B-LT/B-LTA - and fails on mismatch. Do NOT fake the eID hardware path into passing — - real-card B-LTA stays the manual acceptance test in BUILD.md. -- **trust.py**: `test_trust.py` covers LOTL parsing, cert filtering, cache, - TTL, refresh and offline errors with the network mocked (`fetcher=`); a - live run against the real LOTL takes ~1 s if you need to sanity-check. -- **azure mode**: `test_azure.py` mocks ONLY the Azure transport — the fake + and fails on mismatch. It is also the recipe for **stamps + signature** + (real `core.sign_one(…, stamps=[…])`, then coverage `ENTIRE_FILE` and + modification level `NONE`) and holds the negative control (a stamp after + the signature fails verification). +- **azure**: `test_azure.py` mocks ONLY the Azure transport — the fake `CryptographyClient` really signs the digest with a local key, so RSA/EC - signatures flow through pyHanko + `verify_signed_pdf` end-to-end (incl. - the r||s→DER conversion). Auth/claims/key-template/material/batch wiring - are unit-tested with stub credentials; NO test performs a real login — - the real Entra+Key Vault path is the manual acceptance test in BUILD.md. - Do not fake it into passing. -- **GUI**: instantiate `gui.CachetApp(args)`, call `app._start_wizard()`, - inject state directly (`template_path`/`template_dims`/`input_paths`/ - `output_dir`…), walk with `app._goto_step(i)`, call `app.update()`, and - screenshot the window by id with ImageMagick - (`import -window shot.png`) on `DISPLAY=:0` — this catches real - CustomTkinter API errors that `py_compile` cannot. If a GUI test run stalls - at window creation with ~0 % CPU, it is Tk waiting on the ibus X input-method - bridge (`XCreateIC`/`_XimRead`), not the app: run with - `XMODIFIERS=@im=none` (and `PYTHONFAULTHANDLER=1 timeout -s ABRT …` to get a - stack if it ever recurs). Never run several GUI suites concurrently on the - same display. Step 3 auto-validates on - entry; step 6 builds `app.canvas`; a finished batch auto-advances to step 8. -- **i18n**: `test_i18n.py` (headless) checks catalog completeness, - placeholder parity, balanced / equal-count `**bold**` markup, and that the - docs catalog (`i18n_docs.py`) is merged and genuinely translated in all six - languages. -- **vignette appearance** (beid): render `build_stamp_style(identity)` via - `pyhanko.stamp.TextStamp.apply()` onto a copy with an explicit + signatures flow through pyHanko + `verify_signed_pdf` end-to-end. +- **Do NOT fake the hardware / login paths into passing**: real-card B-LTA + and the real Entra + Key Vault run stay the manual acceptance tests of + BUILD.md. +- **GUI**: (profile env vars set) `gui.CachetApp(args)`, + `app._start_wizard()`, inject state directly (`template_path` / + `template_dims` / `input_paths` / `output_dir`…; signatures through + `app._add_signature(stamp, label)`), walk with `app._goto_step(i)`, + `app.update()`, and screenshot the window by id with ImageMagick + (`import -window shot.png`) on `DISPLAY=:0` — this catches + CustomTkinter API errors that `py_compile` cannot. A finished batch + auto-advances to step 8. Dialogs are driven through their handlers (fill + the `dlg_*` widgets, `_on_text_dialog_edit()`, `_apply_*_dialog()`), with + `gui.filedialog.*` and `gui.colorchooser.askcolor` patched. +- **A GUI run stalling at window creation with ~0 % CPU** is Tk waiting on + the ibus X input-method bridge, not the app: run with + `XMODIFIERS=@im=none`. Never run several GUI suites concurrently on one + display. +- **`CACHET_REQUIRE_GUI=1`**: the display guard of the GUI tests turns ANY + failure of `tkinter.Tk()` or `import gui` (no display, broken Tk, an + ImportError in `gui.py`) into a skip — a green `-k Gui` run with skips + proves nothing. With this variable the guard FAILS instead; every run + meant to exercise the GUI sets it and must report zero skips. +- **GUI tests and the cyclic GC**: `_GuiTestBase.setUp` calls `gc.disable()`. + The tests pump `update()` instead of a mainloop; when the collector runs in + the batch WORKER thread, each garbage `CTkFont.__del__` is a Tk call from a + non-main thread that waits one second for a mainloop that never comes, and + the batch outlives the test's wait loop. New worker-thread GUI tests must + inherit `_GuiTestBase`. +- **Vignette appearance** (beid): render `build_stamp_style(identity)` via + `pyhanko.stamp.TextStamp.apply()` with an explicit `BoxConstraints(width=_STAMP_W, height=_STAMP_H)`, then rasterize with `pdftoppm`. `read_card_identity()` needs the card inserted but no PIN. ## Packaging (standalone executables) -PyInstaller builds **two onefile binaries** from one shared spec -(`cachet.spec`), full details in **`BUILD.md`**: +PyInstaller builds **two onefile binaries** from one spec (`cachet.spec`), +details in **`BUILD.md`**: -- **`cachet`** — windowed (`console=False`), entry `gui_main.py` (parses the - core arg parser then calls `gui.launch_gui`); double-click → GUI. -- **`cachet-cli`** — console (`console=True`), entry `sign_pdfs_beid.py`; - headless, **excludes** tkinter/customtkinter to stay lean. +- **`cachet`** — windowed (`console=False`), entry `gui_main.py`. +- **`cachet-cli`** — console, entry `sign_pdfs_beid.py`; headless, + **excludes** tkinter/customtkinter (and pypdfium2) to stay lean. -`i18n.py` is a plain module imported by `gui.py`, so PyInstaller's import -analysis bundles it into the GUI binary by itself — no spec change needed; -the CLI neither imports nor needs it. +**Fonts are data of BOTH binaries** (as `stamps.py` is code of both): +`common_datas` carries `fonts/*.ttf` and `fonts/licenses/*` (the OFL requires +the licence texts to travel with the fonts) — never move them to +`gui_datas`, the CLI stamps text too. +`stamps.asset_path` resolves them next to the module or under +`sys._MEIPASS`. `common_hidden` must keep `PIL._imagingft` (Pillow's FreeType +renderer); `gui_hidden` adds `profile_store` and `tkinter.colorchooser`. Key spec facts (don't regress): no built-in PyInstaller hooks exist for `pyhanko`/`pyhanko_beid`/`pyhanko_certvalidator`/`asn1crypto`/`oscrypto`/`pkcs11` → they are `collect_all`'d; `pkcs11._pkcs11` native ext + `collect_dynamic_libs`; `copy_metadata` for the pyhanko family (defensive). `oscrypto` **must not be -excluded** (hard import in certvalidator) but its OpenSSL backend is never on -this app's signing path. `tzdata` is collected **only on Windows** (pyHanko -timestamps need a zoneinfo there). `upx=False` (UPX can corrupt crypto libs). -The eID middleware (`libbeidpkcs11.so`/`beidpkcs11.dll`) is a **runtime dep, -never bundled**. The page preview uses **pypdfium2** (bundled in the GUI binary -via the contrib hooks; excluded from the CLI binary), so poppler is no longer -required — only an optional fallback. The Azure SDK -(`azure.core`/`azure.identity`/`azure.keyvault.*`/`msal`/`msal_extensions`) -has no provided hooks either → `collect_all`'d + metadata into the **common** -collection, and `azure_signer`/`jwt` are explicit hiddenimports — azure mode -must stay available in the **CLI** binary (do NOT add azure to CLI_EXCLUDES). - -Build routes: `./build_linux.sh` (native), `build_windows.bat` (real Windows), -`./build_windows_wine.sh` (Linux→Windows via Wine, best-effort), and -`.github/workflows/build.yml` (CI matrix, windows+linux, artifacts — runs on -`develop` pushes and PRs). **Releases**: merging `develop` into `main` runs -`.github/workflows/release.yml`, which tags `v{__version__}` (read from -`sign_pdfs_beid.py` — bump it on develop, it is the single source of truth) -and publishes a GitHub Release with both packaged binaries; an existing tag -makes the workflow skip gracefully. See BUILD.md "Release process". Verify -headlessly: CLI `--help`, image-mode end-to-end, a PKCS#11 native-load canary -(`--lib` at a dummy `.so` → expect a PKCS#11 error, not `ImportError`), and the -GUI binary launched on `DISPLAY=:0` + screenshot. Real eID signing needs -hardware and a real-Windows acceptance test. +excluded** (hard import in certvalidator). `tzdata` is collected **only on +Windows** (pyHanko timestamps need a zoneinfo there). `upx=False` (UPX can +corrupt crypto libs). The eID middleware is a **runtime dep, never bundled**. +The Azure SDK (`azure.*`, `msal`, `msal_extensions`) has no hooks either → +`collect_all`'d + metadata into the **common** collection, with +`azure_signer`/`jwt` as explicit hiddenimports — azure mode must stay +available in the **CLI** binary (do NOT add azure to CLI_EXCLUDES). + +Build routes: `./build_linux.sh`, `build_windows.bat`, +`./build_windows_wine.sh` (best-effort), and `.github/workflows/build.yml` +(on `develop` pushes and PRs). **Releases**: merging +`develop` into `main` runs `release.yml`, which tags `v{__version__}` (read +from `sign_pdfs_beid.py` — the single source of truth, bump it on develop) +and publishes both binaries; an existing tag makes it skip. Verify a build +headlessly: CLI `--help`; `--text` / `--signatures` end-to-end (fonts and +`PIL._imagingft` in the frozen CLI); a PKCS#11 native-load canary (`--lib` at +a dummy `.so` → a PKCS#11 error, not `ImportError`); the GUI binary on +`DISPLAY=:0` + screenshot (profile env vars set). + +**CI** — four pipeline files run the tests in the same three Linux steps: the +headless suite; a **GUI canary** (`import tkinter, customtkinter, gui` + a +`Tk()` under Xvfb — a broken Tk or `gui.py` must fail, not skip); the `Gui*` +suites under Xvfb with `CACHET_REQUIRE_GUI=1` and `XMODIFIERS=@im=none` (the +end-to-end layer: a Tk desktop app has no browser to drive). Every test job +has a timeout — a stalled Tk call must not hold a runner or the release +concurrency group — and the GUI step runs under `timeout -s ABRT 900` with +`PYTHONFAULTHANDLER=1`, so a stall dumps its stacks. +GitHub Actions (`build.yml`, `release.yml`) run them on Linux only (on the +Windows runner Tk could open real windows and hang), then build on Windows + +Linux and smoke-test the frozen CLI. GitLab CI (`.gitlab-ci.yml`) and Forgejo +Actions (`.forgejo/workflows/tests.yml`) run the tests only, in +`python:3.13-bookworm` + `xvfb xauth fonts-dejavu-core`; on Forgejo the +runner label and `actions/checkout@v4` depend on the instance. + +## Persistence (user profile) — `profile_store.py` + +GUI only; tkinter-free; the core/CLI never imports it. + +- **Where**: `profile.json` in the config dir, the image store `signatures/` + in the data dir — each: explicit argument > `CACHET_CONFIG_DIR` / + `CACHET_DATA_DIR` > `platformdirs` (ONE folder for both on Windows/macOS). + `ProfileStore()` resolves both ONCE at construction and creates nothing. +- **What**: `settings` — only keys with a value are written (`None` = "never + set by the user", so env/defaults keep applying); `signatures` — per entry + `Stamp.to_dict(base_dir=signatures_dir)` + `id`, `label`, `enabled`, + `placed_on`. A stored image is written as its bare file name, any other as + an absolute path. "Unplaced" = no `x`/`y` (it may still remember + `all_pages: true`). +- **NEVER stored**: PIN, tokens, any credential, the azure key name, the + signed-in UPN, the template, the input files. Everything is UNENCRYPTED + (files created 0600 on POSIX); the GUI help and README say so. +- **Atomic writes** (temp file in the same directory + `os.replace`); `save` + raises only `OSError`. +- **Tolerant `load()` — never raises**: missing file → defaults; a UTF-8 BOM + is accepted; not UTF-8 / invalid JSON / deep nesting / wrong top level / + `version` missing or ≠ 1 → moved aside as `profile.json.bak` → defaults. A + malformed entry is skipped, the others kept; an entry whose image or font + FILE is missing is KEPT (shown as unusable, deletable) — recognised by the + `StampError` prefixes in `_MISSING_FILE_PREFIXES`, keep them in sync with + `stamps.py`; an incomplete placement is cleared; bad / duplicate ids are + regenerated. +- **A degraded load must not lead to a destructive save**: when the file + could not be read (`OSError`) or not be moved aside, it stays in place and + `save()` REFUSES (`OSError`) until a later `load()` succeeds — the GUI + saves on every click and would replace a good profile with an empty one. +- **Image store**: imported images and drawings are COPIED to + `signatures/<32 hex of sha256>` (content-addressed: same bytes → one + file; a moved original does not break a signature). User FONTS given by + path are not copied. +- **Deleting**: `remove_signature` is the ONLY code that unlinks anything, + and only a file for which `is_store_file(path)` holds (not a symlink, + RESOLVED parent = the resolved `signatures/` dir, content-addressed name) + and that no remaining signature references. Never use a lexical + `is_relative_to` / `relative_to` test for "inside the store" + (`dir/../../x` would pass). Deliberately **no sweep of unreferenced + files**: the data dir may serve another profile and a `.bak` still + references its images. diff --git a/README.md b/README.md index 36e89b9..3d2d9fc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,13 @@ both a **command line** and a **CustomTkinter GUI**, with three modes: - **`azure`** — personal *advanced* (AES) signatures with the user's own certificate held in Azure Key Vault, after a single Microsoft Entra ID login per batch; only the document digest ever leaves the machine. -- **`image`** — a simple image stamp (no cryptographic value). +- **`image`** — **visual signatures only** (no cryptographic value): typed + text in the font and colour of your choice, images, or a hand-drawn + signature — one or several per document, each on a given page, on the + first/last page or **on every page** (initials). + +`beid` and `azure` can carry the same visual signatures: they are stamped +first, and the cryptographic signature then covers them. Signatures are **PAdES up to B-LTA** by default: trusted RFC 3161 timestamp, embedded revocation info (LTV), and an archival timestamp chain — with trust @@ -26,7 +32,9 @@ The GUI is a **step-by-step wizard** (template → documents → validation → output folder → signature type → placement → signing → report) with contextual help on every step, available in **English, French, Dutch, German, Spanish and Portuguese** (language selector on the welcome screen and at the top of every -wizard step; the system language is picked by default). +wizard step; the system language is picked by default). It **remembers** your +visual signatures, their last positions and your settings between sessions +(see *Saved profile*). > ⚖️ The eID mode uses the card's **non-repudiation** certificate, legally > equivalent to a handwritten signature. The **national register number** is @@ -41,13 +49,41 @@ wizard step; the system language is picked by default). |---|---|---|---| | **`beid`** | reader + eID card + PIN per document | **cryptographic** eID signature (pyHanko via the PKCS#11 middleware) — *qualified*-grade (QES) | visible **vignette**: cardholder photo + "Signed by:" / name / date | | **`azure`** | Microsoft (Entra ID) login, one per batch + a personal key/cert in Azure Key Vault | **cryptographic** personal signature — **advanced (AES), not qualified** | visible **vignette**: "Signed by:" / name / date (no photo) | -| **`image`** | nothing | **image stamp** (this is *not* a cryptographic signature) | the supplied image, placed at a chosen position | - -In all modes, the same vignette/image + page + position is applied to **all** -the documents in the batch — template validation guarantees the files are -geometrically identical. With `--page first|last` the target page is resolved -**per document** instead, which also lets the batch contain files whose page -count differs from the template (see *Template validation*). +| **`image`** | nothing | **visual signatures** — typed text, images, drawings (this is *not* a cryptographic signature) | the elements you defined, each at its own position | + +A run applies **a list of elements**: the vignette of the cryptographic +signature (`beid`/`azure`) and/or any number of visual signatures, each with +its own target — page *N*, the first page, the last page or every page — and +its own position. The same list is applied identically to **all** the +documents in the batch — template validation guarantees the files are +geometrically identical. A `first`/`last` target is resolved **per document**, +which also lets the batch contain files whose page count differs from the +template (see *Template validation*). Only **one** cryptographic signature is +made per document, and the visual signatures are always stamped **before** it, +never after. + +### Documents that are already signed + +A visual signature rewrites page content, so it **invalidates any signature +the document already carries**. An input that already holds a signature or a +document timestamp is therefore handled as follows: + +- **`beid`/`azure` with visual signatures** — the document is refused before + anything is signed (no PIN prompt, nothing written) and reported as failed + (`the document is already signed: a visual signature would invalidate the + existing signature(s)…`). The rest of the batch continues. +- **`beid`/`azure` without visual signatures** — the document is + countersigned and the earlier signature stays valid. A signature field name + can be used only once per document: to countersign a file that Cachet + already signed, give the new signature another base name with `--field` + (the GUI always uses the default name, so it cannot countersign Cachet's own + output). +- **`image`** — the document is stamped, as it always was, and its report + line ends with `WARNING: the document was already signed; its existing + signature(s) are no longer valid`. + +The post-signing self-verification only checks the signature Cachet just +created; it says nothing about earlier ones. ### eID vs Azure — which one? @@ -60,7 +96,8 @@ count differs from the template (see *Template validation*). signature (AES): fine for internal documents; for documents relied upon by external third parties, a publicly recognised (qualified) issuer would be needed. -- **`image`** is a visual stamp only — no cryptographic value. +- **`image`** adds visual signatures only — an appearance, with no + cryptographic value. ## Template validation (`--template`) @@ -69,13 +106,21 @@ If a template PDF is supplied, each input is accepted **only** if it has the equality, no tolerance). Rejected files are never signed; the rejection reason is displayed (CLI summary / GUI table). -**Files with a different page count** (e.g. scanned annexes were appended) can -still be processed by targeting a per-document page: pass `--page first` or -`--page last` (in the GUI, a selector appears after validation whenever such -files are detected). Every file is then signed on its own first/last page at -the chosen position; that page must still have **exactly** the template's -corresponding page dimensions, so the position is guaranteed to fit. Files with -the template's page count keep the full strict check. +**Files with a different page count** (e.g. scanned annexes were appended) are +accepted only when **every element of the run targets the first or the last +page**: each visual signature (`--page first|last`, or `"page_anchor"` in a +`--signatures` file) and, in `beid`/`azure`, the vignette. Each of those pages +must still have **exactly** the template's corresponding page dimensions, so +the positions are guaranteed to fit. An element on a numbered page or on every +page rejects such a file, and the reason names it, e.g. `1 page(s), the +template has 2; signature #2 targets every page (only first/last page targets +accept a different page count)`. For the vignette, `--page first|last` must be +explicit: the default bottom-right vignette does **not** count as a last-page +target (a safety check is never relaxed silently). Files with the template's +page count keep the full strict check. + +In the GUI, a selector appears after validation whenever such files are +detected: it puts **every** element on each document's own first or last page. ## Output @@ -97,6 +142,10 @@ overwritten**: on collision, ` - 1`, ` - 2`, … are appended. internal CA's CRL/OCSP (≥ b-lt); per-user keys provisioned in Key Vault (see the azure section above). - **`image` mode**: nothing special — pure PDF stamping. +- **Text signatures** (every mode): no new dependency — the text is rendered + by Pillow. Six fonts under the SIL Open Font License are **bundled** + (`fonts/`, licence texts in `fonts/licenses/`): Great Vibes, Dancing Script, + Caveat, Sacramento (handwriting), Lato (sans) and Libre Baskerville (serif). - **Graphical interface**: `customtkinter` + a Python with `tkinter` and a display. The page preview (step 6) is rendered by **pypdfium2** (the **bundled** PDFium engine) — no external dependency to install. (poppler / @@ -126,6 +175,20 @@ python3 -m venv venv --template ../pdfs/MODELE.pdf --input ../pdfs --output ../signes \ --image-path signature.png --page 1 --x 360 --y 150 +# Text signature (no card): two lines, on the last page of each document: +./venv/bin/python sign_pdfs_beid.py --mode image \ + --text "Jane Doe\nRead and approved, {date}" --font great-vibes \ + --color "#1A2B8C" --page last --x 360 --y 120 \ + --input ../pdfs --output ../signes + +# Several visual signatures (texts, images, initials on every page): +./venv/bin/python sign_pdfs_beid.py --mode image --signatures sigs.json \ + --input ../pdfs --output ../signes + +# The same visual signatures, then the eID signature that covers them: +./venv/bin/python sign_pdfs_beid.py --mode beid --signatures sigs.json \ + --input ../pdfs --output ../signes + # Azure mode (personal Key Vault certificate, one Microsoft login per batch): ./venv/bin/python sign_pdfs_beid.py --mode azure \ --azure-vault-url https://myorg-sign.vault.azure.net \ @@ -144,10 +207,15 @@ python3 -m venv venv | `--input ` | files and/or folders to process (folders are globbed for `*.pdf`). | | `--output ` | output folder (`{name}_signe.pdf`, never overwritten). | | `--template ` | template PDF; if supplied, inputs are validated against it. | -| `--mode beid\|image` | signature mode (default `beid`). | -| `--image-path ` | image to stamp (**required** in `--mode image`). | -| `--page ` | target page: a **1-based** number, or `first`/`last` (resolved **per document**; with `--template` this also accepts files whose page count differs — their first/last page must still match the template's). Image: insertion page. beID: vignette page. | -| `--x --y ` | lower-left corner, in points from the page's bottom-left. beID: **omit both ⇒ bottom-right of the last page**. | +| `--mode beid\|azure\|image` | signature mode (default `beid`). `image` = visual signatures only. | +| `--signatures ` | JSON list of visual signatures (see *Visual signatures* below). Works in **every** mode; in beid/azure they are applied before the cryptographic signature. | +| `--text ` | text signature for `--mode image`, placed with `--page/--x/--y`. `\n` starts a new line; `{date}` and `{filename}` are replaced per document. | +| `--font ` | font of `--text`: `great-vibes` (default), `dancing-script`, `caveat`, `sacramento`, `lato`, `libre-baskerville`, or the path of a `.ttf`/`.otf` file. | +| `--color <#RRGGBB>` | colour of `--text` (default `#1A2B8C`). | +| `--font-size ` | font size of `--text` in points (default 24, at most 400). | +| `--image-path ` | image to stamp in `--mode image` — the legacy single-image form, 150 pt wide, placed with `--page/--x/--y`. Ignored in beid/azure. | +| `--page ` | target page: a **1-based** number, or `first`/`last` (resolved **per document**). image mode: page of the `--text` / `--image-path` signature (default 1). beid/azure: vignette page. With `--template`, see *Template validation* for files whose page count differs. | +| `--x --y ` | lower-left corner, in points from the page's bottom-left. image mode: the `--text` / `--image-path` signature (default 0, 0). beid/azure: the vignette — **omit both ⇒ bottom-right of the last page**. | | `--pades-level ` | PAdES baseline level: `b-b`, `b-t`, `b-lt`, `b-lta` (**default `b-lta`**). See *Signature levels* below. | | `--timestamp-url ` | RFC 3161 TSA for levels ≥ b-t. Precedence: flag > `CACHET_TSA_URL` env > `http://timestamp.digicert.com`. | | `--trust-list-url ` | EU LOTL URL seeding the LTV trust anchors. Precedence: flag > `CACHET_LOTL_URL` env > the official EU URL. | @@ -166,6 +234,89 @@ python3 -m venv venv | `--azure-trust-anchors ` | PEM/DER file or directory with the **internal CA chain** used as LTV trust anchors in azure mode (required for b-lt/b-lta and for self-verification at b-t). Env `CACHET_AZURE_TRUST_ANCHORS`. | | `--azure-graph` | opt-in: use the Microsoft Graph `/me` displayName for the vignette. | +Which flags define the visual signatures of a run, and what `--page/--x/--y` +place: + +| Mode | Flags | Visual signatures of the run | `--page/--x/--y` belong to | +|---|---|---|---| +| image | `--image-path` | 1 image | that image | +| image | `--text` (`--font`, `--color`, `--font-size`) | 1 text | that text | +| image | `--signatures` | the file's entries | — (giving them is an error) | +| image | `--signatures` + `--image-path` or `--text` | that image/text **first**, then the file's entries | the image/text | +| image | `--text` + `--image-path` | error (use `--signatures` for several elements) | | +| image | none of the three | error | | +| beid/azure | `--signatures` | the file's entries, applied before the signature | the vignette | +| beid/azure | `--text` (or `--font`/`--color`/`--font-size`) | error (use `--signatures`) | | +| beid/azure | `--image-path` | none (ignored) | the vignette | +| any | `--font`/`--color`/`--font-size` without `--text` | error | | + +### Visual signatures (`--text`, `--signatures`) + +A visual signature is a **text** or an **image** stamped on one or several +pages. It is an appearance only: by itself it proves nothing. In `beid` and +`azure` modes the visual signatures are stamped first, into the same revision +that is then signed, so the cryptographic signature covers them — a signed +output is never stamped afterwards. + +A `--signatures` file is a **UTF-8 JSON array** of objects, one per visual +signature (a byte-order mark is accepted, e.g. PowerShell's +`Out-File -Encoding utf8`): + +```json +[ + {"kind": "text", "text": "Jane Doe\nRead and approved, {date}", + "font": "great-vibes", "color": "#1A2B8C", "font_size": 24, + "page_anchor": "last", "x": 360, "y": 120}, + {"kind": "text", "text": "JD", "font": "caveat", "font_size": 14, + "all_pages": true, "x": 540, "y": 20}, + {"kind": "image", "image_path": "stamp.png", "width_pt": 120, + "page": 1, "x": 60, "y": 700} +] +``` + +| Key | Type | Applies to | Default | Notes | +|---|---|---|---|---| +| `kind` | `"text"` \| `"image"` | all | required | | +| `text` | string | text | required | Line breaks are allowed. `{date}` → `dd/mm/YYYY` (local date of the run); `{filename}` → name of the source file without `.pdf`. Any other brace is left as it is. At most 2000 characters. | +| `font` | string | text | `"great-vibes"` | A bundled id (`great-vibes`, `dancing-script`, `caveat`, `sacramento`, `lato`, `libre-baskerville`) or the path of a `.ttf`/`.otf` file. A relative path resolves against the folder of the JSON file (the `--font` flag: against the current folder). | +| `color` | `"#RRGGBB"` | text | `"#1A2B8C"` | | +| `font_size` | number | text | `24` | points, greater than 0, at most 400 | +| `image_path` | string | image | required | A relative path resolves against the folder of the JSON file; `~` is expanded. | +| `width_pt` | number | image | `150` | points, greater than 0, at most 5000; the height follows the aspect ratio | +| `page` | integer ≥ 1 | all | — | give exactly **one** of `page`, `page_anchor`, `all_pages: true` | +| `page_anchor` | `"first"` \| `"last"` | all | — | resolved per document | +| `all_pages` | boolean | all | `false` | every page of each document (initials) | +| `x`, `y` | number | all | required | lower-left corner, points from the page's bottom-left | +| `enabled` | boolean | all | `true` | `false` → the entry is skipped | + +Unknown keys are ignored, so an entry copied from the GUI's `profile.json` +(which adds `id`, `label`, `placed_on`) is accepted as it is — only the +`image_path` of an image entry has to be rewritten to a real path. A bad +entry stops the run before anything is processed, with its position in the +file (`sigs.json: signature #2: …`). + +Good to know: + +- **Text is drawn as a picture.** It is rendered at 300 dpi with a + transparent background and cropped tightly to its ink; no font is embedded + in the PDF. `(x, y)` is the lower-left corner of that ink box, and the text + grows right and up from it. With `{date}` or `{filename}` the box is + measured per document, so its size — and the baseline, by the depth of the + descenders — can vary slightly from one document to the next. +- **Missing characters.** A character the chosen font does not contain is + drawn as an empty box or, depending on the font (Sacramento, for + instance), not drawn at all — without any error. A text none of whose + characters can be drawn is refused as `text is empty`. Check the result, or + point `font` at a `.ttf`/`.otf` file that covers your script. +- **Limits.** A text whose rendering would exceed 40 megapixels at 300 dpi is + refused with a clear error (reduce the size or the length). Images are + embedded at no more than 600 dpi of their width on the page (a large photo + is downsampled), with their EXIF orientation applied; the alpha channel + of a PNG (and palette transparency) is kept. +- **Blank pages** are stamped like any other. +- **The command line never reads the GUI's saved profile**: a headless run + depends on its flags and files only. + ### Signature levels (PAdES baseline, ETSI EN 319 142-1) | Level | Adds | Network needed | @@ -190,7 +341,8 @@ python3 -m venv venv is re-opened and validated, and the *achieved* level is reported in the summary (e.g. `PAdES-B-LTA, LTV ok`). A mismatch marks the document failed — the level is **never silently downgraded**; `--no-verify` skips this check. -- **Offline behaviour**: `b-b` and `image` mode work fully offline. Levels +- **Offline behaviour**: `b-b` and `image` mode work fully offline (visual + signatures never need the network, in any mode). Levels ≥ b-t fail with an actionable error naming the unreachable endpoint (TSA, EU trusted list, OCSP/CRL). - **🔒 Privacy**: the signer's **national register number (RRN)** is embedded @@ -237,25 +389,94 @@ Cachet** link that opens the payment page in the browser. The wizard adds a stepper (completed steps green, problems red, unreached steps disabled), the current step's form on the left, contextual help on the right, and Previous/Next buttons that -name the target step. **Cancel** asks for confirmation, then resets everything -back to the welcome screen. +name the target step. **Cancel** asks for confirmation, then discards the +current session (template, files, validation, results) and returns to the +welcome screen — your saved signatures and settings are kept. The 8 steps: **1.** template (with a single PDF to sign, pick that document itself as the template) → **2.** documents → **3.** validation (pass/fail table; if some files have a **different page count** than the template, a selector appears to sign every file on its own **first or last page**) → -**4.** output folder → **5.** signature type (eID/image/**Azure** + PAdES -level selector, default `b-lta`; in Azure mode a panel offers the vault -settings and a **"Sign in with Microsoft"** action; **"Full documentation"** -opens the localized reference — modes, PAdES levels, AES vs QES, glossary — -ending with clickable links to the source standards) → **6.** placement -(actual page preview, click to place — in eID/Azure modes the vignette is -signed exactly there —, plus a manual **target page** field; when page counts -differ, the first/last selector is also shown at the top of this step and the -preview is locked onto that page) → **7.** signing (summary, a green -**"insert your eID card"** reminder in eID mode, progress) → **8.** +**4.** output folder → **5.** signature type (eID / Azure / **visual +signature (text / image)** + PAdES level selector, default `b-lta`; in Azure +mode a panel offers the vault settings and a **"Sign in with Microsoft"** +action; **"Full documentation"** opens the localized reference — modes, PAdES +levels, AES vs QES, glossary — ending with clickable links to the source +standards) → **6.** placement, a small **editor** (see below) → **7.** +signing (summary listing the vignette placement and every visual signature, a +green **"insert your eID card"** reminder in eID mode, progress) → **8.** per-document report with an **"Open output folder"** button. +**Step 6 — the element editor.** On the left, the list of **elements** stamped +on every document: in eID/Azure modes the **signature vignette** comes first +(bottom-right of the last page unless you place it), followed by your visual +signatures, each with a tick box (untick one to keep it in your library +without using it). **Add text…** opens a dialog (text on one or several lines, +font, size, colour, with a live preview and the size the text will have on +the page), **Add image…** picks a picture, **Draw…** lets you sign by hand +with the mouse or a stylus (transparent background); **Edit…** and **Delete** +act on the selected signature. On the right, the **actual page preview** shows +every element at its real size: **select an element in the list, then click +on the page** — the click sets its lower-left corner. **On every page +(initials)** repeats the selected signature on all pages, a **target page** +field moves it to another page, and **Reset position** un-places it. The step +is complete when every ticked signature is placed (the visual-signature mode +needs at least one). When page counts differ, the first/last selector is also +shown at the top of this step: every element then goes on that page, the +preview is locked onto it and "On every page" is disabled. + +### Saved profile + +The GUI saves, on every change, and reloads at the next start: + +- your **signature library** — texts (with font, colour and size), imported + images and drawings, with their name and tick box; +- each signature's **last placement** (page, position), re-applied only when + the page of the current template has exactly the size it was placed on — + otherwise the signature simply reads "not placed" and nothing is lost; +- your **settings**: language, signature mode, PAdES level, output folder, + Azure vault URL / sign-in method / trust-anchors file, and the position of + the eID/Azure vignette. + +Where it lives: + +| OS | `profile.json` | Images (`signatures/`) | +|---|---|---| +| Linux | `~/.config/Cachet/` (or `$XDG_CONFIG_HOME/Cachet/`) | `~/.local/share/Cachet/signatures/` (or `$XDG_DATA_HOME/Cachet/…`) | +| Windows | `%LOCALAPPDATA%\Cachet\` | `%LOCALAPPDATA%\Cachet\signatures\` | +| macOS | `~/Library/Application Support/Cachet/` | `~/Library/Application Support/Cachet/signatures/` | + +Set `CACHET_CONFIG_DIR` and/or `CACHET_DATA_DIR` to use other folders (the +first holds `profile.json`, the second `signatures/`). + +- Everything is stored **unencrypted** (files readable by your user account + only on Linux/macOS). **Never stored**: PIN codes, sign-in tokens or any + other credential, the Azure key name, the signed-in account, the template + and the documents. +- Imported images and drawings are **copied** into `signatures/`, so moving + or deleting the original does not break a saved signature. A custom font + file (`.ttf`/`.otf`) is only referenced by its path: if it disappears, the + signature is shown as unusable until you edit or delete it. +- **Cancel** and **Finish** keep the profile; **Delete** removes a signature + and its stored image. +- Precedence for the Azure vault URL and trust anchors: your saved choice, + then the `CACHET_AZURE_*` environment variables, then the built-in default. + A saved output folder or trust-anchors file that no longer exists is + ignored. +- The **command line never reads this profile**. + +Limits, stated plainly: an image that cannot be copied into the user folder +is not added (the reason is shown). A profile that cannot be read (damaged +file, or written by a newer version) is set aside as `profile.json.bak` and +the app starts with an empty library; if it can be neither read nor moved +(access denied, for instance) it is left untouched and nothing is saved over +it until it is readable again. After that, or when the profile was +edited by hand, image files that no signature references any more can remain +in `signatures/`: Cachet never sweeps that folder, because a backup or +another profile may still use them. **To erase everything, delete the folders +above** (a single folder on Windows and macOS; on Windows it also holds the +cached EU trusted list, which is simply downloaded again). + --- ## Standalone executables (Linux & Windows) @@ -274,14 +495,39 @@ build_windows.bat # Windows -> dist\cachet.exe , dist\cachet-cli.exe The eID middleware remains a **runtime dependency** (beid mode) and is never bundled; the page preview, for its part, works without installing anything -(PDFium bundled via pypdfium2). +(PDFium bundled via pypdfium2), and the six fonts of the text signatures are +bundled in both binaries. ## Tests -Headless `unittest` suite (no card, no tkinter): +Headless `unittest` suites (no card, no network, no display — the `Gui*` +classes skip themselves): + +```bash +env -u DISPLAY ./venv/bin/python -m unittest -v +``` + +GUI suites (they drive the real wizard, the editor dialogs and a batch +through the worker thread — the end-to-end layer of this desktop app). Run +them **one at a time**, under a virtual display when available: ```bash -./venv/bin/python -m unittest -v +CACHET_REQUIRE_GUI=1 XMODIFIERS=@im=none xvfb-run -a ./venv/bin/python -m unittest -v -k Gui +``` + +With `CACHET_REQUIRE_GUI=1`, a GUI test that cannot run (no display, broken +Tk, `gui.py` that no longer imports) **fails** instead of being skipped — a +green `-k Gui` run with skips proves nothing. Without `xvfb-run`, use a real +display (`DISPLAY=:0`). + +The tests never touch your real profile: each one works in temporary +folders. The same rule applies to **manual** runs of the GUI during +development — point it at scratch folders first, otherwise it loads and +auto-saves your own profile: + +```bash +export CACHET_CONFIG_DIR=/tmp/cachet-dev/cfg CACHET_DATA_DIR=/tmp/cachet-dev/data +./venv/bin/python sign_pdfs_beid.py --gui ``` ## Project structure @@ -289,14 +535,18 @@ Headless `unittest` suite (no card, no tkinter): | File | Role | |---|---| | `sign_pdfs_beid.py` | core + CLI entry point (business logic, importable without tkinter). | +| `stamps.py` | visual signatures: model, bundled fonts, text/drawing rendering, the `--signatures` JSON, stamping onto a PDF (importable without tkinter). | +| `profile_store.py` | the GUI's saved profile: signature library, last placements, settings, and the image store (never read by the CLI). | | `trust.py` | EU trusted-list (LOTL) trust provider: anchors for LTV in beid mode, with local cache. | | `azure_signer.py` | azure mode: Entra ID login, per-user Key Vault key/cert resolution, pyHanko signer (digest-only signing). | -| `gui.py` | CustomTkinter interface: landing page + 8-step wizard (façade over the core). | +| `gui.py` | CustomTkinter interface: landing page + 8-step wizard with the step-6 element editor (façade over the core). | | `i18n.py` | GUI localization catalog (EN/FR/NL/DE/ES/PT), light `**bold**` markup helper, documentation sections/sources. | | `i18n_docs.py` | long-form documentation of the "Full documentation" popup, six languages (merged into the catalog). | | `gui_main.py` | entry point of the windowed binary (opens the GUI). | -| `test_sign_pdfs_beid.py`, `test_trust.py`, `test_azure.py`, `test_i18n.py` | `unittest` test suites. | +| `fonts/` | the six bundled OFL fonts for text signatures; `fonts/licenses/` holds their licence texts (shipped in both binaries). | +| `test_sign_pdfs_beid.py`, `test_stamps.py`, `test_profile_store.py`, `test_trust.py`, `test_azure.py`, `test_i18n.py` | `unittest` test suites. | | `cachet.spec` | PyInstaller recipe (two binaries). | | `build_*.sh` / `build_windows.bat` | build scripts. | -| `.github/workflows/build.yml` | CI: Windows + Linux binaries as artifacts. | +| `.github/workflows/build.yml`, `release.yml` | GitHub Actions: tests (headless + GUI suites under Xvfb), Windows + Linux binaries as artifacts, and the release on `main`. | +| `.gitlab-ci.yml`, `.forgejo/workflows/tests.yml` | GitLab CI and Forgejo Actions: the same tests (no build, no release). | | `BUILD.md` | detailed packaging guide. | diff --git a/cachet.spec b/cachet.spec index 1892831..c7bde73 100644 --- a/cachet.spec +++ b/cachet.spec @@ -133,6 +133,13 @@ if _ilu.find_spec("tzdata") is not None: common_hidden += ["tzdata"] _meta("tzdata", common_datas) +# Bundled OFL fonts for text signatures (CLI + GUI). stamps.asset_path resolves +# them next to the module, or under sys._MEIPASS in the frozen binaries. The +# OFL requires the licence texts to travel with the fonts. +common_datas += [("fonts/*.ttf", "fonts"), ("fonts/licenses/*", "fonts/licenses")] +# Visual stamps: local module + Pillow's FreeType text renderer. +common_hidden += ["stamps", "PIL.ImageFont", "PIL.ImageDraw", "PIL.ImageOps", "PIL._imagingft"] + # --------------------------------------------------------------------------- # # GUI extras (only for the windowed binary) # --------------------------------------------------------------------------- # @@ -143,8 +150,9 @@ gui_hidden = list(common_hidden) # customtkinter: hook provided, but collect_all guarantees themes + fonts. _add_all("customtkinter", datas=gui_datas, binaries=gui_binaries, hidden=gui_hidden) -# The GUI's own assets: the top-bar logo (gui._asset_path resolves it under -# sys._MEIPASS in the frozen binary). GUI binary only — the CLI has no UI. +# The GUI's own assets: the top-bar logo (gui._asset_path delegates to +# stamps.asset_path, which resolves it under sys._MEIPASS in the frozen +# binary). GUI binary only — the CLI has no UI. gui_datas.append(("logo.png", ".")) gui_hidden += [ "darkdetect", @@ -152,6 +160,8 @@ gui_hidden += [ "tkinter.ttk", "tkinter.filedialog", "tkinter.messagebox", + "tkinter.colorchooser", # colour picker of the text / drawing dialogs + "profile_store", # user profile + signature image store (GUI only) "PIL.ImageTk", "PIL._tkinter_finder", "gui", # local module imported lazily by gui_main diff --git a/fonts/Caveat.ttf b/fonts/Caveat.ttf new file mode 100644 index 0000000..f84acf2 Binary files /dev/null and b/fonts/Caveat.ttf differ diff --git a/fonts/DancingScript.ttf b/fonts/DancingScript.ttf new file mode 100644 index 0000000..9f521e7 Binary files /dev/null and b/fonts/DancingScript.ttf differ diff --git a/fonts/GreatVibes-Regular.ttf b/fonts/GreatVibes-Regular.ttf new file mode 100644 index 0000000..a1327ff Binary files /dev/null and b/fonts/GreatVibes-Regular.ttf differ diff --git a/fonts/Lato-Regular.ttf b/fonts/Lato-Regular.ttf new file mode 100644 index 0000000..0f3d0f8 Binary files /dev/null and b/fonts/Lato-Regular.ttf differ diff --git a/fonts/LibreBaskerville.ttf b/fonts/LibreBaskerville.ttf new file mode 100644 index 0000000..eae4eba Binary files /dev/null and b/fonts/LibreBaskerville.ttf differ diff --git a/fonts/Sacramento-Regular.ttf b/fonts/Sacramento-Regular.ttf new file mode 100644 index 0000000..cfd2eab Binary files /dev/null and b/fonts/Sacramento-Regular.ttf differ diff --git a/fonts/licenses/OFL-caveat.txt b/fonts/licenses/OFL-caveat.txt new file mode 100644 index 0000000..6e5220c --- /dev/null +++ b/fonts/licenses/OFL-caveat.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Caveat Project Authors (https://github.com/googlefonts/caveat) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/licenses/OFL-dancingscript.txt b/fonts/licenses/OFL-dancingscript.txt new file mode 100644 index 0000000..07ff8df --- /dev/null +++ b/fonts/licenses/OFL-dancingscript.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Dancing Script Project Authors (https://github.com/googlefonts/DancingScript), with Reserved Font Name 'Dancing Script'. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/licenses/OFL-greatvibes.txt b/fonts/licenses/OFL-greatvibes.txt new file mode 100644 index 0000000..7002592 --- /dev/null +++ b/fonts/licenses/OFL-greatvibes.txt @@ -0,0 +1,93 @@ +Copyright 2015 The Great Vibes Pro Project Authors (https://github.com/googlefonts/great-vibes) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/fonts/licenses/OFL-lato.txt b/fonts/licenses/OFL-lato.txt new file mode 100644 index 0000000..98383e3 --- /dev/null +++ b/fonts/licenses/OFL-lato.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/licenses/OFL-librebaskerville.txt b/fonts/licenses/OFL-librebaskerville.txt new file mode 100644 index 0000000..92b1e6c --- /dev/null +++ b/fonts/licenses/OFL-librebaskerville.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Libre Baskerville Project Authors (https://github.com/impallari/Libre-Baskerville) with Reserved Font Name Libre Baskerville. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/licenses/OFL-sacramento.txt b/fonts/licenses/OFL-sacramento.txt new file mode 100644 index 0000000..ab66add --- /dev/null +++ b/fonts/licenses/OFL-sacramento.txt @@ -0,0 +1,93 @@ +Copyright (c) 2012, Brian J. Bonislawsky DBA Astigmatic (AOETI) (astigma@astigmatic.com), with Reserved Font Names 'Sacramento' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/gui.py b/gui.py index da6b570..fe9b457 100644 --- a/gui.py +++ b/gui.py @@ -23,10 +23,19 @@ The 8 steps: template → documents → validation → output folder → signature type (+ localized "Full documentation" popup ending with source links) → -placement (page preview + click, an explicit target-page field and — when -page counts differ — the first/last-page selector mirrored from validation) -→ signing (summary, green "insert your eID card" reminder, progress) → -results report (+ "Open output folder"). +placement (a multi-element editor: the list of elements — the crypto +vignette in beid/azure plus the user's library of visual signatures (text, +image, drawing — added and edited through three small non-blocking +dialogs), each with an enabled checkbox — beside the page preview, +which shows every element to scale; select an element, click to place it; +an explicit target-page field and — when page counts differ — the +first/last-page selector mirrored from validation) → signing (summary, +green "insert your eID card" reminder, progress) → results report (+ "Open +output folder"). + +The signature library, each element's last placement and the wizard +settings live in the user profile (`profile_store.py`): loaded at start, +saved on every change, reloaded — never wiped — by Cancel/Finish. Threading rule (do not regress): tkinter is NOT thread-safe. The batch runs on a worker thread that never touches widgets — it only pushes onto a @@ -36,19 +45,22 @@ from __future__ import annotations +import dataclasses +import datetime import os import queue -import sys import threading import webbrowser from pathlib import Path -from tkinter import filedialog, font as tkfont, ttk +from tkinter import colorchooser, filedialog, font as tkfont, ttk import customtkinter as ctk from PIL import Image, ImageTk import i18n +import profile_store import sign_pdfs_beid as core +import stamps as stamplib from i18n import tr _FRAME_MAX_W = 360 # minimum size of the page preview frame (px) @@ -78,13 +90,27 @@ _COL_CARD_BG = ("#e3f3e6", "#1e3a26") # step-7 "insert your eID card" box _COL_CARD_FG = ("#1d4d2a", "#bfe3c6") _COL_LINK = ("#1a5fb4", "#78aeed") # documentation links (light, dark) +_COL_SELECTED = ("#d6e9f8", "#27425c") # step-6 list: selected element row + +VIGNETTE_ID = "vignette" # element id of the crypto vignette; never a signature id + # (profile_store.is_valid_id only accepts 12 hex digits) +_ELEMENTS_PANEL_W = 290 # step-6 left column (element list), px +_ELEMENTS_INNER_W = 270 # width of the widgets inside that column, px +_PREVIEW_MAX_PX = 1200 # long side of a cached element preview, px +_SCALED_CACHE_MAX = 64 # entries of the per-size preview cache +_MAX_PAGE_DIGITS = 9 # target-page field: anything longer is not a page number +_TABLE_CELL_PAD = 16 # room a table cell needs around its text, px +_DRAW_W, _DRAW_H = 620, 240 # freehand dialog: drawing canvas, px +_DRAW_PEN = 3 # pen width on that canvas, px +_DRAW_SCALE = 4 # supersampling of the stored drawing +_DRAW_PT_PER_PX = 0.4 # width on the page of a drawing, per canvas px def _asset_path(name: str) -> Path: """Path of a bundled asset (e.g. the logo): next to this file in a checkout, under the PyInstaller extraction dir (``sys._MEIPASS``) in the frozen binary — cachet.spec ships it in ``gui_datas``.""" - return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) / name + return stamplib.asset_path(name) _logo_pil = None # cached PIL image; False = tried and failed @@ -128,6 +154,23 @@ def _alive(widget) -> bool: return False +def _elide(text: str, max_px: int, font) -> str: + """``text`` if it fits ``max_px`` in ``font``, else its longest prefix + that fits with a trailing "…" — a long user label never widens a panel.""" + if font.measure(text) <= max_px: + return text + for end in range(len(text) - 1, 0, -1): + if font.measure(text[:end] + "…") <= max_px: + return text[:end] + "…" + return "…" + + +def _text_on(color: str) -> str: + """Black or white, whichever reads better on a ``#RRGGBB`` background.""" + r, g, b = (int(color[i:i + 2], 16) for i in (1, 3, 5)) + return "#000000" if (299 * r + 587 * g + 114 * b) / 1000 > 140 else "#ffffff" + + def _bold_tag(box) -> None: """(Re)define the "bold" text tag on a CTkTextbox. ``CTkTextbox.tag_config`` refuses ``font`` (its scaling guard), so the tag goes on the inner @@ -164,7 +207,7 @@ def _fill_textbox(box, text: str) -> None: class CachetApp(ctk.CTk): """Main window: landing page + 8-step wizard.""" - def __init__(self, args): + def __init__(self, args, store=None): super().__init__() self._apply_title() self.geometry("1180x950") @@ -177,28 +220,48 @@ def __init__(self, args): # NOTE: only radio/option-menu widgets get tk variables — their CTk # classes detach the variable trace in destroy(). CTkEntry (5.2.2) # does NOT, so entry-backed state is kept as plain strings instead - # (self.azure_vault / self.azure_key / self.page_text) and synced via - # key bindings; a shared StringVar would fire dead-widget callbacks - # after every step rebuild. + # (self.azure_vault / self.azure_key / self.page_text, and the + # editor-dialog fields, read with .get() when the dialog is applied) + # and synced via key bindings; a shared StringVar would fire + # dead-widget callbacks after every step rebuild. self.mode_var = ctk.StringVar(value="beid") self.pades_level_var = ctk.StringVar(value="b-lta") self.azure_auth_var = ctk.StringVar(value="interactive") # GUI default self._running = False # batch in progress self._azure_login_q: queue.Queue | None = None self._result_q: queue.Queue | None = None - self._canvas_img = None # PhotoImage ref (placeholder) + self._canvas_imgs: list = [] # PhotoImage refs (elements) self._bg_img = None # PhotoImage ref (page render) self._page_img_cache: dict = {} # (template, page) -> PIL image self._last_win_size = None self._stepper_btns: list[ctk.CTkButton] = [] self.step_index = 0 self.canvas = None + # User profile: the signature library + the wizard settings, + # loaded by _reset_state() and saved on every change. Tests inject a + # store; the default one reads CACHET_CONFIG_DIR / CACHET_DATA_DIR, + # else the platform's user folders. + self.store = store if store is not None else profile_store.ProfileStore() + self.profile = profile_store.Profile() + self._loading = False # _reset_state applying the profile + self._profile_error: str | None = None # last save failure (_save_profile) + self._element_notice: str | None = None # last element operation refused + # content_key -> (PIL thumbnail, (w_pt, h_pt)), or -> the error + # message (str) of content that cannot be rendered + self._preview_cache: dict = {} + self._scaled_cache: dict = {} # (content_key, w_px, h_px) -> PIL + self._dialog = None # the single editor dialog + self._dialog_state: dict = {} + self._blank_img = None # 1×1 CTkImage: "no preview" + self.selected_id: str | None = None # VIGNETTE_ID or a signature id self._reset_state() - # Config edits invalidate a previous run + refresh the stepper. + # Config edits invalidate a previous run + refresh the stepper, and + # are persisted (never while _reset_state applies the profile). self.mode_var.trace_add("write", lambda *_: self._on_config_edit()) self.pades_level_var.trace_add("write", lambda *_: self._on_config_edit()) + self.azure_auth_var.trace_add("write", lambda *_: self._on_azure_auth_edit()) self._screen = ctk.CTkFrame(self, fg_color="transparent") self._screen.pack(fill="both", expand=True) @@ -210,42 +273,66 @@ def _apply_title(self) -> None: self.title(tr("app.title", version=core.__version__)) def _reset_state(self) -> None: - """Back to a blank wizard (fresh Start, Cancel, or Finish).""" + """Back to a blank wizard (fresh Start, Cancel, or Finish). Only the + SESSION is reset (template, files, validation, results); the + signature library and the saved settings are reloaded from the user + profile, never wiped.""" self._close_docs_popup() # it belongs to the old wizard + self._close_dialog() self.template_path: Path | None = None self.template_dims: list[tuple[float, float]] = [] self.template_error: str | None = None self.input_paths: list[Path] = [] self.validation_results = None # list[ValidationResult] | None self.valid_paths: list[Path] = [] - self.output_dir: Path | None = None - self.image_path: Path | None = None self.cur_page = 0 # 0-based, preview only - self.place_page: int | None = None # 1-based target page - self.place_x: float | None = None - self.place_y: float | None = None self.page_text = "" # target-page field content + self.selected_id = None + self._element_notice = None + self._preview_cache.clear() + self._scaled_cache.clear() # Page anchor (validation step): when some files' page count differs # from the template, `anchor_choice` decides whether EVERY file is # signed on its own first or last page (default: last, matching the # historical vignette position). Inactive while all counts match. self.anchor_choice = "last" self.count_mismatch = False - self.azure_vault = os.environ.get( - core.ENV_AZURE_VAULT_URL, "https://login.live.com") - self.azure_key = os.environ.get(core.ENV_AZURE_KEY_NAME, "") - self.azure_anchors_path: Path | None = ( - Path(p) if (p := os.environ.get(core.ENV_AZURE_TRUST_ANCHORS)) else None - ) - self.azure_user_upn: str | None = None self.run_rows: list = [] # DocResult, streamed self.run_results: list | None = None # DocResult list once done self.run_error: str | None = None # batch-level failure self._page_img_cache.clear() self.step_index = 0 - self.mode_var.set("beid") - self.pades_level_var.set("b-lta") - self.azure_auth_var.set("interactive") + # Settings precedence: saved user choice > CACHET_AZURE_* env > + # built-in default. The tk-variable traces fire below: _loading keeps + # them from writing these very values back as "user edits". + self._loading = True + try: + self.profile = self.store.load() + s = self.profile.settings + # os.path.isdir / exists, not pathlib: they are False on ANY + # error, whereas Path.is_dir() / exists() raise for an unreachable + # share, a permission problem or an over-long name — and a saved + # path must never keep the application from starting. + self.output_dir: Path | None = ( + Path(s.output_dir) if s.output_dir and os.path.isdir(s.output_dir) + else None) + self.azure_vault = s.azure_vault_url or os.environ.get( + core.ENV_AZURE_VAULT_URL, "https://login.live.com") + self.azure_key = os.environ.get(core.ENV_AZURE_KEY_NAME, "") + saved = s.azure_trust_anchors + env = os.environ.get(core.ENV_AZURE_TRUST_ANCHORS) + self.azure_anchors_path: Path | None = ( + Path(saved) if saved and os.path.exists(saved) + else (Path(env) if env else None)) + self.azure_user_upn: str | None = None + self.mode_var.set(s.mode if s.mode in ("beid", "azure", "image") else "beid") + self.pades_level_var.set( + s.pades_level if s.pades_level in core.PADES_LEVELS else "b-lta") + self.azure_auth_var.set( + s.azure_auth if s.azure_auth in core.AZURE_AUTH_METHODS + else "interactive") + finally: + self._loading = False def _invalidate_run(self) -> None: self.run_rows = [] @@ -253,11 +340,153 @@ def _invalidate_run(self) -> None: self.run_error = None def _on_config_edit(self) -> None: - if self._running: + if self._running or self._loading: return + s = self.profile.settings + s.mode = self.mode_var.get() + s.pades_level = self.pades_level_var.get() + self._save_profile() self._invalidate_run() self._refresh_chrome() + def _on_azure_auth_edit(self) -> None: + if self._loading: + return + self.profile.settings.azure_auth = self.azure_auth_var.get() + self._save_profile() + + def _save_profile(self) -> None: + """Persist the library + settings. Never raises: a failure is kept + in `_profile_error` and shown on the placement step. Main thread + only (the batch worker never calls it).""" + if self._loading: + return + try: + self.store.save(self.profile) + self._profile_error = None + except OSError as exc: + self._profile_error = str(exc) or exc.__class__.__name__ + + # ------------------------------------------- elements (read-only helpers) + def _sig(self, sig_id): + """The library signature with that id, or None.""" + for sig in self.profile.signatures: + if sig.id == sig_id: + return sig + return None + + def _enabled_signatures(self) -> list: + return [s for s in self.profile.signatures if s.enabled] + + def _has_vignette(self) -> bool: + return self.mode_var.get() in ("beid", "azure") + + def _element_ids(self) -> list[str]: + """Step-6 elements, in list order: the vignette (beid/azure), then + the whole library (enabled or not).""" + return (([VIGNETTE_ID] if self._has_vignette() else []) + + [s.id for s in self.profile.signatures]) + + def _sig_placed(self, sig) -> bool: + """True while the signature's saved placement is IN FORCE for the + current template and anchor (placements are never destroyed).""" + return profile_store.stamp_placement_applies( + sig.stamp, sig.placed_on, self.template_dims, self._page_anchor()) + + def _vignette_placed(self) -> bool: + v = self.profile.settings.vignette + return v is not None and profile_store.page_placement_applies( + v.page, v.placed_on, self.template_dims, self._page_anchor()) + + def _sample_filename(self) -> str: + """File stem the previews substitute for {filename}.""" + if self.valid_paths: + return self.valid_paths[0].stem + if self.template_path: + return self.template_path.stem + return "document" + + def _content_key(self, sig) -> tuple: + return stamplib.content_key(sig.stamp, filename=self._sample_filename(), + date=datetime.date.today()) + + def _content_entry(self, sig): + """Cached preview of a signature's content: `(thumbnail, (w_pt, + h_pt))`, or the error message (str) when it cannot be rendered. Runs + inside _refresh_chrome on every navigation, so it never raises and + never renders the same failing content twice.""" + key = self._content_key(sig) + if key not in self._preview_cache: + try: + img, size_pt = stamplib.stamp_content( + sig.stamp, filename=self._sample_filename(), + date=datetime.date.today()) + thumb = img.convert("RGBA") # a copy, whatever the mode + thumb.thumbnail((_PREVIEW_MAX_PX, _PREVIEW_MAX_PX)) + self._preview_cache[key] = (thumb, size_pt) + except Exception as exc: # noqa: BLE001 - MemoryError, bomb guard, … + self._preview_cache[key] = str(exc) or type(exc).__name__ + return self._preview_cache[key] + + def _content(self, sig): + """`(thumbnail, (w_pt, h_pt))` of a signature, or None when unusable.""" + entry = self._content_entry(sig) + return None if isinstance(entry, str) else entry + + def _sig_error(self, sig) -> str | None: + """Why a signature cannot be used (missing image/font, …), or None.""" + entry = self._content_entry(sig) + return entry if isinstance(entry, str) else None + + def _element_size_pt(self, element_id) -> tuple[float, float]: + """Size (pt) of an element on the previewed page: a 3:1 vignette box + of width page/5, or the signature's real content size.""" + if element_id == VIGNETTE_ID: + return core.vignette_size_pt(self.template_dims[self.cur_page][0]) + sig = self._sig(element_id) + content = self._content(sig) if sig else None + return content[1] if content else (150.0, 50.0) + + def _element_page(self, element_id) -> int | None: + """1-based TEMPLATE page an element sits on when its placement is in + force on one single page, else None (unplaced, every page, unknown).""" + if element_id == VIGNETTE_ID: + return self.profile.settings.vignette.page if self._vignette_placed() else None + sig = self._sig(element_id) + if sig is None or not self._sig_placed(sig) or sig.stamp.all_pages: + return None + if sig.stamp.page_anchor is not None: + return 1 if sig.stamp.page_anchor == "first" else len(self.template_dims) + return sig.stamp.page + + def _ensure_selection(self) -> bool: + """Re-pick the selected element when the current one no longer + exists (mode change, deletion, fresh wizard). True if it re-picked.""" + if self.selected_id in self._element_ids(): + return False + if self._has_vignette(): + self.selected_id = VIGNETTE_ID + else: + sigs = self.profile.signatures + todo = [s for s in sigs if s.enabled and not self._sig_placed(s)] + first = todo[0] if todo else (sigs[0] if sigs else None) + self.selected_id = first.id if first else None + self._sync_page_to_selection() + return True + + def _sync_page_to_selection(self) -> None: + """Target-page field + preview page follow the selected element. No + drawing. The ONLY place that derives `page_text` from an element.""" + if self._page_anchor(): + self._set_page_entry("") # field disabled; cur_page is locked by _draw_page + return + page = self._element_page(self.selected_id) + if page is None: + self._set_page_entry("") # unplaced / every page / nothing selected + else: + self.cur_page = page - 1 + self._set_page_entry(str(page)) + # ============================================================= CHROME def _build_top_bar(self, parent, *, lang_command): """Top bar shared by the landing page and the wizard: the brand @@ -297,6 +526,7 @@ def _build_top_bar(self, parent, *, lang_command): # ============================================================ LANDING def _clear_screen(self) -> None: + self._close_dialog() for w in self._screen.winfo_children(): w.destroy() self._stepper_btns = [] @@ -336,19 +566,27 @@ def _language_code(display_name: str) -> str | None: return code return None + def _persist_language(self, code: str) -> None: + if self.profile.settings.language != code: + self.profile.settings.language = code + self._save_profile() + def _on_language_change(self, display_name: str) -> None: code = self._language_code(display_name) if code: i18n.set_language(code) + self._persist_language(code) self._apply_title() self._close_docs_popup() # its text is language-bound + self._close_dialog() self._show_landing() # re-render the landing texts def _on_wizard_language_change(self, display_name: str) -> None: """Language switch from inside the wizard. Widgets are disposable and - all state lives on the app, so the chrome and the current step are - simply rebuilt in place — nothing the user entered is lost. Ignored - while a batch runs (the menu is disabled then anyway).""" + all state lives on the app (the library and the placements on the + profile), so the chrome and the current step are simply rebuilt in + place — nothing the user entered is lost. Ignored while a batch runs + (the menu is disabled then anyway).""" code = self._language_code(display_name) if self._running or code is None: return @@ -356,6 +594,8 @@ def _on_wizard_language_change(self, display_name: str) -> None: i18n.set_language(code) self._apply_title() self._close_docs_popup() # its text is language-bound + self._persist_language(code) + self._close_dialog() # language-bound too step = self.step_index self._build_wizard() self._goto_step(step) # the current step is always re-enterable @@ -435,6 +675,7 @@ def _first_incomplete(self) -> int: return len(_STEP_KEYS) def _goto_step(self, idx: int) -> None: + self._close_dialog() # it edits the step being left if self._running or not self._stepper_btns: return idx = max(0, min(len(_STEP_KEYS) - 1, idx)) @@ -474,32 +715,45 @@ def _finish(self) -> None: def _cancel_wizard(self) -> None: if self._running: return + + def do_cancel(): + self._reset_state() + self._show_landing() + + self._confirm_modal(tr("cancel.title"), tr("cancel.body"), + tr("cancel.confirm"), tr("cancel.keep"), + on_confirm=do_cancel) + + def _confirm_modal(self, title, body, confirm_text, keep_text, on_confirm): + """Small confirmation window (Cancel wizard, Delete signature): the + red button destroys it then calls ``on_confirm``; the other one just + closes it. The button row stays the window's LAST child.""" win = ctk.CTkToplevel(self) - win.title(tr("cancel.title")) + win.title(title) win.geometry("480x190") win.transient(self) win.resizable(False, False) - ctk.CTkLabel(win, text=tr("cancel.title"), + ctk.CTkLabel(win, text=title, font=ctk.CTkFont(size=15, weight="bold") ).pack(anchor="w", padx=18, pady=(16, 4)) - ctk.CTkLabel(win, text=tr("cancel.body"), justify="left", + ctk.CTkLabel(win, text=body, justify="left", wraplength=440).pack(anchor="w", padx=18) row = ctk.CTkFrame(win, fg_color="transparent") row.pack(side="bottom", fill="x", padx=18, pady=14) - def do_cancel(): + def do_confirm(): win.destroy() - self._reset_state() - self._show_landing() + on_confirm() - ctk.CTkButton(row, text=tr("cancel.confirm"), width=150, + ctk.CTkButton(row, text=confirm_text, width=150, fg_color=_COL_ERROR, hover_color=("#c96a6a", "#8a4444"), - command=do_cancel).pack(side="right", padx=(8, 0)) - ctk.CTkButton(row, text=tr("cancel.keep"), width=150, + command=do_confirm).pack(side="right", padx=(8, 0)) + ctk.CTkButton(row, text=keep_text, width=150, fg_color="transparent", border_width=1, text_color=("gray20", "gray80"), command=win.destroy).pack(side="right") win.after(80, lambda: _alive(win) and win.grab_set()) + return win # ------------------------------------------- completion / error state def _mode_error(self) -> str | None: @@ -513,20 +767,38 @@ def _mode_error(self) -> str | None: return None def _page_entry_error(self) -> str | None: - if self._page_anchor(): + if not self.template_dims or self._page_anchor(): return None # field disabled: the anchor fixes the page raw = self.page_text.strip() - if raw and (not raw.isdigit() or int(raw) < 1): + if not raw: + return None + # len: int() raises on a pasted number of several thousand digits + if not raw.isdecimal() or len(raw) > _MAX_PAGE_DIGITS or int(raw) < 1: return tr("place.page_invalid") + if int(raw) > len(self.template_dims): + # blocking: it could only give "page out of range" for + # every accepted document + return tr("place.page_beyond", page=int(raw), + total=len(self.template_dims)) return None def _place_error(self) -> str | None: + """First problem of the placement step, or None. beid/azure with no + enabled signature is complete without a click (default vignette); + disabled signatures never block.""" err = self._page_entry_error() if err: return err - if self.mode_var.get() == "image" and ( - self.image_path is None or self.place_x is None): - return tr("place.image_missing") + enabled = self._enabled_signatures() + for sig in enabled: + broken = self._sig_error(sig) + if broken: + return tr("place.el_broken", name=sig.label, error=broken) + if self.mode_var.get() == "image" and not enabled: + return tr("place.need_signature") + for sig in enabled: + if not self._sig_placed(sig): + return tr("place.unplaced", name=sig.label) return None def _step_complete(self, idx: int) -> bool: @@ -626,19 +898,41 @@ def _refresh_chrome(self) -> None: # ------------------------------------------------------------ tables def _make_table(self, parent, columns: list[tuple[str, int]], height: int): - """ttk table; ``columns`` = [(localized heading, width px), …].""" + """ttk table; ``columns`` = [(localized heading, width px), …]. The + last column takes the remaining width (it follows the window); a + horizontal scrollbar shows up while its text is wider than that + (see ``_fill_table``).""" names = [c[0] for c in columns] table = ttk.Treeview(parent, columns=names, show="headings", height=height) for name, width in columns: table.heading(name, text=name) - table.column(name, width=width, anchor="w") + table.column(name, width=width, anchor="w", stretch=False) + table.column(names[-1], stretch=True) + table.heading(names[-1], anchor="w") # stays in view when scrolled to table.pack(fill="x", pady=4, padx=8) + xbar = ttk.Scrollbar(parent, orient="horizontal", command=table.xview) + + def on_xscroll(first, last) -> None: + xbar.set(first, last) + if float(first) <= 0 and float(last) >= 1: # everything is visible + xbar.pack_forget() + else: + xbar.pack(fill="x", padx=8, after=table) + + table.configure(xscrollcommand=on_xscroll) return table @staticmethod - def _clear(table) -> None: - for item in table.get_children(): - table.delete(item) + def _fill_table(table, rows) -> None: + """Replace the rows of a table. Its last column is never narrower + than its longest text: what the window cannot show is scrolled to.""" + table.delete(*table.get_children()) + for row in rows: + table.insert("", "end", values=row) + font = tkfont.Font(font=ttk.Style().lookup("Treeview", "font")) + need = max((font.measure(row[-1]) for row in rows), default=0) + _TABLE_CELL_PAD + # width too: ttk then re-fits the column to the space that is left + table.column(table["columns"][-1], width=need, minwidth=need) # ===================================================== step 1: template def _build_step_template(self, parent) -> None: @@ -676,11 +970,17 @@ def _pick_template(self) -> None: except Exception as exc: # noqa: BLE001 self.template_dims = [] self.template_error = str(exc) - # A new template invalidates validation, placement and results. + # A new template invalidates validation and results. No saved + # placement is destroyed: each one is in force again whenever the + # template page has the size it was placed on (_sig_placed). self.validation_results = None self.valid_paths = [] self.cur_page = 0 - self._reset_position() + self.page_text = "" # a stale page number would block step 6 + self.selected_id = None + self._element_notice = None + self._preview_cache.clear() # {filename} sample may change + self._scaled_cache.clear() self._invalidate_run() self._update_template_label() self._refresh_chrome() @@ -805,30 +1105,26 @@ def count_differs(p) -> bool: def _sync_anchor_page(self) -> None: """Lock the placement preview onto the template page that will carry - the signature (first/last) while the anchor selector applies; a - position clicked on another page no longer matches, so it is dropped. - The manual target page is cleared too — `page` and `page_anchor` are - mutually exclusive.""" + the elements (first/last) while the anchor selector applies. The + manual target page is cleared — `page` and `page_anchor` are mutually + exclusive. No position is dropped: an element placed on another + template page simply reads "not placed" under this anchor (the + placement predicates take the anchor) and comes back with it.""" anchor = self._page_anchor() if not anchor or not self.template_dims: return - target = 0 if anchor == "first" else len(self.template_dims) - 1 - self.cur_page = target - if self.place_page is not None and self.place_page != target + 1: - self.place_page = self.place_x = self.place_y = None + self.cur_page = 0 if anchor == "first" else len(self.template_dims) - 1 self._set_page_entry("") + self._refresh_elements() self._update_place_labels() self._draw_page() def _update_validation_widgets(self) -> None: if _alive(getattr(self, "valid_table", None)): - self._clear(self.valid_table) - for r in self.validation_results or []: - self.valid_table.insert( - "", "end", - values=(r.path.name, - tr("val.ok") if r.ok else tr("val.rejected"), - r.reason or "—")) + self._fill_table(self.valid_table, [ + (r.path.name, tr("val.ok") if r.ok else tr("val.rejected"), + r.reason or "—") + for r in self.validation_results or []]) if _alive(getattr(self, "val_status_lbl", None)): if self.validation_results is None: self.val_status_lbl.configure(text="") @@ -861,6 +1157,8 @@ def _pick_output(self) -> None: if not path: return self.output_dir = Path(path) + self.profile.settings.output_dir = str(self.output_dir) + self._save_profile() self._invalidate_run() if _alive(getattr(self, "output_lbl", None)): self.output_lbl.configure(text=str(self.output_dir)) @@ -886,6 +1184,7 @@ def hint(container, key, **pack_kw): value=value, command=self._on_mode_change ).pack(anchor="w", padx=8, pady=(8, 0)) hint(box, hint_key, pady=(2, 4)) + hint(box, "mode.stamps_hint", pady=(6, 4)) lrow = ctk.CTkFrame(box, fg_color="transparent") lrow.pack(fill="x", padx=8, pady=(8, 0)) @@ -955,9 +1254,14 @@ def _on_mode_change(self) -> None: def _on_azure_entry_edit(self) -> None: if _alive(getattr(self, "azure_vault_entry", None)): - self.azure_vault = self.azure_vault_entry.get() + new = self.azure_vault_entry.get() + # Persist only a real edit: a mere focus-out must not turn + # the env/default value into a saved user choice. + if new != self.azure_vault: + self.azure_vault = new + self.profile.settings.azure_vault_url = new.strip() or None if _alive(getattr(self, "azure_key_entry", None)): - self.azure_key = self.azure_key_entry.get() + self.azure_key = self.azure_key_entry.get() # never persisted self._on_config_edit() def _refresh_azure_visibility(self) -> None: @@ -1019,6 +1323,8 @@ def _pick_azure_anchors(self) -> None: if not path: return self.azure_anchors_path = Path(path) + self.profile.settings.azure_trust_anchors = path + self._save_profile() if _alive(getattr(self, "azure_anchors_lbl", None)): self.azure_anchors_lbl.configure(text=path) self._invalidate_run() @@ -1104,16 +1410,9 @@ def _build_step_place(self, parent) -> None: font=ctk.CTkFont(size=11)).pack(fill="x") self._update_place_anchor_widgets() - self.image_row = ctk.CTkFrame(parent, fg_color="transparent") - if self.mode_var.get() == "image": # image picker: image mode only - self.image_row.pack(fill="x", pady=(10, 0), padx=8) - ctk.CTkButton(self.image_row, text=tr("place.image_choose"), width=200, - command=self._pick_image).pack(side="left") - self.image_lbl = ctk.CTkLabel( - self.image_row, - text=self.image_path.name if self.image_path else tr("common.none")) - self.image_lbl.pack(side="left", padx=12) - + # Page navigation, at FULL width above the editor: it needs up to + # ~650 px in the longest language, more than the preview column has + # beside the element list. nav = ctk.CTkFrame(parent, fg_color="transparent") nav.pack(fill="x", pady=6, padx=8) ctk.CTkLabel(nav, text=tr("place.page")).pack(side="left") @@ -1121,12 +1420,6 @@ def _build_step_place(self, parent) -> None: self.page_entry.insert(0, self.page_text) self.page_entry.bind("", lambda _e: self._on_page_entry_change()) self.page_entry.pack(side="left", padx=(6, 16)) - # packed before the page buttons so it keeps its space when the - # locked-preview label makes the row tight - ctk.CTkButton(nav, text=tr("place.reset"), width=150, - fg_color="transparent", border_width=1, - text_color=("gray20", "gray80"), - command=self._reset_position).pack(side="right") self.page_prev_btn = ctk.CTkButton(nav, text=tr("place.prev"), width=140, command=lambda: self._turn_page(-1)) self.page_prev_btn.pack(side="left") @@ -1145,23 +1438,83 @@ def _build_step_place(self, parent) -> None: self.page_prev_btn.pack_forget() self.page_next_btn.pack_forget() - info = ctk.CTkFrame(parent, fg_color="transparent") - info.pack(fill="x", padx=8) - self.pos_lbl = ctk.CTkLabel(info, text="", anchor="w", justify="left", - wraplength=380) - self.pos_lbl.pack(side="left") - self.place_warn_lbl = ctk.CTkLabel(info, text="", anchor="w", justify="left", - wraplength=300, + # Editor: the element list on the left, the page preview on the right. + self.editor_row = ctk.CTkFrame(parent, fg_color="transparent") + self.editor_row.pack(fill="x", padx=8, pady=(4, 0)) + + # Left column. Its width is bounded by its children: each one is at + # most _ELEMENTS_INNER_W wide (a CTkButton GROWS with its label, so + # the add buttons are stacked, never side by side). + panel = self.elements_panel = ctk.CTkFrame(self.editor_row) + panel.pack(side="left", anchor="n", padx=(0, 10)) + ctk.CTkLabel(panel, text=tr("place.elements_title"), anchor="w", + font=ctk.CTkFont(size=13, weight="bold") + ).pack(anchor="w", padx=10, pady=(8, 2)) + self.add_text_btn = ctk.CTkButton( + panel, text=tr("sig.add_text"), width=_ELEMENTS_INNER_W, + command=self._open_text_dialog) + self.add_image_btn = ctk.CTkButton( + panel, text=tr("sig.add_image"), width=_ELEMENTS_INNER_W, + command=self._pick_image_signature) + self.draw_btn = ctk.CTkButton( + panel, text=tr("sig.draw"), width=_ELEMENTS_INNER_W, + command=self._open_draw_dialog) + for btn in (self.add_text_btn, self.add_image_btn, self.draw_btn): + btn.pack(padx=10, pady=2) + # A plain frame, not a CTkScrollableFrame (nesting one inside the + # step's own scrollable area double-scrolls). _refresh_elements() + # always leaves a child in it: an empty CTkFrame keeps 200×200. + self.elements_list = ctk.CTkFrame(panel, fg_color="transparent", height=1) + self.elements_list.pack(fill="x", padx=10, pady=(8, 4)) + actions = ctk.CTkFrame(panel, fg_color="transparent") + actions.pack(anchor="w", padx=10, pady=2) + self.edit_btn = ctk.CTkButton(actions, text=tr("sig.edit"), width=130, + command=self._edit_selected) + self.edit_btn.pack(side="left", padx=(0, 6)) + self.delete_btn = ctk.CTkButton( + actions, text=tr("sig.delete"), width=130, + fg_color="transparent", border_width=1, + text_color=("gray20", "gray80"), command=self._delete_selected) + self.delete_btn.pack(side="left") + self.all_pages_chk = ctk.CTkCheckBox( + panel, text=tr("place.all_pages"), command=self._toggle_all_pages) + self.all_pages_chk.pack(anchor="w", padx=10, pady=(8, 4)) + self.reset_btn = ctk.CTkButton( + panel, text=tr("place.reset"), width=_ELEMENTS_INNER_W, + fg_color="transparent", border_width=1, + text_color=("gray20", "gray80"), + command=self._reset_selected_position) + self.reset_btn.pack(padx=10, pady=2) + ctk.CTkLabel(panel, text=tr("place.storage_note"), justify="left", + anchor="w", wraplength=_ELEMENTS_INNER_W, + text_color=("gray25", "gray70"), + font=ctk.CTkFont(size=11) + ).pack(anchor="w", padx=10, pady=(6, 8)) + + # Right column: position of the selected element, problems, preview. + self.preview_col = ctk.CTkFrame(self.editor_row, fg_color="transparent") + self.preview_col.pack(side="left", anchor="n") + self.pos_lbl = ctk.CTkLabel(self.preview_col, text="", anchor="w", + justify="left", wraplength=320) + self.pos_lbl.pack(anchor="w") + self.place_warn_lbl = ctk.CTkLabel(self.preview_col, text="", anchor="w", + justify="left", wraplength=320, text_color=("#b3261e", "#e08a8a")) - self.place_warn_lbl.pack(side="left", padx=16) + self.place_warn_lbl.pack(anchor="w") - # tkinter Canvas: background = rendered template page, click = position. + # tkinter Canvas: background = rendered template page; a click places + # the SELECTED element (selection is made in the list only). import tkinter as tk - self.canvas = tk.Canvas(parent, width=_FRAME_MAX_W, height=_FRAME_MAX_H, - bg="#d9d9d9", highlightthickness=1, - highlightbackground="#888") - self.canvas.pack(pady=6, padx=8) + self.canvas = tk.Canvas(self.preview_col, width=_FRAME_MAX_W, + height=_FRAME_MAX_H, bg="#d9d9d9", + highlightthickness=1, highlightbackground="#888") + self.canvas.pack(pady=6) self.canvas.bind("", self._on_canvas_click) + # Before the first draw: a restored element is shown on its own page, + # with its page number in the field. + self._ensure_selection() + self._sync_page_to_selection() + self._refresh_elements() self._update_place_labels() self._draw_page() @@ -1175,20 +1528,642 @@ def _update_place_anchor_widgets(self) -> None: "place.anchor_status", ok=len(self.valid_paths), total=len(self.validation_results or []))) - def _pick_image(self) -> None: - path = filedialog.askopenfilename( - title=tr("place.image_choose"), - filetypes=[("Images", "*.png *.jpg *.jpeg *.gif *.bmp")]) - if not path: + # ---------------------------------------------------- step 6: element list + def _element_status(self, element_id) -> str: + """Second line of an element's row: where it stands.""" + anchor = self._page_anchor() + if element_id == VIGNETTE_ID: + if self._vignette_placed(): + return tr("place.st_page", page=self.profile.settings.vignette.page) + return tr("place.st_default") + sig = self._sig(element_id) + if self._sig_error(sig): + return tr("place.st_error") + if not self._sig_placed(sig): + return tr("place.st_unplaced") + if anchor: + return tr(f"anchor.{anchor}_page") + if sig.stamp.all_pages: + return tr("place.st_all") + return tr("place.st_page", page=self._element_page(element_id)) + + def _refresh_elements(self) -> None: + """Rebuild the element rows (vignette + library) and sync the action + widgets with the selection.""" + if not _alive(getattr(self, "elements_list", None)): return - self.image_path = Path(path) - if _alive(getattr(self, "image_lbl", None)): - self.image_lbl.configure(text=self.image_path.name) - self._invalidate_run() + for w in self.elements_list.winfo_children(): + w.destroy() + self._element_rows: dict = {} # id -> row frame + self._element_labels: dict = {} # id -> (name label, status label) + self._element_checks: dict = {} # signature id -> enabled checkbox + ids = self._element_ids() + if not ids: # image mode, empty library + ctk.CTkLabel(self.elements_list, text=tr("place.list_empty"), + justify="left", anchor="w", + wraplength=_ELEMENTS_INNER_W, + text_color=("gray25", "gray70")).pack(anchor="w") + name_font = ctk.CTkFont(size=13) + for eid in ids: + sig = self._sig(eid) + row = ctk.CTkFrame( + self.elements_list, + fg_color=_COL_SELECTED if eid == self.selected_id else "transparent") + row.pack(fill="x", pady=1) + if sig is not None: + chk = ctk.CTkCheckBox(row, text="", width=24, + command=lambda i=eid: self._toggle_enabled(i)) + if sig.enabled: + chk.select() + chk.pack(side="left", padx=(4, 0)) + self._element_checks[eid] = chk + else: # the vignette cannot be disabled + ctk.CTkFrame(row, fg_color="transparent", width=24, height=24 + ).pack(side="left", padx=(4, 0)) + # Two stacked labels, not a two-line button: CTkButton has no + # `justify` and centres the second line. + col = ctk.CTkFrame(row, fg_color="transparent") + col.pack(side="left", fill="x", expand=True, padx=(4, 0)) + name = ctk.CTkLabel( + col, anchor="w", cursor="hand2", font=name_font, + text=_elide(tr("place.el_vignette") if sig is None else sig.label, + _ELEMENTS_INNER_W - 50, name_font)) + name.pack(anchor="w") + status = ctk.CTkLabel(col, anchor="w", cursor="hand2", height=16, + text=self._element_status(eid), + text_color="gray50", font=ctk.CTkFont(size=11)) + status.pack(anchor="w") + for w in (row, col, name, status): + w.bind("", lambda _e, i=eid: self._select(i)) + self._element_rows[eid] = row + self._element_labels[eid] = (name, status) + + sig = self._sig(self.selected_id) + anchor = self._page_anchor() + for btn in (self.edit_btn, self.delete_btn): + btn.configure(state="normal" if sig else "disabled") + self.reset_btn.configure( + state="normal" if self.selected_id in ids else "disabled") + # select()/deselect() never call `command` and work while disabled + if sig and sig.stamp.all_pages: + self.all_pages_chk.select() + else: + self.all_pages_chk.deselect() + self.all_pages_chk.configure( + state="normal" if sig and not anchor else "disabled") + + def _refresh_place(self) -> None: + """List, labels, preview and stepper after an element changed. Every + part is a no-op while step 6 is not built.""" + self._refresh_elements() self._update_place_labels() self._draw_page() self._refresh_chrome() + def _commit_elements(self) -> None: + """End of every library/placement mutation: persist, drop the + results of a previous run, refresh the step.""" + self._save_profile() + self._invalidate_run() + self._refresh_place() + + # ----------------------------------------------- step 6: element operations + def _select(self, element_id) -> None: + self._element_notice = None + self.selected_id = element_id + self._sync_page_to_selection() # preview + page field follow it + self._refresh_place() + + def _add_signature(self, stamp, label: str): + """Append a signature to the library and select it. Safe to call + while step 6 is not built.""" + self._element_notice = None + sig = profile_store.Signature(id=profile_store.new_id(), + label=label[:profile_store.MAX_LABEL_LEN], stamp=stamp) + self.profile.signatures.append(sig) + self.selected_id = sig.id + self._sync_page_to_selection() + self._commit_elements() + return sig + + def _add_image_signature(self, path, label=None): + """Copy an image into the user's signature store and add it as an + image signature. When it cannot be read or stored NOTHING is added + (no fallback to the original file) and the reason is shown.""" + self._element_notice = None + try: + stored = self.store.import_image(Path(path)) + except stamplib.StampError as exc: # not an image + self._element_notice = tr("sig.err_image", error=exc) + except OSError as exc: # the data dir cannot be written + self._element_notice = tr("sig.err_store", error=exc) + else: + return self._add_signature( + stamplib.Stamp(kind="image", image_path=stored), + label or Path(path).stem) + self._update_place_labels() + return None + + def _pick_image_signature(self) -> None: + path = filedialog.askopenfilename( + title=tr("sig.add_image"), + filetypes=[("Images", "*.png *.jpg *.jpeg *.gif *.bmp *.webp")]) + if path: + self._add_image_signature(path) + + def _toggle_enabled(self, sig_id) -> None: + sig = self._sig(sig_id) + if sig is None: + return + self._element_notice = None + sig.enabled = not sig.enabled # the model decides; the list re-syncs + self._commit_elements() + + def _toggle_all_pages(self) -> None: + """"On every page" for the selected signature. The direction comes + from the MODEL, never from the checkbox (the list re-syncs it).""" + sig = self._sig(self.selected_id) + if sig is None or self._page_anchor(): + return + self._element_notice = None + if not sig.stamp.all_pages: + sig.stamp = dataclasses.replace(sig.stamp, all_pages=True, + page=None, page_anchor=None) + else: # back onto the previewed page + # _sig_placed, not "has coordinates": a placement saved for + # another page size must stay unplaced until the user clicks. + placed = self._sig_placed(sig) + sig.stamp = dataclasses.replace( + sig.stamp, all_pages=False, + page=(self.cur_page + 1) if placed else None) + if placed: + sig.placed_on = tuple(self.template_dims[self.cur_page]) + self._sync_page_to_selection() + self._commit_elements() + + def _reset_selected_position(self) -> None: + """Forget the selected element's position (the vignette goes back to + its default corner; a signature keeps its "every page" flag).""" + self._element_notice = None + if self.selected_id == VIGNETTE_ID: + self.profile.settings.vignette = None + else: + sig = self._sig(self.selected_id) + if sig is None: + return + sig.stamp = dataclasses.replace(sig.stamp, page=None, page_anchor=None, + x=None, y=None) + sig.placed_on = None + self._sync_page_to_selection() # empties the field + self._commit_elements() + + def _edit_selected(self) -> None: + sig = self._sig(self.selected_id) + if sig is None: + return + if sig.stamp.kind == "text": + self._open_text_dialog(sig) + else: + self._open_image_dialog(sig) + + def _delete_selected(self) -> None: + sig = self._sig(self.selected_id) + if sig is None: + return + self._confirm_modal( + tr("sig.delete_title"), tr("sig.delete_body", name=sig.label), + tr("sig.delete_confirm"), tr("sig.delete_keep"), + on_confirm=lambda: self._delete_signature(sig.id)) + + def _delete_signature(self, sig_id) -> None: + """Remove a signature from the library, with its stored image when + no other signature uses it (profile_store decides).""" + self._element_notice = None + self.store.remove_signature(self.profile, sig_id) + self._preview_cache.clear() + self._scaled_cache.clear() + self.selected_id = None + self._ensure_selection() # re-picks + re-syncs the page field + self._commit_elements() + + def _close_dialog(self) -> None: + """Close the editor dialog, if any (step change, reset, language).""" + if _alive(self._dialog): + self._dialog.destroy() + self._dialog = None + self._dialog_state = {} + + # ------------------------------------------------- step 6: editor dialogs + # One non-blocking window at a time (self._dialog), closed by any step + # change, reset or language switch. Its fields are plain widgets read + # with .get() — no tk variable (see __init__); the non-widget values + # (font, colour, strokes) live in self._dialog_state. + def _open_dialog(self, title: str, geometry: str): + """Open the editor dialog (any previous one is closed first). No + wait_window: the handlers do the work, so tests can drive them.""" + self._close_dialog() + win = ctk.CTkToplevel(self) + win.title(title) + win.geometry(geometry) + win.transient(self) + win.resizable(False, False) + win.protocol("WM_DELETE_WINDOW", self._close_dialog) # same as Cancel + self._dialog = win + win.after(80, lambda: _alive(win) and win.grab_set()) + return win + + def _dialog_footer(self, win, on_save, *, wrap: int) -> None: + """Last row of every dialog (Cancel / Save) with the red error label + above it. Packed FIRST, from the bottom: whatever the content needs, + the buttons stay inside the fixed-size window.""" + row = ctk.CTkFrame(win, fg_color="transparent") + row.pack(side="bottom", fill="x", padx=18, pady=(4, 14)) + self.dlg_save_btn = ctk.CTkButton(row, text=tr("sig.save"), width=150, + command=on_save) + self.dlg_save_btn.pack(side="right", padx=(8, 0)) + self.dlg_cancel_btn = ctk.CTkButton( + row, text=tr("sig.cancel"), width=150, + fg_color="transparent", border_width=1, + text_color=("gray20", "gray80"), command=self._close_dialog) + self.dlg_cancel_btn.pack(side="right") + self.dlg_error_lbl = ctk.CTkLabel(win, text="", anchor="w", justify="left", + wraplength=wrap, + text_color=("#b3261e", "#e08a8a")) + self.dlg_error_lbl.pack(side="bottom", fill="x", padx=18) + + def _dialog_error(self, message: str) -> None: + if _alive(getattr(self, "dlg_error_lbl", None)): + self.dlg_error_lbl.configure(text=message) + + @staticmethod + def _dialog_number(raw: str, maximum: float) -> float | None: + """A dialog field as a number in (0, maximum] (decimal comma + accepted), or None.""" + try: + value = float(raw.strip().replace(",", ".")) + except ValueError: + return None + return value if 0 < value <= maximum else None # (NaN fails both) + + def _blank_ctk_image(self): + """1×1 transparent image standing for "no preview", created once per + app (a CTkImage binds to the root that first renders it).""" + if self._blank_img is None: + pil = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) + self._blank_img = ctk.CTkImage(light_image=pil, dark_image=pil, size=(1, 1)) + return self._blank_img + + def _preview_box(self, win, height: int): + """White box holding the dialog's preview label (starts blank).""" + frame = ctk.CTkFrame(win, height=height, fg_color="white") + frame.pack(fill="x", padx=18) + frame.pack_propagate(False) # fixed height, whatever it shows + self.dlg_preview_lbl = ctk.CTkLabel(frame, text="") + self.dlg_preview_lbl.pack(expand=True) + self._set_dialog_preview(None) + + def _set_dialog_preview(self, img, fit=(1, 1), max_zoom: float = 1.0) -> None: + """Show a PIL image in the dialog's preview label, scaled down to fit + ``fit`` and never enlarged beyond ``max_zoom``; None shows nothing. + Each preview is a FRESH CTkImage pinned on the label, and "nothing" + is the blank image — never ``image=None``: with it CustomTkinter + 5.2.2 leaves the previous picture on screen, and once its last + reference is gone the next configure raises TclError.""" + if img is None: + shown = self._blank_ctk_image() + else: + zoom = min(fit[0] / img.width, fit[1] / img.height, max_zoom) + size = (max(1, round(img.width * zoom)), max(1, round(img.height * zoom))) + small = img.copy() + small.thumbnail((size[0] * 2, size[1] * 2)) # HiDPI headroom, cheap rescale + shown = ctk.CTkImage(light_image=small, dark_image=small, size=size) + self.dlg_preview_lbl.configure(image=shown) + self.dlg_preview_lbl._cachet_img = shown # noqa: SLF001 - keep-alive ref + + def _paint_color_button(self) -> None: + color = self._dialog_state["color"] + self.dlg_color_btn.configure(text=color, fg_color=color, hover_color=color, + text_color=_text_on(color)) + + def _pick_dialog_color(self) -> None: + """Colour of the text / of the drawing, through the native chooser.""" + state = self._dialog_state + _rgb, hexv = colorchooser.askcolor( + color=state["color"], parent=self._dialog, title=tr("sig.color_title")) + if not _alive(self._dialog) or not hexv: # closed meanwhile, or cancelled + return + state["color"] = hexv.upper() + self._paint_color_button() + if state["kind"] == "draw": # the strokes already on screen + self.dlg_draw_canvas.itemconfigure("all", fill=state["color"]) + else: + self._update_text_preview() + + # --- text signature (add / edit) + @staticmethod + def _font_display(font: str) -> str: + """Menu text of a font: a bundled font's name, a custom file's name.""" + bundled = stamplib.BUNDLED_FONTS.get(font.strip().lower()) + return bundled[0] if bundled else Path(font).name + + def _open_text_dialog(self, sig=None) -> None: + """Add (``sig`` None) or edit a text signature: text, font, size and + colour, with a live preview rendered by the stamping code itself.""" + win = self._open_dialog( + tr("sig.text_title_edit" if sig else "sig.text_title_add"), "560x600") + stamp = sig.stamp if sig else stamplib.Stamp(kind="text") + self._dialog_state = {"kind": "text", "sig_id": sig.id if sig else None, + "font": stamp.font, "color": stamp.color.upper()} + self._dialog_footer(win, self._apply_text_dialog, wrap=520) + gray = {"text_color": ("gray25", "gray70"), "font": ctk.CTkFont(size=11)} + + ctk.CTkLabel(win, text=tr("sig.name")).pack(anchor="w", padx=18, pady=(12, 0)) + self.dlg_name_entry = ctk.CTkEntry(win) + if sig: + self.dlg_name_entry.insert(0, sig.label) + self.dlg_name_entry.pack(fill="x", padx=18) + ctk.CTkLabel(win, text=tr("sig.text")).pack(anchor="w", padx=18, pady=(6, 0)) + # wrap="none": the stamp breaks lines only at real newlines, so the + # box must not wrap visually (a long line would look like several + # and be stamped as one, wider than the page). It scrolls instead. + self.dlg_text_box = ctk.CTkTextbox(win, height=90, wrap="none") + self.dlg_text_box.insert("1.0", stamp.text) + self.dlg_text_box.pack(fill="x", padx=18) + # no kwargs: {date} and {filename} are shown literally + ctk.CTkLabel(win, text=tr("sig.text_hint"), justify="left", anchor="w", + wraplength=520, **gray).pack(anchor="w", padx=18, pady=(2, 4)) + + row = ctk.CTkFrame(win, fg_color="transparent") + row.pack(fill="x", padx=18, pady=2) + ctk.CTkLabel(row, text=tr("sig.font")).pack(side="left") + self.dlg_font_menu = ctk.CTkOptionMenu( + row, width=240, + values=[name for _id, name in stamplib.font_choices()] + + [tr("sig.font_custom")], + command=self._on_dialog_font) + self.dlg_font_menu.set(self._font_display(stamp.font)) + self.dlg_font_menu.pack(side="left", padx=8) + + row = ctk.CTkFrame(win, fg_color="transparent") + row.pack(fill="x", padx=18, pady=2) + ctk.CTkLabel(row, text=tr("sig.size")).pack(side="left") + self.dlg_size_entry = ctk.CTkEntry(row, width=70, justify="center") + self.dlg_size_entry.insert(0, f"{stamp.font_size:g}") + self.dlg_size_entry.pack(side="left", padx=(8, 18)) + ctk.CTkLabel(row, text=tr("sig.color")).pack(side="left") + self.dlg_color_btn = ctk.CTkButton(row, width=110, + command=self._pick_dialog_color) + self.dlg_color_btn.pack(side="left", padx=8) + self._paint_color_button() + + ctk.CTkLabel(win, text=tr("sig.preview")).pack(anchor="w", padx=18, pady=(6, 0)) + self._preview_box(win, 130) + self.dlg_size_lbl = ctk.CTkLabel(win, text="", height=18, **gray) + self.dlg_size_lbl.pack(anchor="w", padx=18) + for widget in (self.dlg_text_box, self.dlg_size_entry): + for seq in ("", ""): + widget.bind(seq, self._on_text_dialog_edit) + self._update_text_preview() + + def _on_text_dialog_edit(self, _event=None) -> None: + """The user typed (or pasted) in the text dialog.""" + self._dialog_state["edited"] = True + self._update_text_preview() + + def _on_dialog_font(self, display: str) -> None: + state = self._dialog_state + if display == tr("sig.font_custom"): + path = filedialog.askopenfilename( + parent=self._dialog, title=tr("sig.font_custom"), + filetypes=[("Fonts", "*.ttf *.otf")]) + if not _alive(self._dialog): # closed while the picker was open + return + if path: + state["font"] = path + # shows the file's name — or, cancelled, the font still in use + self.dlg_font_menu.set(self._font_display(state["font"])) + else: + state["font"] = next( + (fid for fid, name in stamplib.font_choices() if name == display), + state["font"]) + self._update_text_preview() + + def _dialog_text_stamp(self): + """``(stamp, None)`` from the text dialog's fields, or ``(None, + message)`` when the text is empty or the size is not usable.""" + text = self.dlg_text_box.get("1.0", "end-1c") + if not text.strip(): + return None, tr("sig.err_text_empty") + size = self._dialog_number(self.dlg_size_entry.get(), stamplib.MAX_FONT_SIZE) + if size is None: + return None, tr("sig.err_size", max=int(stamplib.MAX_FONT_SIZE)) + state = self._dialog_state + return stamplib.Stamp(kind="text", text=text, font=state["font"], + color=state["color"], font_size=size), None + + def _update_text_preview(self) -> None: + """Render the text as it will be stamped (same code as the PDF), on + every change. An oversized text is refused by the pixel budget in + milliseconds, so typing can never freeze the window; whatever goes + wrong becomes the dialog's message, never a traceback in a Tk + callback.""" + if not _alive(self._dialog) or self._dialog_state.get("kind") != "text": + return + stamp, error = self._dialog_text_stamp() + content = None + if stamp is not None: + try: + content = stamplib.stamp_content( + stamp, filename=self._sample_filename()) + except Exception as exc: # noqa: BLE001 - MemoryError, font, budget, … + error = tr("sig.err_render", error=str(exc) or type(exc).__name__) + elif (not self._dialog_state.get("edited") + and not self.dlg_text_box.get("1.0", "end-1c").strip()): + # A dialog just opened on an empty text: no reproach before the + # user has typed anything or tried to save. + error = None + self._dialog_error(error or "") + if content is None: + self._set_dialog_preview(None) + self.dlg_size_lbl.configure(text="") + return + img, (w_pt, h_pt) = content + # at most 1.5 screen px per point: a small text is not blown up, and + # the size line below tells how large it really is on the page + self._set_dialog_preview(img, fit=(500, 120), + max_zoom=1.5 * 72 / stamplib.TEXT_DPI) + self.dlg_size_lbl.configure( + text=tr("sig.preview_size", w=round(w_pt), h=round(h_pt))) + + def _apply_text_dialog(self) -> None: + """Save of the text dialog. On an error the dialog stays open with + the message; an oversized text is refused here for good (never added + to the library, never saved).""" + if not _alive(self._dialog) or self._dialog_state.get("kind") != "text": + return + self._dialog_state["edited"] = True # from now on, errors stay visible + stamp, error = self._dialog_text_stamp() + if stamp is not None: + try: + stamplib.validate_stamp(stamp, placed=False) + except stamplib.StampError as exc: + error = tr("sig.err_render", error=exc) + if error: + self._dialog_error(error) + return + first_line = next(ln.strip() for ln in stamp.text.splitlines() if ln.strip()) + label = (self.dlg_name_entry.get().strip() + or first_line[:40])[:profile_store.MAX_LABEL_LEN] + sig = self._sig(self._dialog_state["sig_id"]) + if sig is None: + self._add_signature(stamp, label) + else: # placement and placed_on are KEPT + sig.stamp = dataclasses.replace( + sig.stamp, text=stamp.text, font=stamp.font, color=stamp.color, + font_size=stamp.font_size) + sig.label = label + self._content_changed() + self._close_dialog() + + def _content_changed(self) -> None: + """A signature's content was edited: its cached previews are stale.""" + self._element_notice = None + self._preview_cache.clear() + self._scaled_cache.clear() + self._commit_elements() + + # --- freehand drawing (add) + def _open_draw_dialog(self) -> None: + """Draw a signature by hand. The strokes are the model — the canvas + is only their on-screen echo — and stamps.render_strokes_image turns + them into a transparent PNG kept in the user's signature store.""" + import tkinter as tk + win = self._open_dialog(tr("sig.draw_title"), "660x420") + self._dialog_state = {"kind": "draw", "sig_id": None, + "color": stamplib.DEFAULT_COLOR, "strokes": []} + self._dialog_footer(win, self._apply_draw_dialog, wrap=620) + ctk.CTkLabel(win, text=tr("sig.draw_hint"), justify="left", anchor="w", + wraplength=620, text_color=("gray25", "gray70"), + font=ctk.CTkFont(size=11) + ).pack(anchor="w", padx=18, pady=(12, 4)) + self.dlg_draw_canvas = tk.Canvas( + win, width=_DRAW_W, height=_DRAW_H, bg="white", highlightthickness=1, + highlightbackground="#888", cursor="pencil") + self.dlg_draw_canvas.pack(padx=18) + self.dlg_draw_canvas.bind("", self._draw_start) + self.dlg_draw_canvas.bind("", self._draw_move) + row = ctk.CTkFrame(win, fg_color="transparent") + row.pack(fill="x", padx=18, pady=(6, 2)) + self.dlg_color_btn = ctk.CTkButton(row, width=110, + command=self._pick_dialog_color) + self.dlg_color_btn.pack(side="left") + self._paint_color_button() + self.dlg_clear_btn = ctk.CTkButton( + row, text=tr("sig.draw_clear"), width=110, + fg_color="transparent", border_width=1, + text_color=("gray20", "gray80"), command=self._draw_clear) + self.dlg_clear_btn.pack(side="left", padx=8) + + @staticmethod + def _draw_point(event) -> tuple[float, float]: + """Pointer position, clamped to the drawing canvas: a drag goes on + outside it, and far-away points would only inflate the raster.""" + return (min(max(event.x, 0), _DRAW_W), min(max(event.y, 0), _DRAW_H)) + + def _draw_start(self, event) -> None: + state = self._dialog_state + if state.get("kind") != "draw": + return + self._dialog_error("") # "draw something first" is answered + x, y = self._draw_point(event) + state["strokes"].append([(x, y)]) + r = _DRAW_PEN / 2 # a click alone leaves a dot + self.dlg_draw_canvas.create_oval(x - r, y - r, x + r, y + r, + fill=state["color"], outline="") + + def _draw_move(self, event) -> None: + state = self._dialog_state + if not state.get("strokes"): # motion without a press + return + x, y = self._draw_point(event) + px, py = state["strokes"][-1][-1] + state["strokes"][-1].append((x, y)) + self.dlg_draw_canvas.create_line(px, py, x, y, width=_DRAW_PEN, + capstyle="round", fill=state["color"]) + + def _draw_clear(self) -> None: + if self._dialog_state.get("kind") != "draw": + return + self._dialog_state["strokes"] = [] + self.dlg_draw_canvas.delete("all") + self._dialog_error("") + + def _apply_draw_dialog(self) -> None: + """Save of the drawing dialog: rasterise, store, add as an image + signature. Nothing is added when the drawing is empty or cannot be + stored; the dialog then stays open with the reason.""" + state = self._dialog_state + if not _alive(self._dialog) or state.get("kind") != "draw": + return + if not any(state["strokes"]): + self._dialog_error(tr("sig.err_draw_empty")) + return + try: + img = stamplib.render_strokes_image( + state["strokes"], state["color"], width=_DRAW_PEN, scale=_DRAW_SCALE) + except stamplib.StampError: # the pixel budget + self._dialog_error(tr("sig.err_draw_too_large")) + return + try: + stored = self.store.store_image(img) + except OSError as exc: # the data dir cannot be written + self._dialog_error(tr("sig.err_store", error=exc)) + return + width_pt = min(240.0, max(30.0, round( + img.width / _DRAW_SCALE * _DRAW_PT_PER_PX, 1))) + self._add_signature( + stamplib.Stamp(kind="image", image_path=stored, width_pt=width_pt), + tr("sig.drawn_label")) + self._close_dialog() + + # --- image signature (edit only: a new one comes from the file picker) + def _open_image_dialog(self, sig) -> None: + """Edit an image signature: its name and its width on the page.""" + win = self._open_dialog(tr("sig.image_title"), "480x380") + self._dialog_state = {"kind": "image", "sig_id": sig.id} + self._dialog_footer(win, self._apply_image_dialog, wrap=440) + ctk.CTkLabel(win, text=tr("sig.name")).pack(anchor="w", padx=18, pady=(12, 0)) + self.dlg_name_entry = ctk.CTkEntry(win) + self.dlg_name_entry.insert(0, sig.label) + self.dlg_name_entry.pack(fill="x", padx=18) + row = ctk.CTkFrame(win, fg_color="transparent") + row.pack(fill="x", padx=18, pady=(6, 8)) + ctk.CTkLabel(row, text=tr("sig.width")).pack(side="left") + self.dlg_width_entry = ctk.CTkEntry(row, width=80, justify="center") + self.dlg_width_entry.insert(0, f"{sig.stamp.width_pt:g}") + self.dlg_width_entry.pack(side="left", padx=8) + self._preview_box(win, 170) + content = self._content(sig) + if content is None: # missing / unreadable file + self._dialog_error(tr("sig.err_image", error=self._sig_error(sig))) + else: + self._set_dialog_preview(content[0], fit=(420, 160)) + + def _apply_image_dialog(self) -> None: + if not _alive(self._dialog) or self._dialog_state.get("kind") != "image": + return + sig = self._sig(self._dialog_state["sig_id"]) + width = self._dialog_number(self.dlg_width_entry.get(), stamplib.MAX_WIDTH_PT) + if width is None: + self._dialog_error(tr("sig.err_width", max=int(stamplib.MAX_WIDTH_PT))) + return + if sig is not None: + sig.stamp = dataclasses.replace(sig.stamp, width_pt=width) + sig.label = (self.dlg_name_entry.get().strip() + or sig.label)[:profile_store.MAX_LABEL_LEN] + self._content_changed() + self._close_dialog() + def _set_page_entry(self, text: str) -> None: """Programmatic update of the target-page field (click, reset).""" self.page_text = text @@ -1196,63 +2171,65 @@ def _set_page_entry(self, text: str) -> None: self.page_entry.delete(0, "end") self.page_entry.insert(0, text) - def _reset_position(self) -> None: - self.place_page = self.place_x = self.place_y = None - self._set_page_entry("") - self._invalidate_run() - self._update_place_labels() - self._draw_page() - self._refresh_chrome() - def _on_page_entry_change(self) -> None: - """Manual target-page field: lets the signature go to a page other - than the previewed one (e.g. documents whose page of interest differs - from the template). Follows the preview when in range; warns when - beyond the template.""" - if self._running: + """Manual target-page field: a valid page number moves the SELECTED + element (when it is placed on a numbered page) to that page and the + preview follows. A number beyond the template blocks the step.""" + if self._running or self._page_anchor(): return if _alive(getattr(self, "page_entry", None)): self.page_text = self.page_entry.get() raw = self.page_text.strip() - if raw.isdigit() and int(raw) >= 1: + if (raw.isdecimal() and len(raw) <= _MAX_PAGE_DIGITS + and 1 <= int(raw) <= len(self.template_dims)): v = int(raw) - if self.place_x is not None: - self.place_page = v - if self.template_dims and 1 <= v <= len(self.template_dims) \ - and self.cur_page != v - 1: - self.cur_page = v - 1 - self._draw_page() + dims = tuple(self.template_dims[v - 1]) + sig = self._sig(self.selected_id) + if self.selected_id == VIGNETTE_ID and self._vignette_placed(): + vignette = self.profile.settings.vignette + vignette.page, vignette.placed_on = v, dims + self._save_profile() + elif sig and self._sig_placed(sig) and not sig.stamp.all_pages: + sig.stamp = dataclasses.replace(sig.stamp, page=v, page_anchor=None) + sig.placed_on = dims + self._save_profile() + self.cur_page = v - 1 self._invalidate_run() - self._update_place_labels() - self._refresh_chrome() + self._refresh_place() - def _update_place_labels(self) -> None: + def _position_text(self) -> str: + """Position line of the SELECTED element.""" anchor = self._page_anchor() - if _alive(getattr(self, "pos_lbl", None)): - if self.place_x is not None: - if anchor: - text = tr("place.pos_anchor", - anchor=tr(f"anchor.{anchor}_page"), - x=f"{self.place_x:.0f}", y=f"{self.place_y:.0f}") - else: - text = tr("place.pos", page=self.place_page, - x=f"{self.place_x:.0f}", y=f"{self.place_y:.0f}") - elif self.mode_var.get() in ("beid", "azure"): - text = (tr("place.pos_default_anchor", + all_pages = False + if self.selected_id == VIGNETTE_ID: + if not self._vignette_placed(): + return (tr("place.pos_default_anchor", anchor=tr(f"anchor.{anchor}_page")) if anchor else tr("place.pos_default")) - else: - text = tr("place.pos_none") - self.pos_lbl.configure(text=text) + vignette = self.profile.settings.vignette + x, y, page = vignette.x, vignette.y, vignette.page + else: + sig = self._sig(self.selected_id) + if sig is None: + return tr("place.select_hint") + if not self._sig_placed(sig): + return tr("place.pos_none") + x, y, page = sig.stamp.x, sig.stamp.y, self._element_page(sig.id) + all_pages = sig.stamp.all_pages + xy = {"x": f"{x:.0f}", "y": f"{y:.0f}"} + if anchor: + return tr("place.pos_anchor", anchor=tr(f"anchor.{anchor}_page"), **xy) + if all_pages: + return tr("place.pos_all", **xy) + return tr("place.pos", page=page, **xy) + + def _update_place_labels(self) -> None: + if _alive(getattr(self, "pos_lbl", None)): + self.pos_lbl.configure(text=self._position_text()) if _alive(getattr(self, "place_warn_lbl", None)): - warn = self._page_entry_error() or "" - if not warn and not anchor and self.place_page and self.template_dims \ - and self.place_page > len(self.template_dims): - warn = tr("place.page_beyond", page=self.place_page, - total=len(self.template_dims)) - if not warn and self.mode_var.get() == "image" and ( - self.image_path is None or self.place_x is None): - warn = tr("place.image_missing") + warn = self._element_notice or self._place_error() or "" + if not warn and self._profile_error: + warn = tr("place.save_failed", error=self._profile_error) self.place_warn_lbl.configure(text=warn) def _turn_page(self, delta: int) -> None: @@ -1263,7 +2240,7 @@ def _turn_page(self, delta: int) -> None: def _canvas_target_size(self) -> tuple[int, int]: """Target canvas size, derived from the window (grows/shrinks with it).""" - w = max(320, self.winfo_width() - _HELP_PANEL_W - 160) + w = max(320, self.winfo_width() - _HELP_PANEL_W - _ELEMENTS_PANEL_W - 170) h = max(260, int(self.winfo_height() * 0.45)) return w, h @@ -1287,6 +2264,7 @@ def _draw_page(self) -> None: cw, ch = self._canvas_target_size() self.canvas.configure(width=cw, height=ch) self.canvas.delete("all") + self._canvas_imgs = [] # keep-alive refs, per draw pw, ph = self.template_dims[self.cur_page] fw, fh = core.fit_frame(pw, ph, cw, ch) ox, oy = (cw - fw) / 2, (ch - fh) / 2 @@ -1308,50 +2286,133 @@ def _draw_page(self) -> None: anchor=tr(f"anchor.{anchor}_page")) self.page_lbl.configure(text=label) self._frame_geom = (fw, fh, ox, oy) - if self.place_page == self.cur_page + 1 and self.place_x is not None: - self._draw_placeholder(self.place_x, self.place_y) + self._draw_elements() def _on_canvas_click(self, event) -> None: + """A click PLACES the selected element: the clicked point becomes its + lower-left corner on the previewed page (no hit-testing — selection + is made in the list only).""" if not self.template_dims or not hasattr(self, "_frame_geom"): return fw, fh, ox, oy = self._frame_geom cx, cy = event.x - ox, event.y - oy if not (0 <= cx <= fw and 0 <= cy <= fh): return + self._element_notice = None + self._ensure_selection() + if self.selected_id is None: # nothing to place yet + self._update_place_labels() + return pw, ph = self.template_dims[self.cur_page] x, y = core.frame_click_to_pdf_xy(pw, ph, fw, fh, cx, cy) - self.place_page, self.place_x, self.place_y = self.cur_page + 1, x, y - if self._page_anchor() is None: - self._set_page_entry(str(self.place_page)) # click -> target page - self._invalidate_run() - self._update_place_labels() - self._draw_page() - self._refresh_chrome() - - def _placeholder_size_pt(self) -> tuple[float, float]: - """Placeholder size (pt): actual image in image mode, otherwise a 3:1 - vignette box of width page/5 of the current page (beid/azure).""" - pw = self.template_dims[self.cur_page][0] - if self.mode_var.get() == "image" and self.image_path: - return core.image_size_pt(self.image_path) - return core.vignette_size_pt(pw) - - def _draw_placeholder(self, x, y) -> None: + page = self.cur_page + 1 # the anchor page while locked + dims = tuple(self.template_dims[self.cur_page]) + all_pages = False + if self.selected_id == VIGNETTE_ID: + self.profile.settings.vignette = profile_store.Placement( + page=page, x=x, y=y, placed_on=dims) + else: + sig = self._sig(self.selected_id) + all_pages = sig.stamp.all_pages # kept: only x/y move + sig.stamp = dataclasses.replace( + sig.stamp, x=x, y=y, page_anchor=None, + page=None if all_pages else page) + sig.placed_on = dims + if self._page_anchor() is None and not all_pages: + self._set_page_entry(str(page)) # click -> target page + self._commit_elements() + + def _sig_on_preview(self, sig) -> bool: + """True if the signature's placement is in force AND lands on the + previewed page (under an anchor the preview is locked on the one + page every element goes to).""" + if not self._sig_placed(sig): + return False + return bool(self._page_anchor()) or ( + self.cur_page in sig.stamp.page_indexes(len(self.template_dims))) + + def _vignette_on_preview(self) -> bool: + if not self._has_vignette(): + return False + if self._vignette_placed(): + return self.profile.settings.vignette.page == self.cur_page + 1 + # default corner box: the anchor page (the preview is locked on it), + # else the template's LAST page + return bool(self._page_anchor()) or self.cur_page == len(self.template_dims) - 1 + + def _draw_elements(self) -> None: + """Every element shown on the previewed page, to scale: the enabled + signatures in library order, then the vignette; the selected element + last, so it is never hidden under another one.""" + shown = [s.id for s in self.profile.signatures + if s.enabled and self._sig_on_preview(s)] + if self._vignette_on_preview(): + shown.append(VIGNETTE_ID) + selected = self._sig(self.selected_id) + if self.selected_id in shown: + shown.remove(self.selected_id) + shown.append(self.selected_id) + elif selected and not selected.enabled and self._sig_on_preview(selected): + # an unticked signature is drawn only while selected (outline + # only), so the user sees where a click put it + shown.append(self.selected_id) + for element_id in shown: + self._draw_element(element_id) + + def _scaled_preview(self, sig, thumb, w: int, h: int): + """The cached thumbnail resized for the canvas, itself cached per + size: a window resize rescales, it never re-renders the content.""" + key = (self._content_key(sig), w, h) + if key not in self._scaled_cache: + if len(self._scaled_cache) > _SCALED_CACHE_MAX: + self._scaled_cache.clear() + self._scaled_cache[key] = thumb.resize((w, h), Image.LANCZOS) + return self._scaled_cache[key] + + def _draw_element(self, element_id) -> None: fw, fh, ox, oy = self._frame_geom pw, ph = self.template_dims[self.cur_page] - iw, ih = self._placeholder_size_pt() - left, top, w, h = core.pdf_rect_to_frame_rect(pw, ph, fw, fh, x, y, iw, ih) + selected = element_id == self.selected_id + sig = self._sig(element_id) + if sig is not None: + rect = (sig.stamp.x, sig.stamp.y, *self._element_size_pt(element_id)) + elif self._vignette_placed(): + vignette = self.profile.settings.vignette + rect = (vignette.x, vignette.y, *self._element_size_pt(VIGNETTE_ID)) + else: + rect = core.default_vignette_rect(pw, ph) + left, top, w, h = core.pdf_rect_to_frame_rect(pw, ph, fw, fh, *rect) left, top = left + ox, top + oy - if self.mode_var.get() == "image" and self.image_path: - try: - im = Image.open(self.image_path).convert("RGBA") - im = im.resize((max(1, int(w)), max(1, int(h)))) - self._canvas_img = ImageTk.PhotoImage(im) - self.canvas.create_image(left, top, anchor="nw", image=self._canvas_img) - except Exception: # noqa: BLE001 - pass - self.canvas.create_rectangle(left, top, left + w, top + h, - outline="#c00", width=2) + box = (left, top, left + w, top + h) + tags = ("element", f"el:{element_id}") # (a bare all-digit id would read as an item id) + # selected: solid red; others: thin blue dashes + outline = ({"outline": "#c00", "width": 2} if selected + else {"outline": "#3B8ED0", "width": 1, "dash": (3, 2)}) + gray = {"outline": "#777", "width": 2 if selected else 1, "dash": (4, 3)} + if sig is None: # vignette: a labelled box + if self._vignette_placed(): + outline.pop("dash", None) # placed -> solid outline + else: + outline = gray # default position -> gray dashes + self.canvas.create_text( + left + w / 2, top + h / 2, text=tr("place.el_vignette_short"), + font=("", 9), fill="#555", tags=tags) + elif not sig.enabled: + outline = gray # selected but unticked: outline only + else: + content = self._content(sig) + if content is None: # unusable: red box + warning sign + outline = {"outline": "#c00", "width": 2 if selected else 1} + self.canvas.create_text(left + w / 2, top + h / 2, text="⚠", + fill="#c00", tags=tags) + else: + scaled = self._scaled_preview(sig, content[0], + max(1, int(w)), max(1, int(h))) + photo = ImageTk.PhotoImage(scaled, master=self.canvas) + self._canvas_imgs.append(photo) # Tk only keeps a name + self.canvas.create_image(left, top, anchor="nw", image=photo, + tags=tags) + self.canvas.create_rectangle(*box, tags=tags, **outline) def _on_resize(self, event) -> None: # the canvas follows the window size, keeping the page proportions @@ -1376,20 +2437,33 @@ def _build_step_run(self, parent) -> None: if mode in ("beid", "azure"): lines.append(tr("run.summary_level", level=self.pades_level_var.get())) anchor = self._page_anchor() - if self.place_x is not None: + anchor_name = tr(f"anchor.{anchor}_page") if anchor else None + + def placed_phrase(page, x, y, all_pages=False) -> str: + xy = {"x": f"{x:.0f}", "y": f"{y:.0f}"} if anchor: - place = tr("run.place_custom_anchor", - anchor=tr(f"anchor.{anchor}_page"), - x=f"{self.place_x:.0f}", y=f"{self.place_y:.0f}") + return tr("run.place_custom_anchor", anchor=anchor_name, **xy) + if all_pages: + return tr("run.place_all", **xy) + return tr("run.place_custom", page=page, **xy) + + if mode in ("beid", "azure"): + if self._vignette_placed(): + v = self.profile.settings.vignette + place = placed_phrase(v.page, v.x, v.y) + elif anchor: + place = tr("run.place_default_anchor", anchor=anchor_name) else: - place = tr("run.place_custom", page=self.place_page, - x=f"{self.place_x:.0f}", y=f"{self.place_y:.0f}") - elif anchor: - place = tr("run.place_default_anchor", - anchor=tr(f"anchor.{anchor}_page")) - else: - place = tr("run.place_default") - lines.append(tr("run.summary_place", place=place)) + place = tr("run.place_default") + lines.append(tr("run.summary_place", place=place)) + enabled = self._enabled_signatures() + if enabled: + lines.append(tr("run.summary_stamps", count=len(enabled))) + for sig in enabled: + place = (placed_phrase(self._element_page(sig.id), sig.stamp.x, + sig.stamp.y, sig.stamp.all_pages) + if self._sig_placed(sig) else tr("place.st_unplaced")) + lines.append(tr("run.stamp_line", name=sig.label, place=place)) if anchor: lines.append(tr(f"run.summary_anchor_{anchor}")) lines.append(tr("run.summary_output", output=self.output_dir)) @@ -1451,13 +2525,21 @@ def _show_card_box(self) -> None: def _launch(self) -> None: if self._running or not self.valid_paths or not self.output_dir: return - # beid/azure: a click places the vignette; without a click, default - # placement (bottom-right, last page). place_* are None in that case - # and process_batch derives the placement from them. With the - # validation-step anchor active, the page is resolved PER DOCUMENT - # (first/last) instead of the fixed clicked page — page and - # page_anchor are mutually exclusive. + # Visual signatures: every ENABLED library entry, in list order. + # page/x/y/page_anchor carry the VIGNETTE placement + # only (beid/azure; all None in image mode): a placed vignette, or + # None -> default corner box (bottom-right, last page). With the + # validation-step anchor active, every element is resolved PER + # DOCUMENT on its first/last page instead of a fixed page — `page` + # and `page_anchor` stay mutually exclusive. anchor = self._page_anchor() + crypto = self._has_vignette() + v = self.profile.settings.vignette if (crypto and self._vignette_placed()) else None + stamp_list = [ + dataclasses.replace(s.stamp, page=None, all_pages=False, page_anchor=anchor) + if anchor else s.stamp + for s in self._enabled_signatures() + ] cfg = core.RunConfig( inputs=list(self.valid_paths), output=self.output_dir, @@ -1465,11 +2547,11 @@ def _launch(self) -> None: template=self.template_path, pades_level=self.pades_level_var.get(), lib=self.default_lib, - image_path=self.image_path, - page=None if anchor else self.place_page, - page_anchor=anchor, - x=self.place_x, - y=self.place_y, + stamps=stamp_list, + page=None if (anchor or v is None) else v.page, + page_anchor=anchor if crypto else None, + x=v.x if v else None, + y=v.y if v else None, # azure settings (ignored by the other modes). The worker batch # reuses the credential cached by "Sign in with Microsoft"; if # the user skipped it, the login happens on the worker thread. @@ -1579,11 +2661,9 @@ def _build_step_results(self, parent) -> None: (tr("res.col_detail"), 360)], height=12, ) - for r in results: - self.summary_table.insert( - "", "end", - values=(r.path.name, tr("res.ok") if r.ok else tr("res.fail"), - r.detail)) + self._fill_table(self.summary_table, [ + (r.path.name, tr("res.ok") if r.ok else tr("res.fail"), r.detail) + for r in results]) row = ctk.CTkFrame(parent, fg_color="transparent") row.pack(fill="x", padx=8, pady=(6, 4)) self.open_folder_btn = ctk.CTkButton( @@ -1612,7 +2692,10 @@ def _open_output_folder(self) -> None: def launch_gui(args) -> int: """Entry point called by `sign_pdfs_beid.py --gui`.""" ctk.set_appearance_mode("system") - i18n.set_language(i18n.system_language()) - app = CachetApp(args) + # Language: the user's saved choice, else the system's. + store = profile_store.ProfileStore() + lang = store.load().settings.language + i18n.set_language(lang if lang in i18n.LANGUAGES else i18n.system_language()) + app = CachetApp(args, store=store) app.mainloop() return 0 diff --git a/i18n.py b/i18n.py index 843387f..c510a62 100644 --- a/i18n.py +++ b/i18n.py @@ -167,24 +167,29 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "your Belgian eID card (a qualified signature, legally equal to a " "handwritten one), with your personal certificate in Azure Key Vault " "(an advanced signature — one Microsoft sign-in for the whole " - "batch), or stamp a simple image with no legal value. A template " - "guarantees that every document receives its signature at exactly " - "the same spot.\n\nThis assistant guides you through eight steps: " - "choose a template, the documents and an output folder, validate " - "the batch, pick and place the signature, then sign and review the " - "report." + "batch), or add visual signatures with no legal value — typed " + "text, images or a drawing, one or several per document. A " + "template guarantees that every document receives each element at " + "exactly the same spot. Your signatures and preferences are " + "remembered for next time.\n\nThis assistant guides you through " + "eight steps: choose a template, the documents and an output " + "folder, validate the batch, pick the signature type, place the " + "elements, then sign and review the report." ), "fr": ( "Cachet signe tout un lot de documents PDF en une seule fois. " "Signez avec votre carte eID belge (signature qualifiée, " "juridiquement équivalente à une signature manuscrite), avec votre " "certificat personnel dans Azure Key Vault (signature avancée — " - "une seule connexion Microsoft pour tout le lot), ou apposez une " - "simple image sans valeur juridique. Un modèle garantit que chaque " - "document reçoit sa signature exactement au même endroit.\n\nCet " - "assistant vous guide en huit étapes : choix du modèle, des " - "documents et du dossier de sortie, validation du lot, choix et " - "positionnement de la signature, puis signature et rapport final." + "une seule connexion Microsoft pour tout le lot), ou ajoutez des " + "signatures visuelles sans valeur juridique — texte saisi, images " + "ou dessin, une ou plusieurs par document. Un modèle garantit que " + "chaque document reçoit chaque élément exactement au même endroit. " + "Vos signatures et vos préférences sont mémorisées pour la " + "prochaine fois.\n\nCet assistant vous guide en huit étapes : " + "choix du modèle, des documents et du dossier de sortie, " + "validation du lot, choix du type de signature, positionnement " + "des éléments, puis signature et rapport final." ), "nl": ( "Cachet ondertekent een hele reeks PDF-documenten in één keer. " @@ -192,12 +197,15 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "handtekening, juridisch gelijkwaardig aan een handgeschreven " "handtekening), met uw persoonlijke certificaat in Azure Key Vault " "(een geavanceerde handtekening — één Microsoft-aanmelding voor de " - "hele reeks), of plaats een eenvoudige afbeelding zonder juridische " - "waarde. Een sjabloon garandeert dat elk document zijn handtekening " - "op exact dezelfde plek krijgt.\n\nDeze assistent begeleidt u in " - "acht stappen: kies een sjabloon, de documenten en een uitvoermap, " - "valideer de reeks, kies en plaats de handtekening, onderteken en " - "bekijk het rapport." + "hele reeks), of voeg visuele handtekeningen zonder juridische " + "waarde toe — getypte tekst, afbeeldingen of een tekening, één of " + "meerdere per document. Een sjabloon garandeert dat elk document " + "elk element op exact dezelfde plek krijgt. Uw handtekeningen en " + "voorkeuren worden onthouden voor de volgende keer.\n\nDeze " + "assistent begeleidt u in acht stappen: kies een sjabloon, de " + "documenten en een uitvoermap, valideer de reeks, kies het " + "handtekeningtype, plaats de elementen, onderteken en bekijk het " + "rapport." ), "de": ( "Cachet signiert einen ganzen Stapel PDF-Dokumente in einem " @@ -205,37 +213,44 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "qualifizierte Signatur, rechtlich der handschriftlichen " "Unterschrift gleichgestellt), mit Ihrem persönlichen Zertifikat " "in Azure Key Vault (eine fortgeschrittene Signatur — eine einzige " - "Microsoft-Anmeldung für den ganzen Stapel), oder stempeln Sie ein " - "einfaches Bild ohne Rechtswert auf. Eine Vorlage stellt sicher, " - "dass jedes Dokument seine Signatur an genau derselben Stelle " - "erhält.\n\nDieser Assistent führt Sie durch acht Schritte: " - "Vorlage, Dokumente und Ausgabeordner wählen, Stapel prüfen, " - "Signatur wählen und platzieren, dann signieren und den Bericht " - "prüfen." + "Microsoft-Anmeldung für den ganzen Stapel), oder fügen Sie " + "visuelle Signaturen ohne Rechtswert hinzu — getippten Text, " + "Bilder oder eine Zeichnung, eine oder mehrere pro Dokument. Eine " + "Vorlage stellt sicher, dass jedes Dokument jedes Element an genau " + "derselben Stelle erhält. Ihre Signaturen und Einstellungen werden " + "für das nächste Mal gespeichert.\n\nDieser Assistent führt Sie " + "durch acht Schritte: Vorlage, Dokumente und Ausgabeordner wählen, " + "Stapel prüfen, Signaturtyp wählen, Elemente platzieren, dann " + "signieren und den Bericht prüfen." ), "es": ( "Cachet firma un lote completo de documentos PDF de una sola vez. " "Firme con su tarjeta eID belga (firma cualificada, legalmente " "equivalente a la manuscrita), con su certificado personal en " "Azure Key Vault (firma avanzada — un solo inicio de sesión de " - "Microsoft para todo el lote), o estampe una simple imagen sin " - "valor legal. Una plantilla garantiza que cada documento reciba su " - "firma exactamente en el mismo lugar.\n\nEste asistente le guía en " + "Microsoft para todo el lote), o añada firmas visuales sin valor " + "legal — texto escrito, imágenes o un dibujo, una o varias por " + "documento. Una plantilla garantiza que cada documento reciba cada " + "elemento exactamente en el mismo lugar. Sus firmas y preferencias " + "se recuerdan para la próxima vez.\n\nEste asistente le guía en " "ocho pasos: elegir la plantilla, los documentos y la carpeta de " - "salida, validar el lote, elegir y colocar la firma y, por último, " - "firmar y revisar el informe." + "salida, validar el lote, elegir el tipo de firma, colocar los " + "elementos y, por último, firmar y revisar el informe." ), "pt": ( "O Cachet assina um lote inteiro de documentos PDF de uma só vez. " "Assine com o seu cartão eID belga (assinatura qualificada, " "juridicamente equivalente à manuscrita), com o seu certificado " "pessoal no Azure Key Vault (assinatura avançada — um único início " - "de sessão Microsoft para todo o lote), ou aplique uma simples " - "imagem sem valor legal. Um modelo garante que cada documento " - "recebe a assinatura exatamente no mesmo sítio.\n\nEste assistente " - "guia-o em oito passos: escolher o modelo, os documentos e a pasta " - "de saída, validar o lote, escolher e posicionar a assinatura e, " - "por fim, assinar e rever o relatório." + "de sessão Microsoft para todo o lote), ou adicione assinaturas " + "visuais sem valor legal — texto escrito, imagens ou um desenho, " + "uma ou várias por documento. Um modelo garante que cada documento " + "recebe cada elemento exatamente no mesmo sítio. As suas " + "assinaturas e preferências ficam guardadas para a próxima " + "vez.\n\nEste assistente guia-o em oito passos: escolher o modelo, " + "os documentos e a pasta de saída, validar o lote, escolher o tipo " + "de assinatura, posicionar os elementos e, por fim, assinar e " + "rever o relatório." ), }, "landing.language_label": { @@ -330,33 +345,37 @@ def split_markup(text: str) -> list[tuple[str, bool]]: }, "cancel.body": { "en": ( - "This discards everything entered so far — selected files, " - "settings and results — and returns to the welcome screen." + "This discards the current session — template, selected files, " + "validation and results — and returns to the welcome screen. Your " + "saved signatures and preferences are kept." ), "fr": ( - "Cela efface tout ce qui a été saisi jusqu'ici — fichiers " - "sélectionnés, réglages et résultats — et revient à l'écran " - "d'accueil." + "Cela efface la session en cours — modèle, fichiers sélectionnés, " + "validation et résultats — et revient à l'écran d'accueil. Vos " + "signatures enregistrées et vos préférences sont conservées." ), "nl": ( - "Hiermee wordt alles gewist wat u tot nu toe hebt ingevoerd — " - "geselecteerde bestanden, instellingen en resultaten — en keert u " - "terug naar het welkomstscherm." + "Hiermee wordt de huidige sessie gewist — sjabloon, geselecteerde " + "bestanden, validatie en resultaten — en keert u terug naar het " + "welkomstscherm. Uw opgeslagen handtekeningen en voorkeuren " + "blijven behouden." ), "de": ( - "Dadurch werden alle bisherigen Eingaben verworfen — ausgewählte " - "Dateien, Einstellungen und Ergebnisse — und Sie kehren zum " - "Startbildschirm zurück." + "Dadurch wird die aktuelle Sitzung verworfen — Vorlage, " + "ausgewählte Dateien, Prüfung und Ergebnisse — und Sie kehren zum " + "Startbildschirm zurück. Ihre gespeicherten Signaturen und " + "Einstellungen bleiben erhalten." ), "es": ( - "Esto descarta todo lo introducido hasta ahora — archivos " - "seleccionados, ajustes y resultados — y vuelve a la pantalla de " - "bienvenida." + "Esto descarta la sesión actual — plantilla, archivos " + "seleccionados, validación y resultados — y vuelve a la pantalla " + "de bienvenida. Sus firmas guardadas y sus preferencias se " + "conservan." ), "pt": ( - "Isto elimina tudo o que foi introduzido até agora — ficheiros " - "selecionados, definições e resultados — e regressa ao ecrã " - "inicial." + "Isto elimina a sessão atual — modelo, ficheiros selecionados, " + "validação e resultados — e regressa ao ecrã inicial. As suas " + "assinaturas guardadas e as suas preferências são mantidas." ), }, "cancel.confirm": { @@ -466,37 +485,37 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "step.files.help": { "en": ( "Choose every PDF document to sign in this run. The same " - "signature, position and settings are applied to the whole batch. " - "Documents that do not match the template are filtered out at the " - "next step." + "signatures, positions and settings are applied to the whole " + "batch. Documents that do not match the template are filtered out " + "at the next step." ), "fr": ( - "Choisissez tous les documents PDF à signer dans ce lot. La même " - "signature, la même position et les mêmes réglages s'appliquent à " - "tout le lot. Les documents qui ne correspondent pas au modèle " - "seront écartés à l'étape suivante." + "Choisissez tous les documents PDF à signer dans ce lot. Les mêmes " + "signatures, les mêmes positions et les mêmes réglages " + "s'appliquent à tout le lot. Les documents qui ne correspondent " + "pas au modèle seront écartés à l'étape suivante." ), "nl": ( "Kies alle PDF-documenten die u in deze reeks wilt ondertekenen. " - "Dezelfde handtekening, positie en instellingen gelden voor de " + "Dezelfde handtekeningen, posities en instellingen gelden voor de " "hele reeks. Documenten die niet met het sjabloon overeenkomen, " "worden in de volgende stap uitgefilterd." ), "de": ( "Wählen Sie alle PDF-Dokumente, die in diesem Durchgang signiert " - "werden sollen. Dieselbe Signatur, Position und dieselben " + "werden sollen. Dieselben Signaturen, Positionen und " "Einstellungen gelten für den gesamten Stapel. Dokumente, die " "nicht zur Vorlage passen, werden im nächsten Schritt aussortiert." ), "es": ( - "Elija todos los documentos PDF que desea firmar en este lote. La " - "misma firma, posición y ajustes se aplican a todo el lote. Los " - "documentos que no coincidan con la plantilla se descartarán en " - "el paso siguiente." + "Elija todos los documentos PDF que desea firmar en este lote. Las " + "mismas firmas, posiciones y ajustes se aplican a todo el lote. " + "Los documentos que no coincidan con la plantilla se descartarán " + "en el paso siguiente." ), "pt": ( - "Escolha todos os documentos PDF a assinar neste lote. A mesma " - "assinatura, posição e definições aplicam-se a todo o lote. Os " + "Escolha todos os documentos PDF a assinar neste lote. As mesmas " + "assinaturas, posições e definições aplicam-se a todo o lote. Os " "documentos que não correspondam ao modelo serão excluídos no " "passo seguinte." ), @@ -658,8 +677,12 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "Azure (AES) — an advanced signature with your personal certificate " "in Azure Key Vault. One Microsoft sign-in for the whole batch, no " "card; only the document fingerprint ever leaves this machine.\n\n" - "Image — pastes a picture. NOT a cryptographic signature, no legal " - "value; works fully offline.\n\n" + "Visual signature — stamps typed text, an image or a drawing on " + "the pages. NOT a cryptographic signature, no legal value; works " + "fully offline.\n\n" + "In every mode, visual signatures are added and placed on the " + "next step. With eID or Azure they are applied first, and the " + "cryptographic signature then covers them.\n\n" "The PAdES level sets durability. Keep b-lta: the signature stays " "verifiable for decades (needs network). Choose b-b only to sign " "offline. The full documentation — modes, levels, glossary and " @@ -675,9 +698,14 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "dans Azure Key Vault. Une seule connexion Microsoft pour tout le " "lot, sans carte ; seule l'empreinte du document quitte cette " "machine.\n\n" - "Image — colle une image. Ce N'EST PAS une signature " + "Signature visuelle — appose un texte saisi, une image ou un " + "dessin sur les pages. Ce N'EST PAS une signature " "cryptographique, sans valeur juridique ; fonctionne entièrement " "hors ligne.\n\n" + "Dans tous les modes, les signatures visuelles s'ajoutent et se " + "positionnent à l'étape suivante. Avec eID ou Azure, elles sont " + "apposées en premier, puis la signature cryptographique les " + "couvre.\n\n" "Le niveau PAdES règle la durabilité. Gardez b-lta : la signature " "reste vérifiable pendant des décennies (réseau requis). Choisissez " "b-b uniquement pour signer hors ligne. La documentation complète — " @@ -693,8 +721,12 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "certificaat in Azure Key Vault. Eén Microsoft-aanmelding voor de " "hele reeks, zonder kaart; alleen de vingerafdruk van het document " "verlaat deze computer.\n\n" - "Afbeelding — plakt een afbeelding. GEEN cryptografische " + "Visuele handtekening — brengt getypte tekst, een afbeelding of " + "een tekening aan op de pagina's. GEEN cryptografische " "handtekening, zonder juridische waarde; werkt volledig offline.\n\n" + "In elke modus voegt u visuele handtekeningen toe en plaatst u ze " + "in de volgende stap. Met eID of Azure worden ze eerst " + "aangebracht; de cryptografische handtekening omvat ze daarna.\n\n" "Het PAdES-niveau bepaalt de duurzaamheid. Behoud b-lta: de " "handtekening blijft tientallen jaren verifieerbaar (netwerk " "vereist). Kies b-b alleen om offline te ondertekenen. De volledige " @@ -712,8 +744,13 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "persönlichen Zertifikat in Azure Key Vault. Eine einzige " "Microsoft-Anmeldung für den ganzen Stapel, ohne Karte; nur der " "Fingerabdruck des Dokuments verlässt diesen Rechner.\n\n" - "Bild — fügt ein Bild ein. KEINE kryptografische Signatur, ohne " + "Visuelle Signatur — stempelt getippten Text, ein Bild oder eine " + "Zeichnung auf die Seiten. KEINE kryptografische Signatur, ohne " "Rechtswert; funktioniert vollständig offline.\n\n" + "In jedem Modus werden visuelle Signaturen im nächsten Schritt " + "hinzugefügt und platziert. Mit eID oder Azure werden sie zuerst " + "aufgebracht; die kryptografische Signatur deckt sie anschließend " + "mit ab.\n\n" "Das PAdES-Niveau bestimmt die Haltbarkeit. Behalten Sie b-lta: Die " "Signatur bleibt über Jahrzehnte prüfbar (Netzwerk erforderlich). " "Wählen Sie b-b nur, um offline zu signieren. Die vollständige " @@ -730,8 +767,12 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "Key Vault. Un solo inicio de sesión de Microsoft para todo el " "lote, sin tarjeta; solo la huella del documento sale de este " "equipo.\n\n" - "Imagen — pega una imagen. NO es una firma criptográfica, sin valor " - "legal; funciona totalmente sin conexión.\n\n" + "Firma visual — estampa un texto escrito, una imagen o un dibujo " + "en las páginas. NO es una firma criptográfica, sin valor legal; " + "funciona totalmente sin conexión.\n\n" + "En todos los modos, las firmas visuales se añaden y se colocan " + "en el paso siguiente. Con eID o Azure se aplican primero, y la " + "firma criptográfica las cubre después.\n\n" "El nivel PAdES define la durabilidad. Mantenga b-lta: la firma " "seguirá siendo verificable durante décadas (requiere red). Elija " "b-b solo para firmar sin conexión. La documentación completa — " @@ -747,8 +788,12 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "Azure Key Vault. Um único início de sessão Microsoft para todo o " "lote, sem cartão; apenas a impressão digital do documento sai " "deste computador.\n\n" - "Imagem — cola uma imagem. NÃO é uma assinatura criptográfica, sem " + "Assinatura visual — aplica um texto escrito, uma imagem ou um " + "desenho nas páginas. NÃO é uma assinatura criptográfica, sem " "valor legal; funciona totalmente offline.\n\n" + "Em todos os modos, as assinaturas visuais são adicionadas e " + "posicionadas no passo seguinte. Com eID ou Azure, são aplicadas " + "primeiro, e a assinatura criptográfica abrange-as em seguida.\n\n" "O nível PAdES define a durabilidade. Mantenha b-lta: a assinatura " "permanece verificável durante décadas (requer rede). Escolha b-b " "apenas para assinar offline. A documentação completa — modos, " @@ -770,130 +815,211 @@ def split_markup(text: str) -> list[tuple[str, bool]]: }, "step.place.help": { "en": ( - "Click on the page preview to set the lower-left corner of the " - "signature. **eID and Azure**: the visible vignette (photo, name, " - "date) is placed exactly where you click, on the chosen page; if " - "you do not click, it goes bottom-right on the last page. " - "**Image**: choose the image, then click to place it.\n\n" - "**Target page** — the page that actually gets signed. It follows " - "the preview; adjust it if a document must be signed on a different " - "page than the previewed template page. Documents without that page " - "will fail with a clear message.\n\n" + "Build the list of elements stamped on every document, then place " + "each one.\n\n" + "**Elements** — with **eID and Azure** the list starts with the " + "signature vignette (photo, name, date): it goes bottom-right on " + "the last page unless you place it. Add as many visual signatures " + "as you need with **Add text**, **Add image** or **Draw**; untick " + "one to keep it in your list without using it.\n\n" + "**Placing** — select an element in the list, then click on the " + "page preview: the click sets its lower-left corner. The preview " + "shows every element at its real size. **On every page** repeats a " + "signature on all pages (initials). In a text, {date} and " + "{filename} are replaced for each document, so the size of that " + "text can vary slightly.\n\n" + "**Target page** — the page that receives the selected element. It " + "follows the preview; type another page number to move the " + "element.\n\n" "**Documents with a different page count** — when some documents " "have more or fewer pages than the template, a selector at the top " - "of this step lets you choose whether each of them is signed on its " - "**first** or its **last** page (the same choice as on the " - "validation step). That page must have the template's page size: " - "the preview is locked on the corresponding template page, the " - "target-page field is disabled, and changing the choice re-checks " - "the documents." + "of this step lets you choose whether they are signed on their " + "**first** or their **last** page (the same choice as on the " + "validation step). Every element then goes on that page: the " + "preview is locked on the corresponding template page, the " + "target-page field and \"On every page\" are disabled, and " + "changing the choice re-checks the documents.\n\n" + "**Saved for next time** — your signatures, their last position " + "and your preferences are stored **unencrypted** in your user " + "folder and reloaded at the next start. **Delete** removes a " + "signature and its stored image. PIN codes and sign-in tokens are " + "never stored." ), "fr": ( - "Cliquez sur l'aperçu de la page pour définir le coin inférieur " - "gauche de la signature. **eID et Azure** : la vignette visible " - "(photo, nom, date) est placée exactement à l'endroit où vous " - "cliquez, sur la page choisie ; si vous ne cliquez pas, elle ira en " - "bas à droite de la dernière page. **Image** : choisissez l'image, " - "puis cliquez pour la positionner.\n\n" - "**Page cible** — la page réellement signée. Elle suit l'aperçu ; " - "ajustez-la si un document doit être signé sur une autre page que " - "celle du modèle affiché. Les documents sans cette page échoueront " - "avec un message clair.\n\n" + "Composez la liste des éléments apposés sur chaque document, puis " + "placez chacun d'eux.\n\n" + "**Éléments** — avec **eID et Azure**, la liste commence par la " + "vignette de signature (photo, nom, date) : elle ira en bas à " + "droite de la dernière page si vous ne la placez pas. Ajoutez " + "autant de signatures visuelles que nécessaire avec **Ajouter un " + "texte**, **Ajouter une image** ou **Dessiner** ; décochez-en une " + "pour la garder dans votre liste sans l'utiliser.\n\n" + "**Placement** — sélectionnez un élément dans la liste, puis " + "cliquez sur l'aperçu de la page : le clic définit son coin " + "inférieur gauche. L'aperçu montre chaque élément à sa taille " + "réelle. **Sur toutes les pages** répète une signature sur chacune " + "des pages (paraphe). Dans un texte, {date} et {filename} sont " + "remplacés pour chaque document ; la taille de ce texte peut donc " + "varier légèrement.\n\n" + "**Page cible** — la page qui reçoit l'élément sélectionné. Elle " + "suit l'aperçu ; saisissez un autre numéro de page pour déplacer " + "l'élément.\n\n" "**Documents dont le nombre de pages diffère** — lorsque certains " "documents comptent plus ou moins de pages que le modèle, un " - "sélecteur en haut de cette étape permet de choisir si chacun d'eux " - "est signé sur sa **première** ou sa **dernière** page (le même " - "choix qu'à l'étape de validation). Cette page doit avoir les mêmes " - "dimensions que le modèle : l'aperçu est verrouillé sur la page " - "correspondante du modèle, le champ « page cible » est désactivé et " - "tout changement de ce choix revérifie les documents." + "sélecteur en haut de cette étape permet de choisir s'ils sont " + "signés sur leur **première** ou leur **dernière** page (le même " + "choix qu'à l'étape de validation). Tous les éléments vont alors " + "sur cette page : l'aperçu est verrouillé sur la page " + "correspondante du modèle, le champ « page cible » et « Sur toutes " + "les pages » sont désactivés, et tout changement de ce choix " + "revérifie les documents.\n\n" + "**Mémorisé pour la prochaine fois** — vos signatures, leur " + "dernière position et vos préférences sont enregistrées **sans " + "chiffrement** dans votre dossier utilisateur et rechargées au " + "prochain démarrage. **Supprimer** retire une signature et son " + "image stockée. Les codes PIN et les jetons de connexion ne sont " + "jamais enregistrés." ), "nl": ( - "Klik op het paginavoorbeeld om de linkerbenedenhoek van de " - "handtekening te bepalen. **eID en Azure**: het zichtbare vignet " - "(foto, naam, datum) wordt op de gekozen pagina precies daar " - "geplaatst waar u klikt; klikt u niet, dan komt het rechtsonder op " - "de laatste pagina. **Afbeelding**: kies de afbeelding en klik " - "daarna om ze te plaatsen.\n\n" - "**Doelpagina** — de pagina die daadwerkelijk wordt ondertekend. " - "Dit veld volgt het voorbeeld; pas het aan als een document op een " - "andere pagina moet worden ondertekend dan de getoonde " - "sjabloonpagina. Documenten zonder die pagina zullen mislukken met " - "een duidelijke melding.\n\n" + "Stel de lijst samen van de elementen die op elk document worden " + "aangebracht en plaats ze daarna één voor één.\n\n" + "**Elementen** — met **eID en Azure** begint de lijst met het " + "handtekeningvignet (foto, naam, datum): het komt rechtsonder op " + "de laatste pagina, tenzij u het zelf plaatst. Voeg zoveel visuele " + "handtekeningen toe als u nodig hebt met **Tekst toevoegen**, " + "**Afbeelding toevoegen** of **Tekenen**; vink er een uit om ze in " + "uw lijst te bewaren zonder ze te gebruiken.\n\n" + "**Plaatsen** — selecteer een element in de lijst en klik daarna " + "op het paginavoorbeeld: de klik bepaalt de linkerbenedenhoek " + "ervan. Het voorbeeld toont elk element op ware grootte. **Op elke " + "pagina** herhaalt een handtekening op alle pagina's (paraaf). In " + "een tekst worden {date} en {filename} voor elk document " + "vervangen; de grootte van die tekst kan daardoor licht " + "variëren.\n\n" + "**Doelpagina** — de pagina die het geselecteerde element krijgt. " + "Dit veld volgt het voorbeeld; typ een ander paginanummer om het " + "element te verplaatsen.\n\n" "**Documenten met een ander aantal pagina's** — wanneer sommige " "documenten meer of minder pagina's hebben dan het sjabloon, kunt u " - "met een keuzelijst bovenaan deze stap kiezen of elk van die " - "documenten op zijn **eerste** of zijn **laatste** pagina wordt " - "ondertekend (dezelfde keuze als in de validatiestap). Die pagina " - "moet de paginagrootte van het sjabloon hebben: het voorbeeld is " - "vergrendeld op de overeenkomstige sjabloonpagina, het veld " - "'Doelpagina' is uitgeschakeld en als u de keuze wijzigt, worden de " - "documenten opnieuw gecontroleerd." + "met een keuzelijst bovenaan deze stap kiezen of ze op hun " + "**eerste** of hun **laatste** pagina worden ondertekend (dezelfde " + "keuze als in de validatiestap). Elk element komt dan op die " + "pagina: het voorbeeld is vergrendeld op de overeenkomstige " + "sjabloonpagina, het veld 'Doelpagina' en 'Op elke pagina' zijn " + "uitgeschakeld en als u de keuze wijzigt, worden de documenten " + "opnieuw gecontroleerd.\n\n" + "**Bewaard voor de volgende keer** — uw handtekeningen, hun " + "laatste positie en uw voorkeuren worden **onversleuteld** in uw " + "gebruikersmap opgeslagen en bij de volgende start opnieuw " + "geladen. **Verwijderen** wist een handtekening en de bijbehorende " + "opgeslagen afbeelding. Pincodes en aanmeldtokens worden nooit " + "opgeslagen." ), "de": ( - "Klicken Sie auf die Seitenvorschau, um die linke untere Ecke der " - "Signatur festzulegen. **eID und Azure**: Die sichtbare Vignette " - "(Foto, Name, Datum) wird auf der gewählten Seite genau dort " - "platziert, wo Sie klicken; ohne Klick kommt sie unten rechts auf " - "die letzte Seite. **Bild**: Wählen Sie das Bild und klicken Sie " - "dann, um es zu platzieren.\n\n" - "**Zielseite** — die Seite, die tatsächlich signiert wird. Sie " - "folgt der Vorschau; passen Sie sie an, wenn ein Dokument auf einer " - "anderen Seite signiert werden soll als der angezeigten " - "Vorlagenseite. Dokumente ohne diese Seite schlagen mit einer " - "klaren Meldung fehl.\n\n" + "Stellen Sie die Liste der Elemente zusammen, die auf jedes " + "Dokument gestempelt werden, und platzieren Sie dann jedes " + "einzelne.\n\n" + "**Elemente** — bei **eID und Azure** beginnt die Liste mit der " + "Signaturvignette (Foto, Name, Datum): Sie kommt unten rechts auf " + "die letzte Seite, sofern Sie sie nicht selbst platzieren. Fügen " + "Sie mit **Text hinzufügen**, **Bild hinzufügen** oder " + "**Zeichnen** beliebig viele visuelle Signaturen hinzu; entfernen " + "Sie das Häkchen, um eine Signatur in Ihrer Liste zu behalten, " + "ohne sie zu verwenden.\n\n" + "**Platzieren** — wählen Sie ein Element in der Liste aus und " + "klicken Sie dann auf die Seitenvorschau: Der Klick legt seine " + "linke untere Ecke fest. Die Vorschau zeigt jedes Element in " + "Originalgröße. **Auf jeder Seite** wiederholt eine Signatur auf " + "allen Seiten (Paraphe). In einem Text werden {date} und " + "{filename} für jedes Dokument ersetzt; die Größe dieses Textes " + "kann daher leicht variieren.\n\n" + "**Zielseite** — die Seite, die das ausgewählte Element erhält. " + "Sie folgt der Vorschau; geben Sie eine andere Seitenzahl ein, um " + "das Element zu verschieben.\n\n" "**Dokumente mit abweichender Seitenzahl** — haben einige Dokumente " "mehr oder weniger Seiten als die Vorlage, können Sie über ein " - "Auswahlfeld oben in diesem Schritt festlegen, ob jedes davon auf " - "seiner **ersten** oder seiner **letzten** Seite signiert wird " - "(dieselbe Wahl wie im Prüfschritt). Diese Seite muss die " - "Seitengröße der Vorlage haben: Die Vorschau ist auf der " - "entsprechenden Vorlagenseite gesperrt, das Feld „Zielseite“ ist " + "Auswahlfeld oben in diesem Schritt festlegen, ob sie auf ihrer " + "**ersten** oder ihrer **letzten** Seite signiert werden (dieselbe " + "Wahl wie im Prüfschritt). Alle Elemente kommen dann auf diese " + "Seite: Die Vorschau ist auf der entsprechenden Vorlagenseite " + "gesperrt, das Feld „Zielseite“ und „Auf jeder Seite“ sind " "deaktiviert, und bei einer Änderung der Wahl werden die Dokumente " - "erneut geprüft." + "erneut geprüft.\n\n" + "**Für das nächste Mal gespeichert** — Ihre Signaturen, deren " + "letzte Position und Ihre Einstellungen werden **unverschlüsselt** " + "in Ihrem Benutzerordner gespeichert und beim nächsten Start " + "wieder geladen. **Löschen** entfernt eine Signatur und ihr " + "gespeichertes Bild. PINs und Anmeldetoken werden niemals " + "gespeichert." ), "es": ( - "Haga clic en la vista previa de la página para fijar la esquina " - "inferior izquierda de la firma. **eID y Azure**: la viñeta visible " - "(foto, nombre, fecha) se coloca exactamente donde haga clic, en la " - "página elegida; si no hace clic, irá abajo a la derecha en la " - "última página. **Imagen**: elija la imagen y luego haga clic para " - "colocarla.\n\n" - "**Página de destino** — la página que realmente se firma. Se " - "actualiza con la vista previa; ajústela si un documento debe " - "firmarse en una página distinta de la página de la plantilla " - "mostrada. Los documentos sin esa página fallarán con un mensaje " - "claro.\n\n" + "Componga la lista de elementos que se estampan en cada documento " + "y, después, coloque cada uno.\n\n" + "**Elementos** — con **eID y Azure** la lista empieza por la viñeta " + "de firma (foto, nombre, fecha): irá abajo a la derecha en la " + "última página, salvo que usted la coloque. Añada tantas firmas " + "visuales como necesite con **Añadir texto**, **Añadir imagen** o " + "**Dibujar**; desmarque una para conservarla en su lista sin " + "utilizarla.\n\n" + "**Colocación** — seleccione un elemento de la lista y luego haga " + "clic en la vista previa de la página: el clic fija su esquina " + "inferior izquierda. La vista previa muestra cada elemento a su " + "tamaño real. **En todas las páginas** repite una firma en cada " + "una de las páginas (rúbrica). En un texto, {date} y {filename} se " + "sustituyen en cada documento, por lo que el tamaño de ese texto " + "puede variar ligeramente.\n\n" + "**Página de destino** — la página que recibe el elemento " + "seleccionado. Se actualiza con la vista previa; escriba otro " + "número de página para mover el elemento.\n\n" "**Documentos con un número de páginas distinto** — cuando algunos " "documentos tienen más o menos páginas que la plantilla, un " "selector en la parte superior de este paso le permite elegir si " - "cada uno de ellos se firma en su **primera** o en su **última** " - "página (la misma elección que en el paso de validación). Esa " - "página debe tener el tamaño de página de la plantilla: la vista " - "previa queda bloqueada en la página correspondiente de la " - "plantilla, el campo «Página de destino» se desactiva y cambiar la " - "elección vuelve a comprobar los documentos." + "se firman en su **primera** o en su **última** página (la misma " + "elección que en el paso de validación). Todos los elementos van " + "entonces a esa página: la vista previa queda bloqueada en la " + "página correspondiente de la plantilla, el campo «Página de " + "destino» y «En todas las páginas» se desactivan, y cambiar la " + "elección vuelve a comprobar los documentos.\n\n" + "**Guardado para la próxima vez** — sus firmas, su última posición " + "y sus preferencias se guardan **sin cifrar** en su carpeta de " + "usuario y se vuelven a cargar en el siguiente inicio. " + "**Eliminar** borra una firma y su imagen almacenada. Los códigos " + "PIN y los tokens de inicio de sesión nunca se guardan." ), "pt": ( - "Clique na pré-visualização da página para definir o canto inferior " - "esquerdo da assinatura. **eID e Azure**: a vinheta visível " - "(fotografia, nome, data) é colocada exatamente onde clicar, na " - "página escolhida; se não clicar, ficará em baixo à direita na " - "última página. **Imagem**: escolha a imagem e depois clique para a " - "posicionar.\n\n" - "**Página de destino** — a página realmente assinada. Segue a " - "pré-visualização; ajuste-a se um documento tiver de ser assinado " - "numa página diferente da página do modelo apresentada na " - "pré-visualização. Os documentos sem essa página falharão com uma " - "mensagem clara.\n\n" + "Componha a lista dos elementos aplicados em cada documento e, " + "depois, posicione cada um.\n\n" + "**Elementos** — com **eID e Azure**, a lista começa pela vinheta " + "de assinatura (fotografia, nome, data): ficará em baixo à direita " + "na última página, a menos que a posicione. Adicione as " + "assinaturas visuais de que precisar com **Adicionar texto**, " + "**Adicionar imagem** ou **Desenhar**; desmarque uma para a manter " + "na lista sem a utilizar.\n\n" + "**Posicionamento** — selecione um elemento na lista e depois " + "clique na pré-visualização da página: o clique define o seu canto " + "inferior esquerdo. A pré-visualização mostra cada elemento no " + "tamanho real. **Em todas as páginas** repete uma assinatura em " + "cada uma das páginas (rubrica). Num texto, {date} e {filename} " + "são substituídos em cada documento, pelo que o tamanho desse " + "texto pode variar ligeiramente.\n\n" + "**Página de destino** — a página que recebe o elemento " + "selecionado. Segue a pré-visualização; escreva outro número de " + "página para mover o elemento.\n\n" "**Documentos com um número de páginas diferente** — quando alguns " "documentos têm mais ou menos páginas do que o modelo, um seletor " - "no topo deste passo permite escolher se cada um deles é assinado " - "na sua **primeira** ou na sua **última** página (a mesma escolha " - "que no passo de validação). Essa página deve ter as dimensões de " - "página do modelo: a pré-visualização fica bloqueada na página " - "correspondente do modelo, o campo «página de destino» fica " - "desativado e alterar a escolha volta a verificar os documentos." + "no topo deste passo permite escolher se são assinados na sua " + "**primeira** ou na sua **última** página (a mesma escolha que no " + "passo de validação). Todos os elementos ficam então nessa " + "página: a pré-visualização fica bloqueada na página " + "correspondente do modelo, o campo «página de destino» e «Em todas " + "as páginas» ficam desativados, e alterar a escolha volta a " + "verificar os documentos.\n\n" + "**Guardado para a próxima vez** — as suas assinaturas, a " + "respetiva última posição e as suas preferências são guardadas " + "**sem encriptação** na sua pasta de utilizador e recarregadas no " + "arranque seguinte. **Eliminar** remove uma assinatura e a " + "respetiva imagem armazenada. Os códigos PIN e os tokens de início " + "de sessão nunca são guardados." ), }, "step.run.short": { @@ -915,7 +1041,9 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "asked once per document. **Azure**: a Microsoft sign-in window may " "open if you have not signed in yet. Levels above b-b contact the " "timestamp authority and revocation services, so network access is " - "required. Keep the window open while signing is in progress." + "required. Keep the window open while signing is in progress. " + "Visual signatures are stamped first; the cryptographic signature " + "(eID or Azure) is applied last and covers them." ), "fr": ( "Vérifiez le récapitulatif, puis lancez le lot. **eID** : insérez " @@ -925,7 +1053,9 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "s'ouvrir si vous n'êtes pas encore connecté. Au-delà de b-b, " "l'autorité d'horodatage et les services de révocation sont " "contactés : un accès réseau est requis. Gardez la fenêtre ouverte " - "pendant la signature." + "pendant la signature. Les signatures visuelles sont apposées en " + "premier ; la signature cryptographique (eID ou Azure) est " + "appliquée en dernier et les couvre." ), "nl": ( "Controleer het overzicht en start de reeks. **eID**: steek uw " @@ -934,7 +1064,10 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "**Azure**: er kan een Microsoft-aanmeldvenster verschijnen als u " "nog niet bent aangemeld. Boven b-b worden de tijdstempelautoriteit " "en de intrekkingsdiensten gecontacteerd: netwerktoegang is " - "vereist. Houd het venster open zolang het ondertekenen bezig is." + "vereist. Houd het venster open zolang het ondertekenen bezig is. " + "Visuele handtekeningen worden eerst aangebracht; de " + "cryptografische handtekening (eID of Azure) komt als laatste en " + "omvat ze." ), "de": ( "Prüfen Sie die Übersicht und starten Sie den Stapel. **eID**: " @@ -944,7 +1077,9 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "öffnen, falls Sie noch nicht angemeldet sind. Oberhalb von b-b " "werden Zeitstempel- und Sperrdienste kontaktiert: Netzwerkzugang " "ist erforderlich. Lassen Sie das Fenster geöffnet, solange " - "signiert wird." + "signiert wird. Visuelle Signaturen werden zuerst aufgebracht; die " + "kryptografische Signatur (eID oder Azure) folgt zuletzt und deckt " + "sie mit ab." ), "es": ( "Compruebe el resumen y lance el lote. **eID**: inserte su tarjeta " @@ -953,7 +1088,9 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "una ventana de inicio de sesión de Microsoft si aún no ha iniciado " "sesión. Por encima de b-b se contacta con la autoridad de sellado " "de tiempo y los servicios de revocación: se necesita acceso a la " - "red. Mantenga la ventana abierta mientras se firma." + "red. Mantenga la ventana abierta mientras se firma. Las firmas " + "visuales se estampan primero; la firma criptográfica (eID o " + "Azure) se aplica al final y las cubre." ), "pt": ( "Verifique o resumo e inicie o lote. **eID**: insira o seu cartão " @@ -962,7 +1099,9 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "janela de início de sessão Microsoft se ainda não tiver iniciado " "sessão. Acima de b-b são contactados a autoridade de selos " "temporais e os serviços de revogação: é necessário acesso à rede. " - "Mantenha a janela aberta enquanto a assinatura decorre." + "Mantenha a janela aberta enquanto a assinatura decorre. As " + "assinaturas visuais são aplicadas primeiro; a assinatura " + "criptográfica (eID ou Azure) é aplicada por último e abrange-as." ), }, "step.results.short": { @@ -1199,12 +1338,12 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "pt": "Azure Key Vault — assinatura avançada (AES)", }, "mode.image": { - "en": "Image stamp — no cryptographic signature", - "fr": "Image — aucune signature cryptographique", - "nl": "Afbeelding — geen cryptografische handtekening", - "de": "Bildstempel — keine kryptografische Signatur", - "es": "Imagen — sin firma criptográfica", - "pt": "Imagem — sem assinatura criptográfica", + "en": "Visual signature (text / image) — no cryptographic signature", + "fr": "Signature visuelle (texte / image) — aucune signature cryptographique", + "nl": "Visuele handtekening (tekst / afbeelding) — geen cryptografische handtekening", + "de": "Visuelle Signatur (Text / Bild) — keine kryptografische Signatur", + "es": "Firma visual (texto / imagen) — sin firma criptográfica", + "pt": "Assinatura visual (texto / imagem) — sem assinatura criptográfica", }, "mode.beid_hint": { "en": "Card reader + one PIN per document. Embeds your national register number (RRN) in every signature.", @@ -1223,12 +1362,67 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "pt": "Um único início de sessão Microsoft para todo o lote; requer rede. Apenas o resumo (digest) do documento sai deste computador.", }, "mode.image_hint": { - "en": "Visual stamp only, no legal value; works fully offline.", - "fr": "Simple tampon visuel, sans valeur juridique ; fonctionne entièrement hors ligne.", - "nl": "Alleen een visuele stempel, zonder juridische waarde; werkt volledig offline.", - "de": "Nur ein sichtbarer Stempel ohne Rechtswert; funktioniert vollständig offline.", - "es": "Solo un sello visual, sin valor legal; funciona totalmente sin conexión.", - "pt": "Apenas um carimbo visual, sem valor legal; funciona totalmente offline.", + "en": ( + "A typed, drawn or image signature stamped on the pages — one or " + "several per document. No legal value; works fully offline." + ), + "fr": ( + "Une signature saisie au clavier, dessinée ou sous forme d'image, " + "apposée sur les pages — une ou plusieurs par document. Sans " + "valeur juridique ; fonctionne entièrement hors ligne." + ), + "nl": ( + "Een getypte of getekende handtekening of een afbeelding, " + "aangebracht op de pagina's — één of meerdere per document. Zonder " + "juridische waarde; werkt volledig offline." + ), + "de": ( + "Eine getippte, gezeichnete oder als Bild vorliegende Signatur, " + "auf die Seiten gestempelt — eine oder mehrere pro Dokument. Ohne " + "Rechtswert; funktioniert vollständig offline." + ), + "es": ( + "Una firma escrita, dibujada o en forma de imagen, estampada en " + "las páginas — una o varias por documento. Sin valor legal; " + "funciona totalmente sin conexión." + ), + "pt": ( + "Uma assinatura escrita, desenhada ou em imagem, aplicada nas " + "páginas — uma ou várias por documento. Sem valor legal; funciona " + "totalmente offline." + ), + }, + "mode.stamps_hint": { + "en": ( + "In every mode you can add visual signatures (text, image, " + "drawing) on the placement step. With eID or Azure they are " + "applied before the cryptographic signature." + ), + "fr": ( + "Dans tous les modes, vous pouvez ajouter des signatures visuelles " + "(texte, image, dessin) à l'étape de positionnement. Avec eID ou " + "Azure, elles sont apposées avant la signature cryptographique." + ), + "nl": ( + "In elke modus kunt u in de plaatsingsstap visuele handtekeningen " + "(tekst, afbeelding, tekening) toevoegen. Met eID of Azure worden " + "ze vóór de cryptografische handtekening aangebracht." + ), + "de": ( + "In jedem Modus können Sie im Platzierungsschritt visuelle " + "Signaturen (Text, Bild, Zeichnung) hinzufügen. Mit eID oder Azure " + "werden sie vor der kryptografischen Signatur aufgebracht." + ), + "es": ( + "En todos los modos puede añadir firmas visuales (texto, imagen, " + "dibujo) en el paso de colocación. Con eID o Azure se aplican " + "antes de la firma criptográfica." + ), + "pt": ( + "Em todos os modos pode adicionar assinaturas visuais (texto, " + "imagem, desenho) no passo de posicionamento. Com eID ou Azure, " + "são aplicadas antes da assinatura criptográfica." + ), }, "mode.level": { "en": "PAdES level:", @@ -1523,14 +1717,6 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "pt": "Cachet — Documentação", }, # ------------------------------------------------------------- step 6 - "place.image_choose": { - "en": "Choose image…", - "fr": "Choisir l'image…", - "nl": "Afbeelding kiezen…", - "de": "Bild wählen…", - "es": "Elegir imagen…", - "pt": "Escolher imagem…", - }, "place.page": { "en": "Target page:", "fr": "Page cible :", @@ -1635,14 +1821,6 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "es": "Sin posición definida — la viñeta irá abajo a la derecha en la {anchor} de cada documento.", "pt": "Nenhuma posição definida — a vinheta ficará em baixo à direita na {anchor} de cada documento.", }, - "place.image_missing": { - "en": "Choose an image, then click on the preview to place it.", - "fr": "Choisissez une image, puis cliquez sur l'aperçu pour la positionner.", - "nl": "Kies een afbeelding en klik daarna op het voorbeeld om ze te plaatsen.", - "de": "Wählen Sie ein Bild und klicken Sie dann auf die Vorschau, um es zu platzieren.", - "es": "Elija una imagen y luego haga clic en la vista previa para colocarla.", - "pt": "Escolha uma imagem e depois clique na pré-visualização para a posicionar.", - }, "place.anchor_status": { "en": ( "{ok}/{total} document(s) accepted with this choice — details on " @@ -1704,6 +1882,494 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "modelo." ), }, + "place.elements_title": { + "en": "Elements to place", + "fr": "Éléments à placer", + "nl": "Te plaatsen elementen", + "de": "Zu platzierende Elemente", + "es": "Elementos a colocar", + "pt": "Elementos a posicionar", + }, + "place.list_empty": { + "en": "No signature yet — add one with the buttons above.", + "fr": "Aucune signature pour l'instant — ajoutez-en une avec les boutons ci-dessus.", + "nl": "Nog geen handtekening — voeg er een toe met de knoppen hierboven.", + "de": "Noch keine Signatur — fügen Sie eine über die Schaltflächen oben hinzu.", + "es": "Aún no hay ninguna firma — añada una con los botones de arriba.", + "pt": "Ainda não há nenhuma assinatura — adicione uma com os botões acima.", + }, + "place.el_vignette": { + "en": "Signature vignette (certificate)", + "fr": "Vignette de signature (certificat)", + "nl": "Handtekeningvignet (certificaat)", + "de": "Signaturvignette (Zertifikat)", + "es": "Viñeta de firma (certificado)", + "pt": "Vinheta de assinatura (certificado)", + }, + "place.el_vignette_short": { + "en": "Vignette", "fr": "Vignette", "nl": "Vignet", + "de": "Vignette", "es": "Viñeta", "pt": "Vinheta", + }, + "place.st_page": { + "en": "page {page}", + "fr": "page {page}", + "nl": "pagina {page}", + "de": "Seite {page}", + "es": "página {page}", + "pt": "página {page}", + }, + "place.st_all": { + "en": "every page", + "fr": "toutes les pages", + "nl": "elke pagina", + "de": "jede Seite", + "es": "todas las páginas", + "pt": "todas as páginas", + }, + "place.st_default": { + "en": "default position", + "fr": "position par défaut", + "nl": "standaardpositie", + "de": "Standardposition", + "es": "posición predeterminada", + "pt": "posição predefinida", + }, + "place.st_unplaced": { + "en": "not placed", + "fr": "non placée", + "nl": "niet geplaatst", + "de": "nicht platziert", + "es": "sin colocar", + "pt": "não posicionada", + }, + "place.st_error": { + "en": "⚠ unusable", + "fr": "⚠ inutilisable", + "nl": "⚠ onbruikbaar", + "de": "⚠ nicht verwendbar", + "es": "⚠ no utilizable", + "pt": "⚠ inutilizável", + }, + "place.all_pages": { + "en": "On every page (initials)", + "fr": "Sur toutes les pages (paraphe)", + "nl": "Op elke pagina (paraaf)", + "de": "Auf jeder Seite (Paraphe)", + "es": "En todas las páginas (rúbrica)", + "pt": "Em todas as páginas (rubrica)", + }, + "place.pos_all": { + "en": "Position: every page, ({x}, {y}) pt from the bottom-left corner.", + "fr": "Position : toutes les pages, ({x}, {y}) pt depuis le coin inférieur gauche.", + "nl": "Positie: elke pagina, ({x}, {y}) pt vanaf de linkerbenedenhoek.", + "de": "Position: jede Seite, ({x}, {y}) pt von der linken unteren Ecke.", + "es": "Posición: todas las páginas, ({x}, {y}) pt desde la esquina inferior izquierda.", + "pt": "Posição: todas as páginas, ({x}, {y}) pt a partir do canto inferior esquerdo.", + }, + "place.select_hint": { + "en": "Select an element in the list, then click on the page preview to place it.", + "fr": "Sélectionnez un élément dans la liste, puis cliquez sur l'aperçu de la page pour le placer.", + "nl": "Selecteer een element in de lijst en klik daarna op het paginavoorbeeld om het te plaatsen.", + "de": "Wählen Sie ein Element in der Liste aus und klicken Sie dann auf die Seitenvorschau, um es zu platzieren.", + "es": "Seleccione un elemento de la lista y luego haga clic en la vista previa de la página para colocarlo.", + "pt": "Selecione um elemento na lista e depois clique na pré-visualização da página para o posicionar.", + }, + "place.need_signature": { + "en": "Add at least one visual signature and keep it ticked.", + "fr": "Ajoutez au moins une signature visuelle et laissez-la cochée.", + "nl": "Voeg minstens één visuele handtekening toe en laat ze aangevinkt.", + "de": "Fügen Sie mindestens eine visuelle Signatur hinzu und lassen Sie das Häkchen gesetzt.", + "es": "Añada al menos una firma visual y manténgala marcada.", + "pt": "Adicione pelo menos uma assinatura visual e mantenha-a marcada.", + }, + "place.unplaced": { + "en": "“{name}” is not placed yet: select it, then click on the page preview.", + "fr": "« {name} » n'est pas encore placée : sélectionnez-la, puis cliquez sur l'aperçu de la page.", + "nl": "'{name}' is nog niet geplaatst: selecteer deze handtekening en klik daarna op het paginavoorbeeld.", + "de": "„{name}“ ist noch nicht platziert: Wählen Sie sie aus und klicken Sie dann auf die Seitenvorschau.", + "es": "«{name}» aún no está colocada: selecciónela y luego haga clic en la vista previa de la página.", + "pt": "«{name}» ainda não está posicionada: selecione-a e depois clique na pré-visualização da página.", + }, + "place.el_broken": { + "en": "“{name}” cannot be used: {error}", + "fr": "« {name} » est inutilisable : {error}", + "nl": "'{name}' kan niet worden gebruikt: {error}", + "de": "„{name}“ kann nicht verwendet werden: {error}", + "es": "«{name}» no se puede utilizar: {error}", + "pt": "«{name}» não pode ser utilizada: {error}", + }, + "place.save_failed": { + "en": "⚠ Your signatures could not be saved: {error}", + "fr": "⚠ Vos signatures n'ont pas pu être enregistrées : {error}", + "nl": "⚠ Uw handtekeningen konden niet worden opgeslagen: {error}", + "de": "⚠ Ihre Signaturen konnten nicht gespeichert werden: {error}", + "es": "⚠ No se pudieron guardar sus firmas: {error}", + "pt": "⚠ Não foi possível guardar as suas assinaturas: {error}", + }, + "place.storage_note": { + "en": "Signatures and preferences are saved unencrypted in your user folder.", + "fr": "Les signatures et les préférences sont enregistrées sans chiffrement dans votre dossier utilisateur.", + "nl": "Handtekeningen en voorkeuren worden onversleuteld opgeslagen in uw gebruikersmap.", + "de": "Signaturen und Einstellungen werden unverschlüsselt in Ihrem Benutzerordner gespeichert.", + "es": "Las firmas y las preferencias se guardan sin cifrar en su carpeta de usuario.", + "pt": "As assinaturas e as preferências são guardadas sem encriptação na sua pasta de utilizador.", + }, + # ------------------------------------------- step 6: signature editor + "sig.add_text": { + "en": "Add text…", + "fr": "Ajouter un texte…", + "nl": "Tekst toevoegen…", + "de": "Text hinzufügen…", + "es": "Añadir texto…", + "pt": "Adicionar texto…", + }, + "sig.add_image": { + "en": "Add image…", + "fr": "Ajouter une image…", + "nl": "Afbeelding toevoegen…", + "de": "Bild hinzufügen…", + "es": "Añadir imagen…", + "pt": "Adicionar imagem…", + }, + "sig.draw": { + "en": "Draw…", + "fr": "Dessiner…", + "nl": "Tekenen…", + "de": "Zeichnen…", + "es": "Dibujar…", + "pt": "Desenhar…", + }, + "sig.edit": { + "en": "Edit…", + "fr": "Modifier…", + "nl": "Bewerken…", + "de": "Bearbeiten…", + "es": "Editar…", + "pt": "Editar…", + }, + "sig.delete": { + "en": "Delete", + "fr": "Supprimer", + "nl": "Verwijderen", + "de": "Löschen", + "es": "Eliminar", + "pt": "Eliminar", + }, + "sig.delete_title": { + "en": "Delete this signature?", + "fr": "Supprimer cette signature ?", + "nl": "Deze handtekening verwijderen?", + "de": "Diese Signatur löschen?", + "es": "¿Eliminar esta firma?", + "pt": "Eliminar esta assinatura?", + }, + "sig.delete_body": { + "en": ( + "“{name}” will be removed from your saved signatures, together " + "with its stored image. This cannot be undone." + ), + "fr": ( + "« {name} » sera retirée de vos signatures enregistrées, ainsi que " + "son image stockée. Cette action est irréversible." + ), + "nl": ( + "'{name}' wordt uit uw opgeslagen handtekeningen verwijderd, samen " + "met de bijbehorende opgeslagen afbeelding. Dit kan niet ongedaan " + "worden gemaakt." + ), + "de": ( + "„{name}“ wird aus Ihren gespeicherten Signaturen entfernt, " + "zusammen mit dem zugehörigen gespeicherten Bild. Dies kann nicht " + "rückgängig gemacht werden." + ), + "es": ( + "«{name}» se eliminará de sus firmas guardadas, junto con su " + "imagen almacenada. Esta acción no se puede deshacer." + ), + "pt": ( + "«{name}» será removida das suas assinaturas guardadas, juntamente " + "com a respetiva imagem armazenada. Esta ação não pode ser " + "anulada." + ), + }, + "sig.delete_confirm": { + "en": "Delete", + "fr": "Supprimer", + "nl": "Verwijderen", + "de": "Löschen", + "es": "Eliminar", + "pt": "Eliminar", + }, + "sig.delete_keep": { + "en": "Keep it", + "fr": "Conserver", + "nl": "Behouden", + "de": "Behalten", + "es": "Conservar", + "pt": "Manter", + }, + "sig.text_title_add": { + "en": "New text signature", + "fr": "Nouvelle signature texte", + "nl": "Nieuwe teksthandtekening", + "de": "Neue Textsignatur", + "es": "Nueva firma de texto", + "pt": "Nova assinatura de texto", + }, + "sig.text_title_edit": { + "en": "Edit text signature", + "fr": "Modifier la signature texte", + "nl": "Teksthandtekening bewerken", + "de": "Textsignatur bearbeiten", + "es": "Editar la firma de texto", + "pt": "Editar a assinatura de texto", + }, + "sig.name": { + "en": "Name (optional):", + "fr": "Nom (facultatif) :", + "nl": "Naam (optioneel):", + "de": "Name (optional):", + "es": "Nombre (opcional):", + "pt": "Nome (opcional):", + }, + "sig.text": { + "en": "Text:", + "fr": "Texte :", + "nl": "Tekst:", + "de": "Text:", + "es": "Texto:", + "pt": "Texto:", + }, + "sig.text_hint": { + "en": ( + "Several lines are allowed. {date} is replaced by the date of the " + "run and {filename} by the name of each document." + ), + "fr": ( + "Plusieurs lignes sont possibles. {date} est remplacé par la date " + "de la signature et {filename} par le nom de chaque document." + ), + "nl": ( + "Meerdere regels zijn toegestaan. {date} wordt vervangen door de " + "datum van ondertekening en {filename} door de naam van elk " + "document." + ), + "de": ( + "Mehrere Zeilen sind möglich. {date} wird durch das Datum der " + "Signierung ersetzt und {filename} durch den Namen des jeweiligen " + "Dokuments." + ), + "es": ( + "Se admiten varias líneas. {date} se sustituye por la fecha de la " + "firma y {filename} por el nombre de cada documento." + ), + "pt": ( + "São permitidas várias linhas. {date} é substituído pela data da " + "assinatura e {filename} pelo nome de cada documento." + ), + }, + "sig.font": { + "en": "Font:", + "fr": "Police :", + "nl": "Lettertype:", + "de": "Schriftart:", + "es": "Fuente:", + "pt": "Tipo de letra:", + }, + "sig.font_custom": { + "en": "Other font file…", + "fr": "Autre fichier de police…", + "nl": "Ander lettertypebestand…", + "de": "Andere Schriftdatei…", + "es": "Otro archivo de fuente…", + "pt": "Outro ficheiro de tipo de letra…", + }, + "sig.size": { + "en": "Size (pt):", + "fr": "Taille (pt) :", + "nl": "Grootte (pt):", + "de": "Größe (pt):", + "es": "Tamaño (pt):", + "pt": "Tamanho (pt):", + }, + "sig.color": { + "en": "Colour:", + "fr": "Couleur :", + "nl": "Kleur:", + "de": "Farbe:", + "es": "Color:", + "pt": "Cor:", + }, + "sig.color_title": { + "en": "Signature colour", + "fr": "Couleur de la signature", + "nl": "Kleur van de handtekening", + "de": "Farbe der Signatur", + "es": "Color de la firma", + "pt": "Cor da assinatura", + }, + "sig.preview": { + "en": "Preview", + "fr": "Aperçu", + "nl": "Voorbeeld", + "de": "Vorschau", + "es": "Vista previa", + "pt": "Pré-visualização", + }, + "sig.preview_size": { + "en": "Size on the page: {w} × {h} pt", + "fr": "Taille sur la page : {w} × {h} pt", + "nl": "Grootte op de pagina: {w} × {h} pt", + "de": "Größe auf der Seite: {w} × {h} pt", + "es": "Tamaño en la página: {w} × {h} pt", + "pt": "Tamanho na página: {w} × {h} pt", + }, + "sig.save": { + "en": "Save", + "fr": "Enregistrer", + "nl": "Opslaan", + "de": "Speichern", + "es": "Guardar", + "pt": "Guardar", + }, + "sig.cancel": { + "en": "Cancel", + "fr": "Annuler", + "nl": "Annuleren", + "de": "Abbrechen", + "es": "Cancelar", + "pt": "Cancelar", + }, + "sig.err_text_empty": { + "en": "Enter some text.", + "fr": "Saisissez un texte.", + "nl": "Voer een tekst in.", + "de": "Geben Sie einen Text ein.", + "es": "Introduzca un texto.", + "pt": "Introduza um texto.", + }, + "sig.err_size": { + "en": "The size must be a number greater than 0, up to {max}.", + "fr": "La taille doit être un nombre supérieur à 0 et au plus égal à {max}.", + "nl": "De grootte moet een getal groter dan 0 en hoogstens {max} zijn.", + "de": "Die Größe muss eine Zahl größer als 0 und höchstens {max} sein.", + "es": "El tamaño debe ser un número mayor que 0 y como máximo {max}.", + "pt": "O tamanho deve ser um número superior a 0 e no máximo {max}.", + }, + "sig.err_render": { + "en": "This signature cannot be rendered: {error}", + "fr": "Impossible de générer cette signature : {error}", + "nl": "Deze handtekening kan niet worden weergegeven: {error}", + "de": "Diese Signatur kann nicht dargestellt werden: {error}", + "es": "No se puede generar esta firma: {error}", + "pt": "Não é possível gerar esta assinatura: {error}", + }, + "sig.draw_title": { + "en": "Draw a signature", + "fr": "Dessiner une signature", + "nl": "Een handtekening tekenen", + "de": "Signatur zeichnen", + "es": "Dibujar una firma", + "pt": "Desenhar uma assinatura", + }, + "sig.draw_hint": { + "en": "Draw with the mouse or a stylus in the box below. The background stays transparent.", + "fr": "Dessinez à la souris ou au stylet dans le cadre ci-dessous. Le fond reste transparent.", + "nl": "Teken met de muis of een stylus in het vak hieronder. De achtergrond blijft transparant.", + "de": "Zeichnen Sie mit der Maus oder einem Stift in das Feld unten. Der Hintergrund bleibt transparent.", + "es": "Dibuje con el ratón o un lápiz óptico en el recuadro inferior. El fondo se mantiene transparente.", + "pt": "Desenhe com o rato ou uma caneta digital na caixa abaixo. O fundo mantém-se transparente.", + }, + "sig.draw_clear": { + "en": "Clear", + "fr": "Effacer", + "nl": "Wissen", + "de": "Leeren", + "es": "Borrar", + "pt": "Limpar", + }, + "sig.err_draw_empty": { + "en": "Draw something first.", + "fr": "Dessinez d'abord quelque chose.", + "nl": "Teken eerst iets.", + "de": "Zeichnen Sie zuerst etwas.", + "es": "Dibuje algo primero.", + "pt": "Desenhe algo primeiro.", + }, + "sig.err_draw_too_large": { + "en": "This drawing is too large. Clear it and draw a smaller one.", + "fr": "Ce dessin est trop grand. Effacez-le et dessinez-en un plus petit.", + "nl": "Deze tekening is te groot. Wis ze en teken een kleinere.", + "de": "Diese Zeichnung ist zu groß. Löschen Sie sie und zeichnen Sie eine kleinere.", + "es": "Este dibujo es demasiado grande. Bórrelo y dibuje uno más pequeño.", + "pt": "Este desenho é demasiado grande. Apague-o e desenhe um mais pequeno.", + }, + "sig.drawn_label": { + "en": "Drawn signature", + "fr": "Signature dessinée", + "nl": "Getekende handtekening", + "de": "Gezeichnete Signatur", + "es": "Firma dibujada", + "pt": "Assinatura desenhada", + }, + "sig.image_title": { + "en": "Edit image signature", + "fr": "Modifier la signature image", + "nl": "Afbeeldingshandtekening bewerken", + "de": "Bildsignatur bearbeiten", + "es": "Editar la firma de imagen", + "pt": "Editar a assinatura de imagem", + }, + "sig.width": { + "en": "Width (pt):", + "fr": "Largeur (pt) :", + "nl": "Breedte (pt):", + "de": "Breite (pt):", + "es": "Ancho (pt):", + "pt": "Largura (pt):", + }, + "sig.err_width": { + "en": "The width must be a number greater than 0, up to {max}.", + "fr": "La largeur doit être un nombre supérieur à 0 et au plus égal à {max}.", + "nl": "De breedte moet een getal groter dan 0 en hoogstens {max} zijn.", + "de": "Die Breite muss eine Zahl größer als 0 und höchstens {max} sein.", + "es": "El ancho debe ser un número mayor que 0 y como máximo {max}.", + "pt": "A largura deve ser um número superior a 0 e no máximo {max}.", + }, + "sig.err_image": { + "en": "This image cannot be used: {error}", + "fr": "Cette image est inutilisable : {error}", + "nl": "Deze afbeelding kan niet worden gebruikt: {error}", + "de": "Dieses Bild kann nicht verwendet werden: {error}", + "es": "No se puede utilizar esta imagen: {error}", + "pt": "Não é possível utilizar esta imagem: {error}", + }, + "sig.err_store": { + "en": ( + "The signature could not be stored in your user folder, so it was " + "not added: {error}" + ), + "fr": ( + "La signature n'a pas pu être enregistrée dans votre dossier " + "utilisateur ; elle n'a donc pas été ajoutée : {error}" + ), + "nl": ( + "De handtekening kon niet in uw gebruikersmap worden opgeslagen en " + "is dus niet toegevoegd: {error}" + ), + "de": ( + "Die Signatur konnte nicht in Ihrem Benutzerordner gespeichert " + "werden und wurde daher nicht hinzugefügt: {error}" + ), + "es": ( + "No se pudo guardar la firma en su carpeta de usuario, por lo que " + "no se ha añadido: {error}" + ), + "pt": ( + "Não foi possível guardar a assinatura na sua pasta de utilizador, " + "pelo que não foi adicionada: {error}" + ), + }, # ------------------------------------------------------------- step 7 "run.summary_docs": { "en": "Documents to sign: {count}", @@ -1738,12 +2404,28 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "pt": "Pasta de saída: {output}", }, "run.summary_place": { - "en": "Placement: {place}", - "fr": "Positionnement : {place}", - "nl": "Plaatsing: {place}", - "de": "Platzierung: {place}", - "es": "Colocación: {place}", - "pt": "Posicionamento: {place}", + "en": "Signature vignette: {place}", + "fr": "Vignette de signature : {place}", + "nl": "Handtekeningvignet: {place}", + "de": "Signaturvignette: {place}", + "es": "Viñeta de firma: {place}", + "pt": "Vinheta de assinatura: {place}", + }, + "run.summary_stamps": { + "en": "Visual signatures: {count}", + "fr": "Signatures visuelles : {count}", + "nl": "Visuele handtekeningen: {count}", + "de": "Visuelle Signaturen: {count}", + "es": "Firmas visuales: {count}", + "pt": "Assinaturas visuais: {count}", + }, + "run.stamp_line": { + "en": " • {name} — {place}", + "fr": " • {name} — {place}", + "nl": " • {name} — {place}", + "de": " • {name} — {place}", + "es": " • {name} — {place}", + "pt": " • {name} — {place}", }, "run.place_custom": { "en": "page {page} @ ({x}, {y}) pt", @@ -1777,6 +2459,14 @@ def split_markup(text: str) -> list[tuple[str, bool]]: "es": "abajo a la derecha, {anchor} de cada documento", "pt": "em baixo à direita, {anchor} de cada documento", }, + "run.place_all": { + "en": "every page @ ({x}, {y}) pt", + "fr": "toutes les pages @ ({x}, {y}) pt", + "nl": "elke pagina @ ({x}, {y}) pt", + "de": "jede Seite @ ({x}, {y}) pt", + "es": "todas las páginas @ ({x}, {y}) pt", + "pt": "todas as páginas @ ({x}, {y}) pt", + }, "run.summary_anchor_last": { "en": "Files with a different page count are signed on their last page.", "fr": "Les fichiers dont le nombre de pages diffère sont signés sur leur dernière page.", diff --git a/i18n_docs.py b/i18n_docs.py index 4647fcc..136effb 100644 --- a/i18n_docs.py +++ b/i18n_docs.py @@ -41,12 +41,17 @@ "does. Trust is anchored on your organisation's internal CA.\n" " Use it when: you need a real cryptographic signature for many " "files quickly, without your physical card.\n\n" - "• **IMAGE — paste a picture only**\n" - " What it is: a visual stamp, NOT a cryptographic signature. No " - "legal value.\n" + "• **IMAGE — visual signatures only**\n" + " What it is: typed text (your choice of font and colour), an " + "image or a hand-drawn signature stamped on the pages. NOT a " + "cryptographic signature. No legal value.\n" " Requires: nothing; works fully offline.\n" - " Use it when: you only need a document to look signed, with no " - "legal effect.\n\n" + " Use it when: you only need a document to look signed, or to add " + "initials on every page.\n" + " Good to know: you can combine several elements per document, " + "and the same visual signatures can be added in BEID and AZURE " + "modes — they are stamped first and the cryptographic signature " + "then covers them.\n\n" "**Recommendation:** use BEID for legally binding documents, AZURE " "for large batches, and IMAGE only for appearance." ), @@ -75,12 +80,18 @@ " Quand l'utiliser : lorsque vous avez besoin d'une véritable " "signature cryptographique pour de nombreux fichiers, rapidement, " "sans votre carte physique.\n\n" - "• **IMAGE — coller une simple image**\n" - " Nature : un tampon visuel, PAS une signature cryptographique. " - "Aucune valeur juridique.\n" + "• **IMAGE — signatures visuelles uniquement**\n" + " Nature : un texte saisi (police et couleur au choix), une image " + "ou une signature dessinée à la main, apposés sur les pages. PAS " + "une signature cryptographique. Aucune valeur juridique.\n" " Prérequis : aucun ; fonctionne entièrement hors ligne.\n" " Quand l'utiliser : lorsque vous voulez seulement qu'un document " - "ait l'air signé, sans effet juridique.\n\n" + "ait l'air signé, ou pour ajouter un paraphe sur toutes les " + "pages.\n" + " Bon à savoir : vous pouvez combiner plusieurs éléments par " + "document, et les mêmes signatures visuelles peuvent être ajoutées " + "en modes BEID et AZURE — elles sont apposées en premier, puis la " + "signature cryptographique les couvre.\n\n" "**Recommandation :** utilisez BEID pour les documents " "juridiquement contraignants, AZURE pour les lots volumineux et " "IMAGE uniquement pour l'aspect visuel." @@ -112,12 +123,19 @@ " Gebruik deze modus wanneer: u snel een echte cryptografische " "handtekening voor veel bestanden nodig hebt, zonder uw fysieke " "kaart.\n\n" - "• **IMAGE — alleen een afbeelding plakken**\n" - " Wat het inhoudt: een visuele stempel, GEEN cryptografische " - "handtekening. Zonder juridische waarde.\n" + "• **IMAGE — alleen visuele handtekeningen**\n" + " Wat het inhoudt: getypte tekst (lettertype en kleur naar keuze), " + "een afbeelding of een met de hand getekende handtekening, " + "aangebracht op de pagina's. GEEN cryptografische handtekening. " + "Zonder juridische waarde.\n" " Vereist: niets; werkt volledig offline.\n" " Gebruik deze modus wanneer: een document er alleen ondertekend " - "moet uitzien, zonder juridisch effect.\n\n" + "moet uitzien, of om op elke pagina een paraaf te zetten.\n" + " Goed om te weten: u kunt meerdere elementen per document " + "combineren, en dezelfde visuele handtekeningen kunnen ook in de " + "modi BEID en AZURE worden toegevoegd — ze worden eerst " + "aangebracht en de cryptografische handtekening omvat ze " + "daarna.\n\n" "**Aanbeveling:** gebruik BEID voor juridisch bindende documenten, " "AZURE voor grote reeksen en IMAGE alleen als visuele stempel." ), @@ -147,12 +165,19 @@ "Ihrer Organisation verankert.\n" " Geeignet, wenn: Sie schnell eine echte kryptografische Signatur " "für viele Dateien benötigen, ohne Ihre physische Karte.\n\n" - "• **IMAGE — nur ein Bild einfügen**\n" - " Worum es sich handelt: ein sichtbarer Stempel, KEINE " - "kryptografische Signatur. Ohne Rechtswert.\n" + "• **IMAGE — nur visuelle Signaturen**\n" + " Worum es sich handelt: getippter Text (Schriftart und Farbe nach " + "Wahl), ein Bild oder eine von Hand gezeichnete Signatur, auf die " + "Seiten gestempelt. KEINE kryptografische Signatur. Ohne " + "Rechtswert.\n" " Erfordert: nichts; funktioniert vollständig offline.\n" - " Geeignet, wenn: ein Dokument lediglich signiert aussehen soll, " - "ohne rechtliche Wirkung.\n\n" + " Geeignet, wenn: ein Dokument lediglich signiert aussehen soll " + "oder jede Seite eine Paraphe erhalten soll.\n" + " Gut zu wissen: Sie können mehrere Elemente pro Dokument " + "kombinieren, und dieselben visuellen Signaturen lassen sich auch " + "in den Modi BEID und AZURE hinzufügen — sie werden zuerst " + "aufgebracht, und die kryptografische Signatur deckt sie " + "anschließend mit ab.\n\n" "**Empfehlung:** Verwenden Sie BEID für rechtsverbindliche " "Dokumente, AZURE für große Stapel und IMAGE nur, wenn es allein um " "das Erscheinungsbild geht." @@ -180,12 +205,17 @@ "interna de su organización.\n" " Úselo cuando: necesite una verdadera firma criptográfica para " "muchos archivos con rapidez, sin su tarjeta física.\n\n" - "• **IMAGE — solo pegar una imagen**\n" - " Qué es: un sello visual, NO una firma criptográfica. Sin valor " - "legal.\n" + "• **IMAGE — solo firmas visuales**\n" + " Qué es: un texto escrito (con la fuente y el color que elija), " + "una imagen o una firma dibujada a mano, estampados en las " + "páginas. NO es una firma criptográfica. Sin valor legal.\n" " Requiere: nada; funciona totalmente sin conexión.\n" - " Úselo cuando: solo necesite que un documento parezca firmado, " - "sin efecto legal.\n\n" + " Úselo cuando: solo necesite que un documento parezca firmado, o " + "para añadir una rúbrica en todas las páginas.\n" + " Conviene saber: puede combinar varios elementos por documento, " + "y las mismas firmas visuales pueden añadirse en los modos BEID y " + "AZURE — se estampan primero y la firma criptográfica las cubre " + "después.\n\n" "**Recomendación:** use BEID para documentos legalmente " "vinculantes, AZURE para lotes grandes e IMAGE solo por la " "apariencia." @@ -214,12 +244,18 @@ "interna da sua organização.\n" " Use-o quando: precisa de uma verdadeira assinatura criptográfica " "para muitos ficheiros, rapidamente e sem o seu cartão físico.\n\n" - "• **IMAGE — colar apenas uma imagem**\n" - " O que é: um carimbo visual, NÃO uma assinatura criptográfica. " - "Sem valor legal.\n" + "• **IMAGE — apenas assinaturas visuais**\n" + " O que é: um texto escrito (com o tipo de letra e a cor à sua " + "escolha), uma imagem ou uma assinatura desenhada à mão, aplicados " + "nas páginas. NÃO é uma assinatura criptográfica. Sem valor " + "legal.\n" " Requer: nada; funciona totalmente offline.\n" - " Use-o quando: basta que um documento pareça assinado, sem efeito " - "jurídico.\n\n" + " Use-o quando: basta que um documento pareça assinado, ou para " + "acrescentar uma rubrica em todas as páginas.\n" + " Convém saber: pode combinar vários elementos por documento, e " + "as mesmas assinaturas visuais podem ser adicionadas nos modos " + "BEID e AZURE — são aplicadas primeiro e a assinatura " + "criptográfica abrange-as em seguida.\n\n" "**Recomendação:** use BEID para documentos juridicamente " "vinculativos, AZURE para lotes grandes e IMAGE apenas para efeitos " "de aparência." @@ -399,9 +435,9 @@ "**AES VS QES: WHICH SIGNATURE DO I NEED?**\n\n" "Under EU law (eIDAS) there are three levels of electronic " "signature:\n\n" - "• **SES (simple)**: any electronic mark, even a pasted picture. " - "Easy, but weak proof of who signed. This is what \"image\" mode " - "produces — no legal value.\n\n" + "• **SES (simple)**: any electronic mark, even a pasted picture or a " + "typed name. Easy, but weak proof of who signed. This is what the " + "visual signatures of \"image\" mode produce — no legal value.\n\n" "• **AES (advanced)**: uniquely tied to one signer and to the exact " "document; any later change is detectable. This is \"azure\" mode.\n\n" "• **QES (qualified)**: an AES made with a certified device and a " @@ -433,8 +469,9 @@ "Le droit européen (eIDAS) distingue trois niveaux de signature " "électronique :\n\n" "• **SES (simple)** : toute marque électronique, même une image " - "collée. Facile, mais preuve faible de l'identité du signataire. " - "C'est ce que produit le mode « image » — aucune valeur juridique.\n\n" + "collée ou un nom saisi au clavier. Facile, mais preuve faible de " + "l'identité du signataire. C'est ce que produisent les signatures " + "visuelles du mode « image » — aucune valeur juridique.\n\n" "• **AES (avancée)** : liée de manière univoque à un seul " "signataire et au document lui-même ; toute modification ultérieure " "est détectable. C'est le mode « azure ».\n\n" @@ -471,8 +508,9 @@ "Volgens de EU-wetgeving (eIDAS) zijn er drie niveaus van " "elektronische handtekening:\n\n" "• **SES (eenvoudig)**: elk elektronisch merkteken, zelfs een " - "geplakte afbeelding. Gemakkelijk, maar een zwak bewijs van wie " - "heeft ondertekend. Dit is wat de modus 'image' oplevert — zonder " + "geplakte afbeelding of een getypte naam. Gemakkelijk, maar een " + "zwak bewijs van wie heeft ondertekend. Dit is wat de visuele " + "handtekeningen van de modus 'image' opleveren — zonder " "juridische waarde.\n\n" "• **AES (geavanceerd)**: uniek gekoppeld aan één ondertekenaar en " "aan het exacte document; elke latere wijziging is detecteerbaar. " @@ -509,9 +547,9 @@ "Nach EU-Recht (eIDAS) gibt es drei Stufen elektronischer " "Signaturen:\n\n" "• **SES (einfach)**: jedes elektronische Zeichen, selbst ein " - "eingefügtes Bild. Unkompliziert, aber nur ein schwacher Nachweis " - "dafür, wer signiert hat. Das erzeugt der Modus „image“ — ohne " - "Rechtswert.\n\n" + "eingefügtes Bild oder ein getippter Name. Unkompliziert, aber nur " + "ein schwacher Nachweis dafür, wer signiert hat. Das erzeugen die " + "visuellen Signaturen des Modus „image“ — ohne Rechtswert.\n\n" "• **AES (fortgeschritten)**: eindeutig einem Unterzeichner und " "genau diesem Dokument zugeordnet; jede spätere Änderung ist " "erkennbar. Das ist der Modus „azure“.\n\n" @@ -549,8 +587,9 @@ "Según la legislación de la UE (eIDAS) existen tres niveles de " "firma electrónica:\n\n" "• **SES (simple)**: cualquier marca electrónica, incluso una " - "imagen pegada. Fácil, pero con una prueba débil de quién firmó. Es " - "lo que produce el modo «image» — sin valor legal.\n\n" + "imagen pegada o un nombre escrito. Fácil, pero con una prueba " + "débil de quién firmó. Es lo que producen las firmas visuales del " + "modo «image» — sin valor legal.\n\n" "• **AES (avanzada)**: vinculada de forma única a un firmante y al " "documento exacto; cualquier cambio posterior es detectable. Es el " "modo «azure».\n\n" @@ -584,8 +623,9 @@ "Ao abrigo do direito da UE (eIDAS), existem três níveis de " "assinatura eletrónica:\n\n" "• **SES (simples)**: qualquer marca eletrónica, mesmo uma imagem " - "colada. Fácil, mas uma prova fraca de quem assinou. É o que o modo " - "«image» produz — sem valor legal.\n\n" + "colada ou um nome escrito. Fácil, mas uma prova fraca de quem " + "assinou. É o que as assinaturas visuais do modo «image» produzem " + "— sem valor legal.\n\n" "• **AES (avançada)**: ligada de forma única a um signatário e ao " "documento em causa; qualquer alteração posterior é detetável. É o " "modo «azure».\n\n" @@ -624,8 +664,9 @@ "Cachet signs PDF documents in batches. It can apply three kinds of " "mark: a cryptographic signature made with your Belgian eID card " "(beid), a cryptographic signature made with your personal " - "certificate held in Azure Key Vault (azure), or a simple pasted " - "image with no legal value (image). For the two cryptographic modes " + "certificate held in Azure Key Vault (azure), or visual signatures " + "with no legal value — typed text, an image or a drawing (image). " + "For the two cryptographic modes " "it follows the European PAdES standard and, after signing, " "re-checks each file and tells you exactly what was achieved. The " "terms below explain what those checks and labels mean.\n\n" @@ -742,6 +783,29 @@ "invisible cryptographic signature. You choose its position by " "clicking on the page preview; without a click it goes bottom-right " "on the last page.\n\n" + "**Visual signature**\n" + "A text, an image or a hand-drawn signature stamped on a page. It " + "is only an appearance: by itself it proves nothing. With eID or " + "Azure, Cachet stamps the visual signatures first and signs " + "afterwards, so the cryptographic signature covers them. A document " + "that is already signed cannot receive a visual signature without " + "breaking that earlier signature: with eID or Azure, Cachet refuses " + "such a document (sign it without visual signatures to add your " + "signature next to the existing one; if that earlier signature was " + "made by Cachet, use the command line with another --field name); " + "in image mode it stamps it and warns you in the report.\n\n" + "**Initials on every page**\n" + "A visual signature repeated at the same position on all pages of " + "each document (\"On every page\"). Not available for a batch whose " + "documents have different page counts.\n\n" + "**Signature library**\n" + "The visual signatures you created, their last position and your " + "preferences. They are saved unencrypted in your user folder and " + "reloaded at the next start; \"Delete\" removes a signature and its " + "stored image. PIN codes and sign-in tokens are never stored. To " + "erase everything, delete Cachet's configuration and data folders " + "in your user folder (a single folder on Windows and macOS; the " + "README gives their location).\n\n" "**Template validation**\n" "A safety check: before signing, every input PDF is compared to a " "model (\"template\") and must have the same page count and identical " @@ -764,8 +828,9 @@ "types de marques : une signature cryptographique réalisée avec " "votre carte eID belge (beid), une signature cryptographique " "réalisée avec votre certificat personnel conservé dans Azure Key " - "Vault (azure), ou une simple image collée sans valeur juridique " - "(image). Pour les deux modes cryptographiques, il suit la norme " + "Vault (azure), ou des signatures visuelles sans valeur juridique " + "— texte saisi, image ou dessin (image). " + "Pour les deux modes cryptographiques, il suit la norme " "européenne PAdES et, après la signature, revérifie chaque fichier " "et vous indique exactement ce qui a été obtenu. Les termes " "ci-dessous expliquent la signification de ces vérifications et de " @@ -898,6 +963,34 @@ "nu d'une signature cryptographique par ailleurs invisible. Vous " "choisissez sa position en cliquant sur l'aperçu de la page ; sans " "clic, elle ira en bas à droite de la dernière page.\n\n" + "**Signature visuelle**\n" + "Un texte, une image ou une signature dessinée à la main, apposés " + "sur une page. Ce n'est qu'une apparence : à elle seule, elle ne " + "prouve rien. Avec eID ou Azure, Cachet appose d'abord les " + "signatures visuelles et signe ensuite, de sorte que la signature " + "cryptographique les couvre. Un document déjà signé ne peut pas " + "recevoir de signature visuelle sans que cette signature antérieure " + "soit rompue : avec eID ou Azure, Cachet refuse un tel document " + "(signez-le sans signature visuelle pour ajouter votre signature à " + "côté de celle qui existe ; si cette dernière a été apposée par " + "Cachet, passez par la ligne de commande avec un autre nom " + "--field) ; en mode image, il y appose les signatures visuelles et " + "vous en avertit dans le rapport.\n\n" + "**Paraphe sur toutes les pages**\n" + "Une signature visuelle répétée à la même position sur toutes les " + "pages de chaque document (« Sur toutes les pages »). Indisponible " + "pour un lot dont les documents n'ont pas tous le même nombre de " + "pages.\n\n" + "**Bibliothèque de signatures**\n" + "Les signatures visuelles que vous avez créées, leur dernière " + "position et vos préférences. Elles sont enregistrées sans " + "chiffrement dans votre dossier utilisateur et rechargées au " + "prochain démarrage ; « Supprimer » retire une signature et son " + "image stockée. Les codes PIN et les jetons de connexion ne sont " + "jamais enregistrés. Pour tout effacer, supprimez les dossiers de " + "configuration et de données de Cachet dans votre dossier " + "utilisateur (un seul et même dossier sous Windows et macOS ; le " + "README indique leur emplacement).\n\n" "**Validation par rapport au modèle**\n" "Un contrôle de sécurité : avant la signature, chaque PDF à signer " "est comparé à un document de référence (le « modèle ») et doit " @@ -923,8 +1016,9 @@ "Cachet ondertekent PDF-documenten in reeksen. Het kan drie soorten " "merktekens aanbrengen: een cryptografische handtekening met uw " "Belgische eID-kaart (beid), een cryptografische handtekening met " - "uw persoonlijke certificaat in Azure Key Vault (azure), of een " - "eenvoudige geplakte afbeelding zonder juridische waarde (image). " + "uw persoonlijke certificaat in Azure Key Vault (azure), of " + "visuele handtekeningen zonder juridische waarde — getypte tekst, " + "een afbeelding of een tekening (image). " "Voor de twee cryptografische modi volgt het de Europese PAdES-norm " "en controleert het na het ondertekenen elk bestand opnieuw, " "waarbij het precies meldt wat is bereikt. De onderstaande termen " @@ -1058,6 +1152,34 @@ "een verder onzichtbare cryptografische handtekening. U kiest de " "positie door op het paginavoorbeeld te klikken; zonder klik komt " "het rechtsonder op de laatste pagina.\n\n" + "**Visuele handtekening**\n" + "Een tekst, een afbeelding of een met de hand getekende " + "handtekening die op een pagina wordt aangebracht. Het is alleen " + "een uiterlijk kenmerk: op zichzelf bewijst ze niets. Met eID of " + "Azure brengt Cachet eerst de visuele handtekeningen aan en " + "ondertekent het daarna, zodat de cryptografische handtekening ze " + "omvat. Een document dat al ondertekend is, kan geen visuele " + "handtekening krijgen zonder die eerdere handtekening ongeldig te " + "maken: met eID of Azure weigert Cachet zo'n document (onderteken " + "het zonder visuele handtekeningen om uw handtekening naast de " + "bestaande te plaatsen; is die eerdere handtekening door Cachet " + "gezet, gebruik dan de opdrachtregel met een andere --field-naam); " + "in de modus image brengt Cachet ze wel aan en waarschuwt het u in " + "het rapport.\n\n" + "**Paraaf op elke pagina**\n" + "Een visuele handtekening die op dezelfde positie wordt herhaald " + "op alle pagina's van elk document ('Op elke pagina'). Niet " + "beschikbaar voor een reeks waarvan de documenten een verschillend " + "aantal pagina's hebben.\n\n" + "**Handtekeningenbibliotheek**\n" + "De visuele handtekeningen die u hebt gemaakt, hun laatste positie " + "en uw voorkeuren. Ze worden onversleuteld in uw gebruikersmap " + "opgeslagen en bij de volgende start opnieuw geladen; " + "'Verwijderen' wist een handtekening en de bijbehorende opgeslagen " + "afbeelding. Pincodes en aanmeldtokens worden nooit opgeslagen. Om " + "alles te wissen, verwijdert u de configuratie- en de gegevensmap " + "van Cachet in uw gebruikersmap (onder Windows en macOS is dat één " + "enkele map; de README vermeldt de locatie).\n\n" "**Sjabloonvalidatie**\n" "Een veiligheidscontrole: vóór het ondertekenen wordt elke " "invoer-PDF vergeleken met een model ('sjabloon') en moet die " @@ -1083,8 +1205,9 @@ "Cachet signiert PDF-Dokumente stapelweise. Es kann drei Arten von " "Kennzeichnung anbringen: eine kryptografische Signatur mit Ihrer " "belgischen eID-Karte (beid), eine kryptografische Signatur mit " - "Ihrem persönlichen Zertifikat in Azure Key Vault (azure) oder ein " - "einfach eingefügtes Bild ohne Rechtswert (image). Für die beiden " + "Ihrem persönlichen Zertifikat in Azure Key Vault (azure) oder " + "visuelle Signaturen ohne Rechtswert — getippter Text, ein Bild " + "oder eine Zeichnung (image). Für die beiden " "kryptografischen Modi folgt es dem europäischen PAdES-Standard, " "prüft nach dem Signieren jede Datei erneut und teilt Ihnen genau " "mit, was erreicht wurde. Die folgenden Begriffe erklären, was " @@ -1220,6 +1343,33 @@ "kryptografischen Signatur. Die Position wählen Sie per Klick auf " "die Seitenvorschau; ohne Klick kommt sie unten rechts auf die " "letzte Seite.\n\n" + "**Visuelle Signatur**\n" + "Ein Text, ein Bild oder eine von Hand gezeichnete Signatur, die " + "auf eine Seite gestempelt wird. Sie ist nur ein Erscheinungsbild: " + "Für sich allein beweist sie nichts. Mit eID oder Azure bringt " + "Cachet zuerst die visuellen Signaturen auf und signiert danach, " + "sodass die kryptografische Signatur sie mit abdeckt. Ein bereits " + "signiertes Dokument kann keine visuelle Signatur erhalten, ohne " + "dass die frühere Signatur ungültig wird: Mit eID oder Azure lehnt " + "Cachet ein solches Dokument ab (signieren Sie es ohne visuelle " + "Signaturen, um Ihre Signatur neben die bestehende zu setzen; " + "stammt diese von Cachet, verwenden Sie die Kommandozeile mit " + "einem anderen --field-Namen); im Modus image stempelt Cachet es " + "und weist Sie im Bericht darauf hin.\n\n" + "**Paraphe auf jeder Seite**\n" + "Eine visuelle Signatur, die an derselben Position auf allen " + "Seiten jedes Dokuments wiederholt wird („Auf jeder Seite“). Nicht " + "verfügbar für einen Stapel, dessen Dokumente unterschiedliche " + "Seitenzahlen haben.\n\n" + "**Signaturbibliothek**\n" + "Die von Ihnen erstellten visuellen Signaturen, deren letzte " + "Position und Ihre Einstellungen. Sie werden unverschlüsselt in " + "Ihrem Benutzerordner gespeichert und beim nächsten Start wieder " + "geladen; „Löschen“ entfernt eine Signatur und ihr gespeichertes " + "Bild. PINs und Anmeldetoken werden niemals gespeichert. Um alles " + "zu löschen, entfernen Sie den Konfigurations- und den Datenordner " + "von Cachet in Ihrem Benutzerordner (unter Windows und macOS ein " + "und derselbe Ordner; die README nennt den Speicherort).\n\n" "**Vorlagenprüfung**\n" "Eine Sicherheitsprüfung: Vor dem Signieren wird jedes Eingabe-PDF " "mit einem Muster („Vorlage“) verglichen und muss dieselbe " @@ -1244,8 +1394,9 @@ "Cachet firma documentos PDF por lotes. Puede aplicar tres tipos de " "marca: una firma criptográfica realizada con su tarjeta eID belga " "(beid), una firma criptográfica realizada con su certificado " - "personal almacenado en Azure Key Vault (azure) o una simple imagen " - "pegada sin valor legal (image). En los dos modos criptográficos " + "personal almacenado en Azure Key Vault (azure) o firmas visuales " + "sin valor legal — texto escrito, una imagen o un dibujo (image). " + "En los dos modos criptográficos " "sigue la norma europea PAdES y, tras firmar, vuelve a comprobar " "cada archivo y le indica exactamente lo que se ha conseguido. Los " "términos siguientes explican el significado de esas comprobaciones " @@ -1374,6 +1525,31 @@ "firma criptográfica que, de otro modo, sería invisible. Su " "posición se elige haciendo clic en la vista previa de la página; " "si no hace clic, irá abajo a la derecha en la última página.\n\n" + "**Firma visual**\n" + "Un texto, una imagen o una firma dibujada a mano que se estampa " + "en una página. Es solo una apariencia: por sí sola no demuestra " + "nada. Con eID o Azure, Cachet estampa primero las firmas visuales " + "y firma después, de modo que la firma criptográfica las cubre. Un " + "documento que ya está firmado no puede recibir una firma visual " + "sin invalidar esa firma anterior: con eID o Azure, Cachet rechaza " + "ese documento (fírmelo sin firmas visuales para añadir su firma " + "junto a la existente; si esa firma anterior la hizo Cachet, use " + "la línea de comandos con otro nombre --field); en modo image lo " + "estampa y le avisa en el informe.\n\n" + "**Rúbrica en todas las páginas**\n" + "Una firma visual repetida en la misma posición en todas las " + "páginas de cada documento («En todas las páginas»). No está " + "disponible para un lote cuyos documentos tienen distinto número " + "de páginas.\n\n" + "**Biblioteca de firmas**\n" + "Las firmas visuales que ha creado, su última posición y sus " + "preferencias. Se guardan sin cifrar en su carpeta de usuario y se " + "vuelven a cargar en el siguiente inicio; «Eliminar» borra una " + "firma y su imagen almacenada. Los códigos PIN y los tokens de " + "inicio de sesión nunca se guardan. Para borrarlo todo, elimine " + "las carpetas de configuración y de datos de Cachet en su carpeta " + "de usuario (una sola carpeta en Windows y macOS; el README indica " + "su ubicación).\n\n" "**Validación con la plantilla**\n" "Una comprobación de seguridad: antes de firmar, cada PDF de " "entrada se compara con un modelo («plantilla») y debe tener el " @@ -1399,8 +1575,9 @@ "O Cachet assina documentos PDF em lote. Pode aplicar três tipos de " "marca: uma assinatura criptográfica feita com o seu cartão eID " "belga (beid), uma assinatura criptográfica feita com o seu " - "certificado pessoal guardado no Azure Key Vault (azure), ou uma " - "simples imagem colada sem valor legal (image). Para os dois modos " + "certificado pessoal guardado no Azure Key Vault (azure), ou " + "assinaturas visuais sem valor legal — texto escrito, uma imagem ou " + "um desenho (image). Para os dois modos " "criptográficos, segue a norma europeia PAdES e, depois de assinar, " "volta a verificar cada ficheiro e indica-lhe exatamente o que foi " "alcançado. Os termos abaixo explicam o significado dessas " @@ -1526,6 +1703,32 @@ "uma assinatura criptográfica que, de outro modo, seria invisível. " "A posição é escolhida clicando na pré-visualização da página; sem " "clique, a vinheta fica em baixo à direita na última página.\n\n" + "**Assinatura visual**\n" + "Um texto, uma imagem ou uma assinatura desenhada à mão, aplicados " + "numa página. É apenas uma aparência: por si só, não prova nada. " + "Com eID ou Azure, o Cachet aplica primeiro as assinaturas visuais " + "e assina depois, pelo que a assinatura criptográfica as abrange. " + "Um documento que já esteja assinado não pode receber uma " + "assinatura visual sem invalidar essa assinatura anterior: com eID " + "ou Azure, o Cachet recusa esse documento (assine-o sem " + "assinaturas visuais para acrescentar a sua assinatura ao lado da " + "existente; se essa assinatura anterior foi feita pelo Cachet, use " + "a linha de comandos com outro nome --field); no modo image, " + "aplica-as e avisa-o no relatório.\n\n" + "**Rubrica em todas as páginas**\n" + "Uma assinatura visual repetida na mesma posição em todas as " + "páginas de cada documento («Em todas as páginas»). Não está " + "disponível para um lote cujos documentos têm números de páginas " + "diferentes.\n\n" + "**Biblioteca de assinaturas**\n" + "As assinaturas visuais que criou, a respetiva última posição e as " + "suas preferências. São guardadas sem encriptação na sua pasta de " + "utilizador e recarregadas no arranque seguinte; «Eliminar» remove " + "uma assinatura e a respetiva imagem armazenada. Os códigos PIN e " + "os tokens de início de sessão nunca são guardados. Para apagar " + "tudo, elimine as pastas de configuração e de dados do Cachet na " + "sua pasta de utilizador (uma única pasta em Windows e macOS; o " + "README indica a sua localização).\n\n" "**Validação face ao modelo**\n" "Uma verificação de segurança: antes de assinar, cada PDF de " "entrada é comparado com um documento de referência (o «modelo») e " diff --git a/profile_store.py b/profile_store.py new file mode 100644 index 0000000..56fd983 --- /dev/null +++ b/profile_store.py @@ -0,0 +1,470 @@ +"""User profile and signature image store for the Cachet GUI. + +What is persisted, in the user folder, so nothing has to be reconfigured at +the next start: + +- ``/profile.json`` — the signature library (content + last + placement of each visual signature) and the wizard settings (language, + mode, PAdES level, output folder, azure vault URL / auth method / + trust-anchors path, beID vignette placement); +- ``/signatures/`` — a copy of every imported or drawn image, under + a content-addressed name, so a moved original does not break a signature. + +NEVER persisted: PIN, tokens, any credential, the azure key name, the +signed-in UPN, the template, the input files. Everything is stored +UNENCRYPTED. + +Design notes (deliberate choices): + +- Directories: explicit argument > ``CACHET_CONFIG_DIR`` / ``CACHET_DATA_DIR`` + > ``platformdirs``. Tests must always pass explicit temp dirs (or set the + env vars) — never touch the real user profile. +- ``load()`` never raises: a missing file gives defaults, a corrupt or + other-version file is moved aside as ``profile.json.bak`` (so a later save + cannot destroy it silently), a bad entry is skipped and the others kept. + A profile that could neither be read nor moved aside stays in place, and + ``save()`` then refuses to overwrite it until a later ``load()`` succeeds. +- Writes are atomic (temp file in the same directory + ``os.replace``). +- The only code that deletes anything is ``remove_signature``, for the one + stored image of the one signature being deleted, and only when + ``is_store_file`` says the file belongs to the store. There is no sweep of + unreferenced images: the data dir may serve another profile, and a + ``.bak`` still references its images. + +This module must stay importable without tkinter. It is used by the GUI only: +the core/CLI never reads the profile (headless runs stay deterministic). +""" + +from __future__ import annotations + +import dataclasses +import hashlib +import io +import json +import math +import os +import re +import tempfile +import uuid +from pathlib import Path + +import stamps as stamplib + +PROFILE_VERSION = 1 +APP_NAME = "Cachet" +PROFILE_FILENAME = "profile.json" +SIGNATURES_DIRNAME = "signatures" +ENV_CONFIG_DIR = "CACHET_CONFIG_DIR" +ENV_DATA_DIR = "CACHET_DATA_DIR" + +MAX_LABEL_LEN = 80 +_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tif", ".tiff") +# Names import_image / store_image produce: 32 hex digits of the sha256 + extension. +_STORE_NAME_RE = re.compile(r"[0-9a-f]{32}\.[a-z0-9]+") +_ID_RE = re.compile(r"[0-9a-f]{12}") +# validate_stamp messages meaning "the content is fine but its file is not +# there (any more)": such an entry is kept, the GUI shows it as unusable. +_MISSING_FILE_PREFIXES = ( + "image not found", "unknown font", "bundled font file missing", "cannot load font") +_SETTING_KEYS = ( + "language", "mode", "pades_level", "output_dir", + "azure_vault_url", "azure_auth", "azure_trust_anchors") + + +# --------------------------------------------------------------------------- +# Directories and ids +# --------------------------------------------------------------------------- + +def _resolve_dir(explicit, env_name: str, kind: str) -> Path: + if explicit is not None: + return Path(explicit) + env = os.environ.get(env_name) + if env: + return Path(env) + import platformdirs # lazy: only the default location needs it + + return Path(getattr(platformdirs, f"user_{kind}_dir")(APP_NAME, appauthor=False)) + + +def resolve_config_dir(explicit=None) -> Path: + """Directory of ``profile.json``: ``explicit`` > ``CACHET_CONFIG_DIR`` > + the platform's user config dir. Never creates it.""" + return _resolve_dir(explicit, ENV_CONFIG_DIR, "config") + + +def resolve_data_dir(explicit=None) -> Path: + """Directory holding ``signatures/``: ``explicit`` > ``CACHET_DATA_DIR`` > + the platform's user data dir. Never creates it.""" + return _resolve_dir(explicit, ENV_DATA_DIR, "data") + + +def new_id() -> str: + return uuid.uuid4().hex[:12] + + +def is_valid_id(value) -> bool: + """12 lower-case hex digits — so the reserved GUI element id + ``"vignette"`` can never be a signature id.""" + return isinstance(value, str) and _ID_RE.fullmatch(value) is not None + + +# --------------------------------------------------------------------------- +# Placement rules (pure) +# --------------------------------------------------------------------------- + +def page_placement_applies(page, placed_on, template_dims, anchor=None) -> bool: + """Is a placement saved for the 1-based ``page`` in force on this + template? Only when that page exists, is the anchor page (if an anchor + applies) and has EXACTLY the ``(w, h)`` the element was placed on.""" + if page is None or placed_on is None or not template_dims: + return False + n = len(template_dims) + idx = page - 1 + if not 0 <= idx < n: + return False + if anchor is not None and idx != (0 if anchor == "first" else n - 1): + return False + return tuple(placed_on) == tuple(template_dims[idx]) + + +def stamp_placement_applies(stamp, placed_on, template_dims, anchor=None) -> bool: + """Full rule for a ``Stamp``: numbered page, first/last anchor or every + page (then any page of the template may match, or the anchor page when + the batch is forced onto one).""" + if stamp.x is None or stamp.y is None or placed_on is None or not template_dims: + return False + n = len(template_dims) + dims = [tuple(d) for d in template_dims] + po = tuple(placed_on) + if stamp.all_pages: + if anchor is not None: # forced onto the anchor page + return po == dims[0 if anchor == "first" else n - 1] + return po in dims + if stamp.page_anchor is not None: + page = 1 if stamp.page_anchor == "first" else n + else: + page = stamp.page + return page_placement_applies(page, placed_on, template_dims, anchor) + + +# --------------------------------------------------------------------------- +# Model +# --------------------------------------------------------------------------- + +@dataclasses.dataclass +class Placement: # vignette placement + page: int + x: float + y: float + placed_on: tuple[float, float] + + +@dataclasses.dataclass +class Settings: # None = "never set by the user" -> env/defaults apply + language: str | None = None + mode: str | None = None + pades_level: str | None = None + output_dir: str | None = None + azure_vault_url: str | None = None + azure_auth: str | None = None + azure_trust_anchors: str | None = None + vignette: Placement | None = None + + +@dataclasses.dataclass +class Signature: # one library entry + id: str + label: str + stamp: stamplib.Stamp # content + last placement (page target, x, y) + enabled: bool = True + placed_on: tuple[float, float] | None = None # (w, h) of the page it was placed on + + +@dataclasses.dataclass +class Profile: + settings: Settings = dataclasses.field(default_factory=Settings) + signatures: list[Signature] = dataclasses.field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Tolerant parsing helpers (never raise) +# --------------------------------------------------------------------------- + +def _finite(value) -> float | None: + """``value`` as a finite float, else None. A bool is never a number.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + try: + number = float(value) + except OverflowError: # an integer too large for a float + return None + return number if math.isfinite(number) else None + + +def _finite_pair(value) -> tuple[float, float] | None: + if not isinstance(value, (list, tuple)) or len(value) != 2: + return None + w, h = _finite(value[0]), _finite(value[1]) + return None if w is None or h is None else (w, h) + + +def _parse_vignette(data) -> Placement | None: + if not isinstance(data, dict): + return None + page = data.get("page") + x, y = _finite(data.get("x")), _finite(data.get("y")) + placed_on = _finite_pair(data.get("placed_on")) + if isinstance(page, bool) or not isinstance(page, int) or page < 1: + return None + if x is None or y is None or placed_on is None: + return None + return Placement(page=page, x=x, y=y, placed_on=placed_on) + + +def _parse_settings(data) -> Settings: + if not isinstance(data, dict): + return Settings() + settings = Settings() + for key in _SETTING_KEYS: + if isinstance(data.get(key), str): + setattr(settings, key, data[key]) + settings.vignette = _parse_vignette(data.get("vignette")) + return settings + + +def _resolved(path) -> Path | None: + try: + return Path(path).resolve() + except (OSError, RuntimeError, ValueError): + return None + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + +def _atomic_write(path: Path, data: bytes) -> None: + """Write ``data`` to ``path`` through a temp file in the same directory + + ``os.replace``: a crash mid-write never leaves a truncated file. The file + is created 0600 on POSIX (``mkstemp``).""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") + try: + with os.fdopen(fd, "wb") as fh: + fh.write(data) + os.replace(tmp, path) + except OSError: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +class ProfileStore: + """Reads and writes one user profile and its image store.""" + + def __init__(self, config_dir=None, data_dir=None): + # Resolved ONCE (the env is read here). Nothing is created on disk. + self.config_dir = resolve_config_dir(config_dir) + self.data_dir = resolve_data_dir(data_dir) + # True while the profile on disk was neither read nor moved aside: + # saving the (empty) in-memory profile would destroy it. + self._keep_file = False + + @property + def profile_path(self) -> Path: + return self.config_dir / PROFILE_FILENAME + + @property + def signatures_dir(self) -> Path: + return self.data_dir / SIGNATURES_DIRNAME + + # -- profile ------------------------------------------------------------ + + def load(self) -> Profile: + """Tolerant load: never raises. See the module docstring.""" + self._keep_file = False + # utf-8-sig: a BOM added by an editor (or PowerShell 5) is not corruption. + try: + raw = self.profile_path.read_text(encoding="utf-8-sig") + except FileNotFoundError: + return Profile() + except OSError: + self._keep_file = True # unreadable: left alone + return Profile() + except ValueError: # UnicodeDecodeError + return self._set_aside() + try: + data = json.loads(raw) + except (ValueError, RecursionError): # bad JSON / pathological nesting + return self._set_aside() + if not isinstance(data, dict): + return self._set_aside() + version = data.get("version") + if isinstance(version, bool) or not isinstance(version, int): + return self._set_aside() + if version != PROFILE_VERSION: + return self._set_aside() # e.g. written by a newer Cachet + + profile = Profile(settings=_parse_settings(data.get("settings"))) + entries = data.get("signatures") + used_ids: set[str] = set() + for entry in entries if isinstance(entries, list) else []: + sig = self._parse_signature(entry, used_ids) + if sig is not None: + used_ids.add(sig.id) + profile.signatures.append(sig) + return profile + + def _set_aside(self) -> Profile: + """Move an unusable profile to ``profile.json.bak`` and start afresh.""" + try: + os.replace(self.profile_path, self.profile_path.with_name(PROFILE_FILENAME + ".bak")) + except OSError: + self._keep_file = True # still in place: not to be overwritten + return Profile() + + def _parse_signature(self, entry, used_ids: set[str]) -> Signature | None: + """One library entry, or None when it has to be skipped.""" + if not isinstance(entry, dict): + return None + try: + stamp = stamplib.Stamp.from_dict(entry, base_dir=self.signatures_dir) + except stamplib.StampError: + return None + try: + stamplib.validate_stamp(stamp, placed=False) + except stamplib.StampError as exc: + # Malformed content is dropped; a missing image/font file is kept + # (shown as unusable, the user can delete it). + if not str(exc).startswith(_MISSING_FILE_PREFIXES): + return None + + placed_on = None + try: + stamplib.validate_placement(stamp) + placed_on = _finite_pair(entry.get("placed_on")) + except stamplib.StampError: + # Unplaced, or an incomplete/invalid placement: cleared. The one + # exception is an unplaced signature that remembers "every page". + remembers_all_pages = ( + stamp.all_pages and stamp.page is None and stamp.page_anchor is None + and stamp.x is None and stamp.y is None) + if not remembers_all_pages: + stamp = dataclasses.replace( + stamp, page=None, page_anchor=None, all_pages=False, x=None, y=None) + + sig_id = entry.get("id") + if not is_valid_id(sig_id) or sig_id in used_ids: + sig_id = new_id() + while sig_id in used_ids: + sig_id = new_id() + label = entry.get("label") + if not isinstance(label, str) or not label.strip(): + label = stamp.content_label() + if stamp.kind == "text": + label = label[1:-1] # without the quotes + enabled = entry.get("enabled") + return Signature( + id=sig_id, label=label[:MAX_LABEL_LEN], stamp=stamp, + enabled=enabled if isinstance(enabled, bool) else True, + placed_on=placed_on) + + def _payload(self, profile: Profile) -> dict: + settings: dict = {} + for key in _SETTING_KEYS: + value = getattr(profile.settings, key) + if value is not None: + settings[key] = value + v = profile.settings.vignette + if v is not None: + settings["vignette"] = { + "page": v.page, "x": v.x, "y": v.y, "placed_on": list(v.placed_on)} + signatures = [] + for sig in profile.signatures: + entry = {"id": sig.id, "label": sig.label, "enabled": sig.enabled} + entry.update(sig.stamp.to_dict(base_dir=self.signatures_dir)) + if sig.placed_on is not None: + entry["placed_on"] = list(sig.placed_on) + signatures.append(entry) + return {"version": PROFILE_VERSION, "settings": settings, "signatures": signatures} + + def save(self, profile: Profile) -> None: + """Atomic write of ``profile.json`` (UTF-8, no BOM). Raises ``OSError`` + — also when the last ``load()`` had to leave an unusable file in place.""" + if self._keep_file: + raise OSError(f"{self.profile_path} could not be read and is left untouched") + text = json.dumps(self._payload(profile), ensure_ascii=False, indent=2) + # A lone surrogate (a folder name that is not valid UTF-8, a "\ud800" + # typed into the file) cannot be encoded: backslashreplace writes it + # as \udXXX, which is precisely its JSON escape — the file stays valid + # UTF-8, loads back identically, and save never raises a ValueError. + _atomic_write(self.profile_path, text.encode("utf-8", errors="backslashreplace")) + + # -- image store -------------------------------------------------------- + + def _store_bytes(self, data: bytes, ext: str) -> Path: + """Content-addressed write: the same bytes always land in the same + file, which is left untouched when it already exists.""" + target = self.signatures_dir / f"{hashlib.sha256(data).hexdigest()[:32]}{ext}" + if not target.exists(): + _atomic_write(target, data) + return target + + def import_image(self, source) -> Path: + """Copy an image file into the store and return the stored path. + ``StampError`` if it is not a readable image (nothing is written), + ``OSError`` if the copy cannot be written.""" + source = Path(source) + stamplib.load_image(source) # validate first + ext = source.suffix.lower() + return self._store_bytes( + source.read_bytes(), ext if ext in _IMAGE_EXTENSIONS else ".img") + + def store_image(self, image) -> Path: + """Store a PIL image (a drawn signature) as a PNG; returns its path.""" + buf = io.BytesIO() + image.save(buf, format="PNG") + return self._store_bytes(buf.getvalue(), ".png") + + def is_store_file(self, path) -> bool: + """THE predicate for "this file belongs to the image store": not a + symlink, a DIRECT child of ``signatures_dir`` by RESOLVED paths + (pathlib's ``is_relative_to`` is lexical: ``dir/../../x`` would count + as inside), and a content-addressed name.""" + try: + path = Path(path) + if path.is_symlink(): + return False + resolved = path.resolve() + return (resolved.parent == self.signatures_dir.resolve() + and _STORE_NAME_RE.fullmatch(resolved.name) is not None) + except (OSError, RuntimeError, ValueError): + return False + + def remove_signature(self, profile: Profile, sig_id: str) -> None: + """Remove a library entry (no-op if absent) and, when it was an image + living in the store that no REMAINING signature references, its file. + Anything that is not a store file is never unlinked. Does NOT save.""" + for index, sig in enumerate(profile.signatures): + if sig.id == sig_id: + break + else: + return + del profile.signatures[index] + path = sig.stamp.image_path + if sig.stamp.kind != "image" or path is None or not self.is_store_file(path): + return + target = _resolved(path) + if target is None: + return + for other in profile.signatures: + other_path = other.stamp.image_path + if (other.stamp.kind == "image" and other_path is not None + and _resolved(other_path) == target): + return # still in use + try: + target.unlink() + except OSError: + pass diff --git a/sign_pdfs_beid.py b/sign_pdfs_beid.py index 21581e2..1e71466 100644 --- a/sign_pdfs_beid.py +++ b/sign_pdfs_beid.py @@ -28,10 +28,11 @@ # merging develop -> main triggers the release workflow, which tags # v{__version__} and publishes the binaries (see .github/workflows/release.yml # and BUILD.md "Release process"). -__version__ = "1.3.1" +__version__ = "1.4.0" import argparse import dataclasses +import datetime import io import os import platform @@ -39,6 +40,7 @@ import sys import tempfile import warnings +from collections.abc import Sequence from pathlib import Path import requests @@ -53,7 +55,6 @@ from pyhanko.pdf_utils import images from pyhanko.pdf_utils.layout import ( AxisAlignment, - BoxConstraints, InnerScaling, Margins, SimpleBoxLayoutRule, @@ -63,9 +64,17 @@ from pyhanko.sign.validation import validate_pdf_signature from pyhanko.sign.validation.dss import DocumentSecurityStore from pyhanko.sign.validation.errors import NoDSSFoundError -from pyhanko.stamp import StaticStampStyle, TextStampStyle +from pyhanko.stamp import TextStampStyle from pyhanko_certvalidator import ValidationContext +# Visual signatures (text / image stamps). NAMES are imported, never the +# module object: `stamps` is also a RunConfig field and a sign_one() keyword. +from stamps import ( + BUNDLED_FONTS, DEFAULT_COLOR, DEFAULT_FONT, DEFAULT_FONT_SIZE, PAGE_ANCHORS, + Stamp, StampError, apply_stamps, existing_signature_count, load_stamps_file, + page_mediabox, validate_stamp, +) + # Since pyHanko >= 0.22, Belgian eID support lives in the separate plugin. # On a very old install (< 0.22), replace with: # from pyhanko.sign.beid import BEIDSigner @@ -285,33 +294,22 @@ def load_trust_anchor_certs(path) -> list: return certs -def _page_mediabox(writer, page_index: int) -> list[float]: - """MediaBox of page `page_index` (0-based, -1 = last), with inheritance - from the page tree.""" - page_ref, _ = writer.find_page_for_modification(page_index) - node = page_ref.get_object() - for _ in range(50): - if "/MediaBox" in node: - mb = node.raw_get("/MediaBox").get_object() - return [ - float(v.get_object() if hasattr(v, "get_object") else v) for v in mb - ] - parent = node.get("/Parent") - if parent is None: - break - node = parent.get_object() - return [0.0, 0.0, 595.276, 841.89] # A4 default +def default_vignette_rect(page_w: float, page_h: float) -> tuple[float, float, float, float]: + """(x, y, w, h) of the default bottom-right vignette, relative to the page's + bottom-left corner.""" + x1 = page_w - _STAMP_MARGIN + x0 = max(4, x1 - _STAMP_W) + y0 = _STAMP_MARGIN + y1 = min(y0 + _STAMP_H, page_h - 4) + return (x0, y0, x1 - x0, y1 - y0) def _default_vignette_box(writer, page_index: int = -1) -> tuple[float, float, float, float]: """Vignette rectangle, anchored bottom-right of page ``page_index`` (0-based, -1 = last page — the historical default).""" - mb = _page_mediabox(writer, page_index) - x1 = mb[2] - _STAMP_MARGIN - x0 = max(mb[0] + 4, x1 - _STAMP_W) - y0 = mb[1] + _STAMP_MARGIN - y1 = min(y0 + _STAMP_H, mb[3] - 4) - return (x0, y0, x1, y1) + mb = page_mediabox(writer, page_index) + x, y, w, h = default_vignette_rect(mb[2] - mb[0], mb[3] - mb[1]) + return (mb[0] + x, mb[1] + y, mb[0] + x + w, mb[1] + y + h) def build_stamp_style( @@ -488,10 +486,21 @@ def sign_one( legacy_cms: bool = False, timestamper: timestamps.TimeStamper | None = None, validation_context: ValidationContext | None = None, + stamps: Sequence[Stamp] = (), + stamp_date: datetime.date | None = None, + stamp_cache: dict | None = None, ) -> None: """Sign a PDF (incremental signature) with any pyHanko ``Signer`` and stamp the visible vignette onto it ("Signed by ..." + optional photo). + ``stamps`` are the visual signatures (text / image) of the run: they are + applied into the SAME incremental writer BEFORE the signature, so the + signature covers them — there is never a second pass on ``dst``. + ``stamp_date`` is the run date for ``{date}`` and ``stamp_cache`` the + batch-wide cache of rendered content. With stamps, an input that already + carries a signature or a document timestamp is refused (``StampError``) + before anything is signed; without stamps it is countersigned as before. + ``pades_level`` selects the PAdES baseline level (see ``signature_meta_kwargs``); ``timestamper`` must be provided for levels >= b-t and ``validation_context`` for b-lt/b-lta (both are built once per @@ -519,13 +528,27 @@ def sign_one( # refuses to sign them # ("hybrid cross-reference sections while hybrid xrefs are disabled"). writer = IncrementalPdfFileWriter(inf, strict=False) + if stamps: + # Stamps rewrite page content: on an input that already carries a + # signature or a document timestamp they would invalidate it, and + # self-verification (last signature only) would not notice. + if existing_signature_count(writer): + raise StampError( + "the document is already signed: a visual signature would " + "invalidate the existing signature(s) (sign it without visual " + "signatures to countersign)") + # Visual signatures go into THIS writer, before the signature. + # Stamping the signed output afterwards would be flagged as a + # modification of the signed document. + apply_stamps(writer, stamps, filename=src.stem, date=stamp_date, + cache=stamp_cache) if pos is None: on_page = page_index if page_index is not None else -1 box = _default_vignette_box(writer, on_page) style = build_stamp_style(identity) else: on_page = page_index if page_index is not None else -1 - mb = _page_mediabox(writer, on_page) + mb = page_mediabox(writer, on_page) vw, vh = vignette_size_pt(mb[2] - mb[0]) x0, y0 = mb[0] + pos[0], mb[1] + pos[1] box = (x0, y0, x0 + vw, y0 + vh) @@ -551,7 +574,6 @@ def sign_one( # batch contain documents whose page count differs from the template: the # anchor page must still match the template's anchor page exactly, so the # chosen (x, y) is guaranteed to fit the page that carries the signature. -PAGE_ANCHORS = ("first", "last") def anchor_page_index(page_anchor: str) -> int: @@ -599,15 +621,22 @@ def validate_against_template( pdf_path, *, page_anchor: str | None = None, + anchors=None, + blocker: str | None = None, ) -> ValidationResult: """Check that a PDF has the SAME page count AND per-page dimensions EXACTLY identical to the template (no tolerance). - With ``page_anchor`` ("first"/"last"), a file whose page count DIFFERS - from the template is accepted iff its anchor page has exactly the - template's anchor-page dimensions — that is the page the signature lands - on, so the chosen position is guaranteed to fit it. Files with the - template's page count keep the full strict check. + A file whose page count DIFFERS from the template is accepted iff EVERY + placed element targets a page anchor ("first"/"last") and each of those + anchor pages has exactly the template's anchor-page dimensions — those + are the pages the elements land on, so the chosen positions are + guaranteed to fit them. ``page_anchor`` names a single anchor; + ``anchors`` (a collection of anchors, see ``anchor_requirements``) + replaces it when given. ``blocker`` is a phrase naming an element that + does NOT target first/last: it rejects every page-count mismatch, with + that reason. Files with the template's page count keep the full strict + check. """ path = Path(pdf_path) try: @@ -616,20 +645,29 @@ def validate_against_template( return ValidationResult(path, False, f"unreadable ({exc})") if len(dims) != len(template_dims): count_msg = f"{len(dims)} page(s), the template has {len(template_dims)}" - if page_anchor is None or not dims or not template_dims: - return ValidationResult(path, False, count_msg) - idx = anchor_page_index(page_anchor) - d, t = dims[idx], template_dims[idx] - if d != t: + names = (tuple(a for a in PAGE_ANCHORS if a in anchors) if anchors is not None + else ((page_anchor,) if page_anchor else ())) + if blocker: return ValidationResult( path, False, - f"{count_msg}; {page_anchor} page {d[0]:.2f}×{d[1]:.2f} pt " - f"≠ template {t[0]:.2f}×{t[1]:.2f} pt", + f"{count_msg}; {blocker} (only first/last page targets accept " + "a different page count)", ) - return ValidationResult( - path, True, f"{count_msg} — signed on the {page_anchor} page" - ) + if not names or not dims or not template_dims: + return ValidationResult(path, False, count_msg) + for name in names: # EVERY anchor page must match + idx = anchor_page_index(name) + d, t = dims[idx], template_dims[idx] + if d != t: + return ValidationResult( + path, + False, + f"{count_msg}; {name} page {d[0]:.2f}×{d[1]:.2f} pt " + f"≠ template {t[0]:.2f}×{t[1]:.2f} pt", + ) + where = f"{names[0]} page" if len(names) == 1 else "first and last pages" + return ValidationResult(path, True, f"{count_msg} — signed on the {where}") for i, (d, t) in enumerate(zip(dims, template_dims), start=1): if d != t: return ValidationResult( @@ -652,49 +690,31 @@ def validate_files( # ========================================================================= -# "image" mode: inserting a signature image +# "image" mode: visual signatures only (text / image stamps) # ========================================================================= -_IMG_TARGET_W_PT = 150.0 # default insertion width (points, ratio preserved) - - -def image_size_pt(image_path) -> tuple[float, float]: - """Size (width, height) in points of the inserted image: fixed width - (_IMG_TARGET_W_PT), height derived from the ratio.""" - with Image.open(image_path) as im: - w_px, h_px = im.size - return (_IMG_TARGET_W_PT, _IMG_TARGET_W_PT * h_px / w_px) - - -def insert_image_one(src, dst, image_path, page_index: int, x: float, y: float) -> None: - """Insert the image on page `page_index` (0-based, -1 = last) with its - lower-left corner at (x, y) points from the page's lower-left corner. - Incremental update — no card required.""" - img = Image.open(image_path) - img.load() - w_pt, h_pt = image_size_pt(image_path) - n_pages = len(page_dimensions(src)) # clear message if the page is out of range - if not (-n_pages <= page_index < n_pages): - shown = page_index + 1 if page_index >= 0 else page_index - raise ValueError(f"page {shown} out of range (the document has {n_pages} page(s))") - with open(src, "rb") as inf: +def apply_stamps_one(src, dst, stamp_list, *, date=None, cache=None) -> bool: + """Visual-only path (image mode): apply every stamp, write ONE incremental update. + `dst` is written only when everything succeeded (no partial output). No card required. + + Returns True when the input already carried a signature or a document timestamp: + the stamps then invalidate it (historical behaviour of image mode, kept) and the + caller must say so.""" + if not stamp_list: + raise StampError("no visual signature to apply") + src = Path(src) + with src.open("rb") as inf: writer = IncrementalPdfFileWriter(inf, strict=False) - mb = _page_mediabox(writer, page_index) # (x, y) relative to the page corner - style = StaticStampStyle( - background=images.PdfImage(img), - background_opacity=1.0, - background_layout=SimpleBoxLayoutRule( - x_align=AxisAlignment.ALIGN_MID, - y_align=AxisAlignment.ALIGN_MID, - margins=Margins(), - inner_content_scaling=InnerScaling.STRETCH_TO_FIT, - ), - border_width=0, # no black border around the inserted image - ) - stamp = style.create_stamp(writer, BoxConstraints(width=w_pt, height=h_pt), {}) - stamp.apply(page_index, int(round(mb[0] + x)), int(round(mb[1] + y))) + try: + already_signed = existing_signature_count(writer) > 0 + except Exception: # noqa: BLE001 - malformed form (e.g. /AcroForm not a dictionary) + # Image mode has always stamped such a document: the flag only + # drives a warning, it must not fail what used to succeed. + already_signed = False + apply_stamps(writer, stamp_list, filename=src.stem, date=date, cache=cache) out = io.BytesIO() writer.write(out) Path(dst).write_bytes(out.getbuffer()) + return already_signed # ========================================================================= @@ -841,7 +861,7 @@ class RunConfig: inputs: list[Path] output: Path - mode: str = "beid" # "beid" (eID card + vignette) | "image" + mode: str = "beid" # "beid" | "azure" | "image" (visual signatures only) template: Path | None = None pades_level: str = "b-lta" # b-b | b-t | b-lt | b-lta (beid mode) field: str = "Signature" @@ -873,6 +893,11 @@ class RunConfig: azure_auth: str | None = None # None -> device-code (CLI) azure_trust_anchors: Path | None = None # PEM/DER file or directory azure_use_graph: bool = False # Graph /me displayName opt-in + # Visual signatures (text / image). beid/azure: applied into the same + # incremental writer BEFORE the cryptographic signature. image mode: + # they are the whole job. The legacy single-image fields above + # (image_path/page/x/y/page_anchor) still work in image mode: see effective_stamps(). + stamps: list[Stamp] = dataclasses.field(default_factory=list) @dataclasses.dataclass @@ -885,6 +910,46 @@ class DocResult: detail: str +def effective_stamps(cfg: RunConfig) -> list[Stamp]: + """Every visual signature of the run: the legacy single image of image mode (if any), + then cfg.stamps.""" + out: list[Stamp] = [] + if cfg.mode == "image" and cfg.image_path: + out.append(Stamp( + kind="image", image_path=Path(cfg.image_path), + page=None if cfg.page_anchor else (cfg.page or 1), + page_anchor=cfg.page_anchor, + x=cfg.x if cfg.x is not None else 0.0, + y=cfg.y if cfg.y is not None else 0.0)) + out.extend(cfg.stamps) + return out + + +def anchor_requirements(cfg: RunConfig) -> tuple[tuple[str, ...], str | None]: + """(anchors, blocker) for template validation. `anchors`: the first/last anchors used + by the placed elements, ordered ("first", "last"). `blocker`: None when EVERY element + (each stamp, and the vignette in beid/azure) targets first/last, else a phrase naming the + first element that does not.""" + recorded: set[str] = set() + blocker = None + for i, s in enumerate(effective_stamps(cfg), 1): + if s.page_anchor: + recorded.add(s.page_anchor) + elif blocker is None: + blocker = (f"signature #{i} targets every page" if s.all_pages + else f"signature #{i} targets page {s.page}") + if cfg.mode in ("beid", "azure"): + if cfg.page_anchor: + recorded.add(cfg.page_anchor) + elif blocker is None: + # The default bottom-right vignette is NOT auto-anchored: a safety + # check is never relaxed silently, --page first|last must be explicit. + blocker = (f"the vignette targets page {cfg.page or 1}" + if cfg.x is not None and cfg.y is not None + else "the vignette has no first/last page target") + return (tuple(a for a in PAGE_ANCHORS if a in recorded), blocker) + + def validate_config(cfg: RunConfig) -> None: """Check the consistency of a RunConfig; raises ValueError otherwise.""" if not cfg.inputs: @@ -957,13 +1022,24 @@ def validate_config(cfg: RunConfig) -> None: raise ValueError( f"Trust anchors not found: {cfg.azure_trust_anchors}" ) + # Visual signatures: the legacy image-mode checks first (their historical + # messages win), then every stamp. Optional in beid/azure, where + # cfg.image_path is ignored. + stamp_list = effective_stamps(cfg) if cfg.mode == "image": - if not cfg.image_path: - raise ValueError("--image-path is required in image mode.") - if not Path(cfg.image_path).exists(): + if cfg.image_path and not Path(cfg.image_path).exists(): raise ValueError(f"Image not found: {cfg.image_path}") if cfg.page is not None and cfg.page < 1: raise ValueError("--page must be >= 1.") + if not stamp_list: + raise ValueError( + "image mode needs at least one visual signature: " + "--text, --image-path or --signatures.") + for i, s in enumerate(stamp_list, 1): + try: + validate_stamp(s) + except StampError as exc: + raise ValueError(f"Signature #{i}: {exc}.") from None if cfg.template and not Path(cfg.template).exists(): raise ValueError(f"Template not found: {cfg.template}") @@ -1104,13 +1180,21 @@ def verify_signed_pdf( return label -def process_batch(cfg: RunConfig, *, on_progress=None) -> list[DocResult]: +def process_batch(cfg: RunConfig, *, on_progress=None, + today: datetime.date | None = None) -> list[DocResult]: """Validate (if a template is provided) then process each file according to the mode. Returns one DocResult per input file. `on_progress` is called after each document (useful for the GUI). In beid mode the PKCS#11 session, the RFC 3161 timestamper and the LTV ValidationContext are built ONCE here and reused for every document. + + The visual signatures (``effective_stamps``) are resolved ONCE too, with + one run date for ``{date}`` (``today``, injectable for tests; default: the + local date) and one batch-wide cache of rendered content. In beid/azure + mode they are handed to ``sign_one``, which applies them into the same + incremental writer BEFORE the signature — the signed output is never + stamped afterwards. In image mode they are the whole job. """ results: list[DocResult] = [] template_dims = page_dimensions(cfg.template) if cfg.template else None @@ -1161,7 +1245,13 @@ def process_batch(cfg: RunConfig, *, on_progress=None) -> list[DocResult]: Path(cfg.output).mkdir(parents=True, exist_ok=True) - # Optional position (None = unspecified). beid mode: if provided, vignette + stamp_list = effective_stamps(cfg) + stamp_date = today or datetime.date.today() # ONE local date for the whole run + stamp_cache: dict = {} # rendered/loaded images, batch-wide + anchors, blocker = anchor_requirements(cfg) + + # VIGNETTE placement (beid/azure; unused in image mode, where every stamp + # carries its own). Optional position (None = unspecified): if provided, vignette # placed freely; otherwise default vignette (bottom-right corner). # A page anchor resolves the page PER DOCUMENT (first -> 0, last -> -1), # which is what makes page-count mismatches against the template workable. @@ -1172,8 +1262,6 @@ def process_batch(cfg: RunConfig, *, on_progress=None) -> list[DocResult]: else: page_index = (cfg.page or 1) - 1 page_label = f"page {cfg.page or 1}" - x = cfg.x if cfg.x is not None else 0.0 - y = cfg.y if cfg.y is not None else 0.0 label = signature_level_label(cfg.pades_level, cfg.legacy_cms) mode_tag = "eID" if cfg.mode == "beid" else ( @@ -1183,7 +1271,7 @@ def process_batch(cfg: RunConfig, *, on_progress=None) -> list[DocResult]: src = Path(src) if template_dims is not None: verdict = validate_against_template( - template_dims, src, page_anchor=cfg.page_anchor + template_dims, src, anchors=anchors, blocker=blocker ) if not verdict.ok: res = DocResult(src, None, False, f"rejected — {verdict.reason}") @@ -1199,13 +1287,17 @@ def process_batch(cfg: RunConfig, *, on_progress=None) -> list[DocResult]: legacy_cms=cfg.legacy_cms, timestamper=material.timestamper, validation_context=material.validation_context, + # Applied by sign_one BEFORE the signature, same writer. + stamps=stamp_list, + stamp_date=stamp_date, + stamp_cache=stamp_cache, ) if placement: sign_one(signer, src, dst, f"{cfg.field}1", cfg.pades_level, - identity, page_index=page_index, pos=(x, y), + identity, page_index=page_index, pos=(cfg.x, cfg.y), **sign_kwargs) prefix = (f"signed ({mode_tag}) — vignette {page_label} " - f"@ ({x:.0f}, {y:.0f})") + f"@ ({cfg.x:.0f}, {cfg.y:.0f})") else: # No position: default bottom-right vignette. An anchor # moves it to the first/last page; None keeps the @@ -1217,6 +1309,8 @@ def process_batch(cfg: RunConfig, *, on_progress=None) -> list[DocResult]: prefix = f"signed ({mode_tag}) — vignette" if cfg.page_anchor: prefix += f" ({page_label}, bottom-right)" + if stamp_list: + prefix += f" + {len(stamp_list)} visual signature(s)" # R8: re-open + validate, report the *achieved* level. A # mismatch raises SelfVerificationError -> document failed. if (cfg.verify and not cfg.legacy_cms @@ -1229,8 +1323,14 @@ def process_batch(cfg: RunConfig, *, on_progress=None) -> list[DocResult]: else: detail = f"{prefix} — {label}" else: - insert_image_one(src, dst, cfg.image_path, page_index, x, y) - detail = f"image inserted — {page_label} @ ({x:.0f}, {y:.0f})" + already_signed = apply_stamps_one( + src, dst, stamp_list, date=stamp_date, cache=stamp_cache) + detail = (f"{len(stamp_list)} visual signature(s) applied — " + + "; ".join(s.describe() for s in stamp_list)) + if already_signed: + # Historical outcome of image mode, kept — but said out loud. + detail += (" — WARNING: the document was already signed; its " + "existing signature(s) are no longer valid") res = DocResult(src, dst, True, detail) except SelfVerificationError as exc: res = DocResult( @@ -1291,9 +1391,15 @@ def describe_placement(cfg: RunConfig) -> str: return f"vignette bottom-right, {cfg.page_anchor or 'last'} page" +def describe_stamps(cfg: RunConfig) -> list[str]: + """One human line per visual signature of the run (CLI banner).""" + return [f"{s.describe()} — {s.content_label()}" for s in effective_stamps(cfg)] + + def build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - description="Batch-sign (Belgian eID card) or stamp an image onto PDFs." + description="Batch-sign PDFs (Belgian eID card or Azure Key Vault) and/or " + "stamp visual signatures (text, images) onto them." ) # Positionals kept for backward compatibility: "inputs… output". parser.add_argument( @@ -1318,32 +1424,68 @@ def build_arg_parser() -> argparse.ArgumentParser: "--mode", choices=("beid", "image", "azure"), default="beid", - help="Mode: beid (eID card + vignette), image (image insertion), or " - "azure (your personal certificate in Azure Key Vault via a " - "Microsoft Entra ID login — an advanced (AES), not qualified, " - "signature).", + help="Mode: beid (eID card + vignette), azure (your personal " + "certificate in Azure Key Vault via a Microsoft Entra ID login — " + "an advanced (AES), not qualified, signature), or image (visual " + "signatures only: text and/or images, NOT a cryptographic " + "signature). beid and azure can also carry visual signatures " + "(--signatures), applied before the cryptographic signature.", ) parser.add_argument( "--image-path", dest="image_path", default=None, - help="Image to insert (required in --mode image).", + help="Image to stamp in --mode image (legacy single-image form; " + "placed with --page/--x/--y).", ) parser.add_argument( "--page", type=page_arg, default=None, metavar="N|first|last", help="Target page: a 1-based number, or 'first'/'last' (resolved per " - "document). Image: insertion page. beid: vignette page. With " - "--template, first/last also accepts files whose page count " - "differs from the template (their first/last page must still " - "match the template's).", + "document). image mode: page of the --text / --image-path " + "signature. beid/azure: vignette page. With --template, a file " + "whose page count differs from the template is accepted only if " + "EVERY element targets first/last (and those pages match the " + "template's).", ) parser.add_argument( "--x", type=float, default=None, help="X position (points, from the page's lower-left corner). " - "In beid mode, --x/--y place the vignette (otherwise: bottom-right).", + "image mode: the --text / --image-path signature. beid/azure: " + "--x/--y place the vignette (otherwise: bottom-right).", ) parser.add_argument( "--y", type=float, default=None, help="Y position (points, from the page's lower-left corner).", ) + visual = parser.add_argument_group( + "visual signatures", "Text and image signatures stamped on the pages. " + "Not a cryptographic signature by themselves." + ) + visual.add_argument( + "--signatures", dest="signatures", default=None, metavar="FILE.json", + help="JSON file: a list of visual signatures (kind text|image, " + "text/font/color/font_size or image_path/width_pt, one of page / " + "page_anchor / all_pages, x, y). Works in every mode; in " + "beid/azure they are applied before the cryptographic signature. " + "See README.", + ) + visual.add_argument( + "--text", default=None, + help="Text signature for --mode image, placed with --page/--x/--y. " + "'\\n' starts a new line; {date} and {filename} are replaced per " + "document.", + ) + visual.add_argument( + "--font", default=None, + help=f"Font of --text: one of {', '.join(BUNDLED_FONTS)} or the path of " + f"a .ttf/.otf file (default: {DEFAULT_FONT}).", + ) + visual.add_argument( + "--color", default=None, + help=f"Colour of --text as #RRGGBB (default: {DEFAULT_COLOR}).", + ) + visual.add_argument( + "--font-size", dest="font_size", type=float, default=None, + help="Font size of --text in points (default: 24).", + ) parser.add_argument("--lib", default=None, help="Path to the eID PKCS#11 lib.") parser.add_argument( "--field", default="Signature", help="Base name of the signature field (beid mode)." @@ -1486,6 +1628,43 @@ def resolve_config(args) -> RunConfig: if isinstance(page, str): page_anchor, page = page, None + # Visual signatures. getattr(): hand-made namespaces may lack the flags. + # --page/--x/--y belong to the single convenience stamp (--text here, or + # the legacy --image-path through effective_stamps) in image mode, and to + # the vignette in beid/azure; the entries of --signatures carry their own + # placement. The persisted GUI profile is NEVER read here. + text = getattr(args, "text", None) + font = getattr(args, "font", None) + color = getattr(args, "color", None) + font_size = getattr(args, "font_size", None) + sig_file = getattr(args, "signatures", None) + if text is None and (font is not None or color is not None or font_size is not None): + raise ValueError("--font, --color and --font-size need --text.") + if text is not None and args.mode != "image": + raise ValueError( + "--text only applies to --mode image; in beid/azure mode pass the " + "visual signatures with --signatures.") + if text is not None and args.image_path: + raise ValueError( + "--text and --image-path are mutually exclusive; use --signatures " + "to stamp several elements.") + stamp_list: list[Stamp] = [] + if text is not None: + stamp_list.append(Stamp( + kind="text", text=text.replace("\\n", "\n"), + font=font or DEFAULT_FONT, color=color or DEFAULT_COLOR, + font_size=font_size if font_size is not None else DEFAULT_FONT_SIZE, + page=None if page_anchor else (page or 1), page_anchor=page_anchor, + x=args.x if args.x is not None else 0.0, + y=args.y if args.y is not None else 0.0)) + if sig_file: + stamp_list.extend(load_stamps_file(sig_file)) # StampError is a ValueError + if (args.mode == "image" and text is None and not args.image_path + and (page is not None or page_anchor or args.x is not None or args.y is not None)): + raise ValueError( + "--page/--x/--y need --text or --image-path in image mode: the " + "signatures of --signatures carry their own placement.") + cfg = RunConfig( inputs=collect_pdfs([str(p) for p in raw_inputs]), output=Path(output) if output else None, @@ -1521,12 +1700,21 @@ def resolve_config(args) -> RunConfig: else None ), azure_use_graph=getattr(args, "azure_use_graph", False), + stamps=stamp_list, ) validate_config(cfg) return cfg def main() -> int: + # Texts and file names are echoed: a character the console encoding lacks + # (redirected output on Windows is the ANSI code page) is escaped instead + # of raising UnicodeEncodeError before any document is processed. Guarded: + # a replaced stream (StringIO) has no reconfigure, and there is no stdout + # at all in the windowed binary. + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(errors="backslashreplace") parser = build_arg_parser() args = parser.parse_args() @@ -1549,6 +1737,13 @@ def main() -> int: except ValueError as exc: parser.error(str(exc)) + stamp_lines = describe_stamps(cfg) + extra = f" + {len(stamp_lines)} visual signature(s)" if stamp_lines else "" + + def print_stamp_lines() -> None: + for i, line in enumerate(stamp_lines, 1): + print(f" {i}. {line}") + if cfg.mode == "beid": lib_path = cfg.lib or default_pkcs11_lib() if not Path(lib_path).exists(): @@ -1558,7 +1753,8 @@ def main() -> int: file=sys.stderr, ) return 1 - print(f"Mode: eID — {describe_placement(cfg)}. PKCS#11 lib: {lib_path}") + print(f"Mode: eID — {describe_placement(cfg)}{extra}. PKCS#11 lib: {lib_path}") + print_stamp_lines() # R9: the RRN is in the signing certificate, hence in every signature. print( "WARNING: each eID signature embeds the signer's national " @@ -1575,8 +1771,9 @@ def main() -> int: print(f"LTV trust anchors: EU trusted list ({resolve_lotl_url(cfg.trust_list_url)})") print(f"{len(cfg.inputs)} PDF(s). The PIN will be requested for each document.") elif cfg.mode == "azure": - print(f"Mode: Azure Key Vault — {describe_placement(cfg)}. " + print(f"Mode: Azure Key Vault — {describe_placement(cfg)}{extra}. " f"Vault: {cfg.azure_vault_url}") + print_stamp_lines() # R11: AES, not QES — and the signature carries the user's identity. print( "Note: signs with YOUR personal Key Vault certificate — an " @@ -1592,18 +1789,17 @@ def main() -> int: print(f"{len(cfg.inputs)} PDF(s). One Microsoft sign-in for the whole " f"batch ({resolve_azure_auth(cfg.azure_auth)}).") else: - page_phrase = (f"{cfg.page_anchor} page" if cfg.page_anchor - else f"page {cfg.page or 1}") - print( - f"Mode: image — {cfg.image_path} " - f"({page_phrase}, x={cfg.x or 0:.0f}, y={cfg.y or 0:.0f})." - ) + print(f"Mode: visual signatures — {len(stamp_lines)} element(s):") + print_stamp_lines() print(f"{len(cfg.inputs)} PDF(s).") if cfg.template: print(f"Validation against the template: {cfg.template}") - if cfg.page_anchor: + anchors, blocker = anchor_requirements(cfg) + if anchors and not blocker: + where = (f"{anchors[0]} page" if len(anchors) == 1 + else "first and last pages") print(f" Files whose page count differs from the template are " - f"accepted and signed on their {cfg.page_anchor} page.") + f"accepted and signed on their {where}.") print() def progress(r: DocResult) -> None: diff --git a/stamps.py b/stamps.py new file mode 100644 index 0000000..022054c --- /dev/null +++ b/stamps.py @@ -0,0 +1,696 @@ +"""Visual signatures ("stamps") for Cachet: model, fonts, rendering, JSON, PDF. + +A visual signature is a text or an image stamped onto one or more pages. It +is NOT a cryptographic signature: in beid/azure mode the stamps are applied +into the same incremental writer BEFORE the document is signed; in image mode +they are the whole job. + +Design notes (deliberate choices): + +- A text is a raster. It is rendered with Pillow into a tightly cropped RGBA + image at 300 dpi (flat colour, anti-aliased alpha mask) and then goes + through exactly the same pipeline as an image (one image XObject painted + at its exact float size, no border). No font is embedded, no shaping + library needed. +- The text layout is pinned to ``ImageFont.Layout.BASIC`` so sizes do not + depend on raqm being installed (identical on every OS and in CI). +- Memory is bounded: a text (or a freehand drawing) is measured first and + refused above ``MAX_TEXT_PIXELS`` before anything is allocated; an image is + downsampled to ``IMAGE_MAX_DPI`` of its target width before it is embedded. +- ``stamp_content`` is THE single source of pixels + size for both the PDF + and the GUI preview, so what is previewed is what gets stamped. +- Every failure raises ``StampError`` (a ``ValueError``): messages are + English, lower-case first word, no trailing period. + +This module must stay importable without tkinter, customtkinter, pypdfium2, +the GUI or the core (``sign_pdfs_beid`` is the CLI entry script: importing it +from here would load it twice). +""" + +from __future__ import annotations + +import dataclasses +import datetime +import json +import math +import os +import re +import sys +import uuid +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont, ImageOps, UnidentifiedImageError + +from pyhanko.pdf_utils import generic, images +from pyhanko.sign.fields import enumerate_sig_fields + + +class StampError(ValueError): + """Any failure of this module (bad stamp, font, image, JSON, page target).""" + + +KINDS = ("text", "image") +PAGE_ANCHORS = ("first", "last") +DEFAULT_FONT = "great-vibes" +DEFAULT_COLOR = "#1A2B8C" +DEFAULT_FONT_SIZE = 24.0 +DEFAULT_IMAGE_WIDTH_PT = 150.0 +TEXT_DPI = 300 +MAX_FONT_SIZE = 400.0 +MAX_WIDTH_PT = 5000.0 +MAX_TEXT_LEN = 2000 +MAX_TEXT_PIXELS = 40_000_000 # pixel budget of one rendered text (before cropping) +IMAGE_MAX_DPI = 600 # image stamps are downsampled to this, for their width_pt +DATE_FORMAT = "%d/%m/%Y" +BUNDLED_FONTS = { # id -> (display name, file name); dict order = menu order + "great-vibes": ("Great Vibes", "GreatVibes-Regular.ttf"), + "dancing-script": ("Dancing Script", "DancingScript.ttf"), + "caveat": ("Caveat", "Caveat.ttf"), + "sacramento": ("Sacramento", "Sacramento-Regular.ttf"), + "lato": ("Lato", "Lato-Regular.ttf"), + "libre-baskerville": ("Libre Baskerville", "LibreBaskerville.ttf"), +} + +# Image modes pyHanko embeds as they are; anything else is converted to RGBA. +_NATIVE_IMAGE_MODES = ("RGB", "RGBA", "L", "LA") + + +# --------------------------------------------------------------------------- +# Fonts and assets +# --------------------------------------------------------------------------- + +def _expanduser(path: Path) -> Path: + """``~`` expanded; a ``~user`` that does not exist is left as written + (pathlib raises ``RuntimeError`` for it).""" + try: + return path.expanduser() + except RuntimeError: + return path + + +def _is_file(path) -> bool: + """``Path.is_file`` that never raises (over-long names, embedded NULs).""" + try: + return Path(path).is_file() + except (OSError, ValueError): + return False + + +def asset_path(name: str) -> Path: + """Path of a bundled asset: next to this module in a checkout, under + ``sys._MEIPASS`` in the frozen binaries (GUI and CLI alike).""" + return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) / name + + +def font_choices() -> list[tuple[str, str]]: + """``[(id, display name), ...]`` of the bundled fonts, in menu order.""" + return [(font_id, name) for font_id, (name, _) in BUNDLED_FONTS.items()] + + +def resolve_font(font: str) -> Path: + """Font file of a bundled id (case-insensitive) or of a user ``.ttf/.otf`` + path. The result is an existing file.""" + font_id = font.strip().lower() + if font_id in BUNDLED_FONTS: + path = asset_path("fonts") / BUNDLED_FONTS[font_id][1] + if not _is_file(path): + raise StampError(f"bundled font file missing: {path}") + return path + path = _expanduser(Path(font)) + if not _is_file(path): + raise StampError( + f"unknown font {font!r} (expected one of {', '.join(BUNDLED_FONTS)}, " + "or the path of a .ttf/.otf file)") + return path + + +# --------------------------------------------------------------------------- +# Text and stroke rendering +# --------------------------------------------------------------------------- + +def _normalise_newlines(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def resolve_text(text: str, *, filename: str = "", date: datetime.date | None = None) -> str: + """Normalise the newlines and replace the two placeholders: ``{date}`` + (dd/mm/YYYY) and ``{filename}``. Plain ``str.replace`` — never + ``str.format`` — so every other brace is left untouched. ``{date}`` goes + first: a file name that itself contains ``{date}`` is not re-expanded.""" + day = (date or datetime.date.today()).strftime(DATE_FORMAT) + return _normalise_newlines(text).replace("{date}", day).replace("{filename}", filename) + + +def _parse_color(color: str) -> tuple[int, int, int]: + m = re.fullmatch(r"#([0-9A-Fa-f]{6})", color) if isinstance(color, str) else None + if m is None: + raise StampError(f"bad color {color!r} (expected #RRGGBB)") + return tuple(int(m.group(1)[i:i + 2], 16) for i in (0, 2, 4)) + + +def _ink_image(mask: Image.Image, rgb: tuple[int, int, int]) -> Image.Image: + """RGBA image whose RGB plane is the flat colour and whose alpha is the + (anti-aliased) ink mask.""" + img = Image.new("RGBA", mask.size, rgb + (0,)) + img.putalpha(mask) + return img + + +def text_layout(text: str, font: str, font_size: float) -> tuple: + """Measure a text WITHOUT rasterising it and enforce the pixel budget. + + Returns ``(font_obj, spacing, (l, t), pad, (w_px, h_px))``. ``text`` must + already be newline-normalised. Shared by ``render_text_image`` and + ``validate_stamp``.""" + px = max(1, round(font_size * TEXT_DPI / 72)) + spacing = round(px * 0.2) + # Measuring is inside the try as well: FreeType opens a damaged font file + # and only fails (OSError) on the first glyph it has to load. + try: + fnt = ImageFont.truetype(str(resolve_font(font)), px, layout_engine=ImageFont.Layout.BASIC) + # A 1x1 probe: measuring allocates nothing proportional to the text. + probe = ImageDraw.Draw(Image.new("L", (1, 1))) + l, t, r, b = probe.multiline_textbbox((0, 0), text, font=fnt, spacing=spacing) + except OSError as exc: + raise StampError(f"cannot load font {font!r}: {exc}") from None + pad = max(2, round(px * 0.25)) + size = (int(r - l) + 2 * pad, int(b - t) + 2 * pad) + if size[0] * size[1] > MAX_TEXT_PIXELS: # BEFORE any allocation + raise StampError("text is too large to render (reduce the font size or the text length)") + return fnt, spacing, (l, t), pad, size + + +def render_text_image(text: str, font: str, font_size: float, color: str) -> Image.Image: + """Render a (multi-line) text into a tightly cropped RGBA image: flat + colour, anti-aliased alpha. Never allocates more than ``MAX_TEXT_PIXELS`` + mask pixels (the uncropped mask is the only large allocation).""" + rgb = _parse_color(color) + text = _normalise_newlines(text) + fnt, spacing, (l, t), pad, size = text_layout(text, font, font_size) + mask = Image.new("L", size, 0) + try: + ImageDraw.Draw(mask).multiline_text( + (pad - l, pad - t), text, font=fnt, fill=255, spacing=spacing) + except OSError as exc: # FreeType failing on a glyph + raise StampError(f"cannot load font {font!r}: {exc}") from None + bbox = mask.getbbox() + if bbox is None: + raise StampError("text is empty") + return _ink_image(mask.crop(bbox), rgb) + + +def render_strokes_image(strokes, color: str, *, width: float = 3.0, scale: int = 4) -> Image.Image: + """Rasterise freehand strokes into a tightly cropped transparent RGBA + image, ``scale`` times the canvas size (supersampling). + + ``strokes``: a list of strokes, each a list of ``(x, y)`` floats in canvas + pixels. Every point also gets a filled disc of diameter ``width``: round + caps, and a one-point stroke draws a dot.""" + rgb = _parse_color(color) + points = [(float(x), float(y)) for stroke in strokes for x, y in stroke] + if not points: + raise StampError("nothing was drawn") + # Origin moved to the strokes' own bounding box (plus the pen width), so + # points dragged outside the canvas (negative coordinates) are kept. + x0 = min(x for x, _ in points) - width + y0 = min(y for _, y in points) - width + w = (max(x for x, _ in points) + width - x0) * scale + h = (max(y for _, y in points) + width - y0) * scale + # Same budget as a text, checked BEFORE the allocation: the mask covers + # the whole bounding box, and a drag can leave the canvas by a screen. + if not w * h <= MAX_TEXT_PIXELS: # (also true for NaN / inf) + raise StampError("drawing is too large") + mask = Image.new("L", (max(1, math.ceil(w)), max(1, math.ceil(h))), 0) + draw = ImageDraw.Draw(mask) + pen = max(1, round(width * scale)) + radius = width * scale / 2 + for stroke in strokes: + scaled = [((float(x) - x0) * scale, (float(y) - y0) * scale) for x, y in stroke] + if len(scaled) > 1: + draw.line(scaled, fill=255, width=pen, joint="curve") + for cx, cy in scaled: + draw.ellipse((cx - radius, cy - radius, cx + radius, cy + radius), fill=255) + return _ink_image(mask.crop(mask.getbbox()), rgb) + + +# --------------------------------------------------------------------------- +# Images +# --------------------------------------------------------------------------- + +def load_image(path) -> Image.Image: + """Load an image, normalised once: EXIF orientation applied, any mode + pyHanko cannot embed (palette, CMYK, 1-bit, …) converted to RGBA. The + file handle is closed on return.""" + try: + with Image.open(path) as im: + im.load() + out = ImageOps.exif_transpose(im) + if out.mode not in _NATIVE_IMAGE_MODES: + return out.convert("RGBA") + return out.copy() + # Deliberately broad: on a damaged file Pillow's decoders raise far more + # than OSError / ValueError / DecompressionBombError — SyntaxError (broken + # PNG chunk, bad EXIF block), NotImplementedError (exotic DDS), + # MemoryError (absurd declared size). All mean "not a usable image". + except Exception as exc: + # The text of these two repeats the path: keep the reason alone. + if isinstance(exc, UnidentifiedImageError): + reason = "not a recognised image file" + else: + reason = getattr(exc, "strerror", None) or exc # OSError: its reason + raise StampError(f"cannot read image {path}: {reason}") from None + + +# --------------------------------------------------------------------------- +# Model +# --------------------------------------------------------------------------- + +def _unknown_kind(kind) -> StampError: + return StampError(f"unknown kind {kind!r} (expected {'|'.join(KINDS)})") + + +def _unknown_anchor(anchor) -> StampError: + return StampError(f"unknown page_anchor {anchor!r} (expected {'|'.join(PAGE_ANCHORS)})") + + +@dataclasses.dataclass(frozen=True) +class Stamp: + """One visual signature: its content and (once placed) where it goes. + + The field order is part of the contract.""" + kind: str = "image" # "text" | "image" + text: str = "" + font: str = DEFAULT_FONT # bundled id or path + color: str = DEFAULT_COLOR # "#RRGGBB" + font_size: float = DEFAULT_FONT_SIZE # pt (text) + image_path: Path | None = None # (image) + width_pt: float = DEFAULT_IMAGE_WIDTH_PT # pt (image) + page: int | None = None # 1-based } exactly one of the three + page_anchor: str | None = None # "first" | "last" } once the stamp is placed + all_pages: bool = False # } + x: float | None = None + y: float | None = None + + def _target_count(self) -> int: + return (self.page is not None) + (self.page_anchor is not None) + bool(self.all_pages) + + @property + def is_placed(self) -> bool: + """A position and exactly one page target.""" + return self.x is not None and self.y is not None and self._target_count() == 1 + + def page_indexes(self, n_pages: int) -> list[int]: + """0-based indexes of the pages this stamp lands on, for a document + of ``n_pages`` pages.""" + if n_pages < 1: + raise StampError("the document has no page") + if self.all_pages: + return list(range(n_pages)) + if self.page_anchor is not None: + if self.page_anchor not in PAGE_ANCHORS: + raise _unknown_anchor(self.page_anchor) + return [0 if self.page_anchor == "first" else n_pages - 1] + if self.page is None: + raise StampError("the signature has no page target") + if not 1 <= self.page <= n_pages: + raise StampError( + f"page {self.page} out of range (the document has {n_pages} page(s))") + return [self.page - 1] + + def target_label(self) -> str: + if self.all_pages: + return "all pages" + if self.page_anchor is not None: + return f"{self.page_anchor} page" + if self.page is not None: + return f"page {self.page}" + return "unplaced" + + def describe(self) -> str: + """One-line summary, e.g. ``text last page @ (360, 120)``.""" + if self.x is None or self.y is None: + return f"{self.kind} unplaced" + return f"{self.kind} {self.target_label()} @ ({self.x:.0f}, {self.y:.0f})" + + def content_label(self) -> str: + """Short label of the content: the first non-empty line of a text (in + double quotes, truncated to 30 characters), or the image file name.""" + if self.kind == "text": + first = next((ln.strip() for ln in self.text.splitlines() if ln.strip()), "") + if len(first) > 30: + first = first[:30] + "…" + return f'"{first}"' + return Path(self.image_path).name if self.image_path else "?" + + def to_dict(self, *, base_dir=None) -> dict: + """JSON form (the ``--signatures`` schema). Only the keys of the kind + and the placement keys that are set are emitted. An image that is a + DIRECT child of ``base_dir`` is written as its bare file name, any + other one as an absolute path.""" + out: dict = {"kind": self.kind} + if self.kind == "text": + out.update(text=self.text, font=self.font, color=self.color, + font_size=self.font_size) + else: + if self.image_path is not None: + out["image_path"] = self._image_path_for(base_dir) + out["width_pt"] = self.width_pt + if self.page is not None: + out["page"] = self.page + if self.page_anchor is not None: + out["page_anchor"] = self.page_anchor + if self.all_pages: + out["all_pages"] = True + if self.x is not None: + out["x"] = self.x + if self.y is not None: + out["y"] = self.y + return out + + def _image_path_for(self, base_dir) -> str: + if base_dir is not None: + # RESOLVED paths only: pathlib's relative_to/is_relative_to are + # lexical, so base_dir/../../x would count as "inside" base_dir. + # ValueError: a NUL or a lone surrogate in the path (hand-edited + # profile) — such a file is nowhere, so it is not in base_dir. + try: + resolved = Path(self.image_path).resolve() + if resolved.parent == Path(base_dir).resolve(): + return resolved.name + except (OSError, RuntimeError, ValueError): + pass + return os.path.abspath(self.image_path) + + @classmethod + def from_dict(cls, data, *, base_dir=None) -> "Stamp": + """Type-check and build a stamp from its JSON form. Does NOT call + ``validate_stamp``. Unknown keys are ignored, missing keys take the + defaults. A relative ``image_path`` (or font path) is joined to + ``base_dir`` as written — the result may point outside ``base_dir`` + and nothing here treats it as owned by that directory.""" + if not isinstance(data, dict): + raise StampError("expected an object") + if "kind" not in data: + raise StampError("missing 'kind'") + + def string(key): + value = data[key] + if not isinstance(value, str): + raise StampError(f"'{key}' must be a string") + return value + + def number(key): + value = data[key] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise StampError(f"'{key}' must be a number") + try: + return float(value) + except OverflowError: # an integer too large for a float + raise StampError(f"'{key}' must be a number") from None + + def under_base(path: Path) -> Path: + path = _expanduser(path) + if base_dir is not None and not path.is_absolute(): + return Path(base_dir) / path + return path + + fields: dict = {"kind": string("kind")} + for key in ("text", "color", "page_anchor"): + if key in data: + fields[key] = string(key) + if "font" in data: + font = string("font") + # A bundled id stays an id; so does any font when there is no + # base_dir (resolve_font expands and checks the path later). A + # bare word that is no file there stays as typed too: it is a + # mistyped id, to be reported as such, not as a path. + if (base_dir is not None and font.strip() + and font.strip().lower() not in BUNDLED_FONTS): + joined = under_base(Path(font)) + bare_word = Path(font).name == font and not Path(font).suffix + if not bare_word or _is_file(joined): + font = str(joined) + fields["font"] = font + if "image_path" in data: + fields["image_path"] = under_base(Path(string("image_path"))) + for key in ("font_size", "width_pt", "x", "y"): + if key in data: + fields[key] = number(key) + if "page" in data: + page = data["page"] + if isinstance(page, bool) or not isinstance(page, int): + raise StampError("'page' must be an integer") + fields["page"] = page + if "all_pages" in data: + if not isinstance(data["all_pages"], bool): + raise StampError("'all_pages' must be true or false") + fields["all_pages"] = data["all_pages"] + return cls(**fields) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +def _in_range(value, maximum: float) -> bool: + return math.isfinite(value) and 0 < value <= maximum + + +def _validate_content(stamp: Stamp) -> None: + if stamp.kind not in KINDS: + raise _unknown_kind(stamp.kind) + if stamp.kind == "text": + if not stamp.text.strip(): + raise StampError("text is empty") + if len(stamp.text) > MAX_TEXT_LEN: + raise StampError(f"text is too long (max {MAX_TEXT_LEN} characters)") + _parse_color(stamp.color) + if not _in_range(stamp.font_size, MAX_FONT_SIZE): + raise StampError( + f"font_size must be greater than 0 and at most {MAX_FONT_SIZE:g}") + # Probe only (font + pixel budget): nothing is rasterised here. + text_layout(_normalise_newlines(stamp.text), stamp.font, stamp.font_size) + else: + if stamp.image_path is None: + raise StampError("image_path is required") + if not _is_file(stamp.image_path): + raise StampError(f"image not found: {stamp.image_path}") + if not _in_range(stamp.width_pt, MAX_WIDTH_PT): + raise StampError( + f"width_pt must be greater than 0 and at most {MAX_WIDTH_PT:g}") + + +def validate_placement(stamp: Stamp) -> None: + """Placement rules alone (page target + position), whatever the content. + + Lets a caller judge a saved placement even when the content is + temporarily unusable (image or font file missing).""" + if stamp._target_count() != 1: + raise StampError("give exactly one page target: page, page_anchor or all_pages") + if stamp.page is not None and stamp.page < 1: + raise StampError("page must be an integer >= 1") + if stamp.page_anchor is not None and stamp.page_anchor not in PAGE_ANCHORS: + raise _unknown_anchor(stamp.page_anchor) + if stamp.x is None or stamp.y is None: + raise StampError("x and y are required") + if not (math.isfinite(stamp.x) and math.isfinite(stamp.y)): + raise StampError("x and y must be finite numbers") + + +def validate_stamp(stamp: Stamp, *, placed: bool = True) -> None: + """Raise ``StampError`` on the first broken rule: content first (kind, + text/colour/size/font/pixel budget or image/width), then — unless + ``placed=False`` (a library entry not placed yet) — the placement.""" + _validate_content(stamp) + if placed: + validate_placement(stamp) + + +# --------------------------------------------------------------------------- +# Content (pixels + size) and JSON file +# --------------------------------------------------------------------------- + +def content_key(stamp: Stamp, *, filename: str = "", date: datetime.date | None = None) -> tuple: + """Hashable identity of a stamp's rendered content (placement excluded).""" + if stamp.kind == "text": + return ("text", resolve_text(stamp.text, filename=filename, date=date), + stamp.font, float(stamp.font_size), stamp.color.upper()) + return ("image", str(stamp.image_path), float(stamp.width_pt)) + + +def stamp_content(stamp: Stamp, *, filename: str = "", date: datetime.date | None = None, + cache: dict | None = None) -> tuple[Image.Image, tuple[float, float]]: + """Pixels and size in points of a stamp — THE single source for both the + PDF and the GUI preview. + + ``cache`` (a plain dict owned by the caller) is read and written under + ``content_key(...)``, except that a text using ``{filename}`` is never + written to it: one entry per document would grow without bound over a + batch.""" + if stamp.kind not in KINDS: + raise _unknown_kind(stamp.kind) + if stamp.kind == "image" and stamp.image_path is None: + raise StampError("image_path is required") + key = content_key(stamp, filename=filename, date=date) + if cache is not None and key in cache: + return cache[key] + if stamp.kind == "text": + img = render_text_image(key[1], stamp.font, stamp.font_size, stamp.color) + size = (img.width * 72 / TEXT_DPI, img.height * 72 / TEXT_DPI) + cacheable = "{filename}" not in stamp.text + else: + img = load_image(stamp.image_path) + # Size from the aspect of the full-resolution image, then cap the + # embedded bitmap: a 12 MP photo stamped 150 pt wide is 1250 px. + size = (float(stamp.width_pt), stamp.width_pt * img.height / img.width) + max_w = max(1, math.ceil(stamp.width_pt * IMAGE_MAX_DPI / 72)) + if img.width > max_w: + img = img.resize( + (max_w, max(1, round(img.height * max_w / img.width))), Image.LANCZOS) + cacheable = True + if cache is not None and cacheable: + cache[key] = (img, size) + return img, size + + +def load_stamps_file(path) -> list[Stamp]: + """Read a ``--signatures`` file: a UTF-8 JSON list of visual signatures + (same schema as the profile's entries). Entries with ``"enabled": false`` + are skipped; every other one is validated (placement included).""" + # Encoding pinned (never the platform default); utf-8-sig also swallows + # the BOM that PowerShell 5 writes. + try: + raw = Path(path).read_text(encoding="utf-8-sig") + except UnicodeDecodeError: # a ValueError, not an OSError + raise StampError(f"{path}: not valid UTF-8") from None + except OSError as exc: + raise StampError(f"cannot read signatures file {path}: {exc}") from None + try: + data = json.loads(raw) + except (ValueError, RecursionError) as exc: # deep nesting -> RecursionError + raise StampError(f"invalid JSON in {path}: {exc}") from None + if not isinstance(data, list): + raise StampError(f"{path}: expected a JSON list of signatures") + name = Path(path).name + base_dir = Path(path).resolve().parent + stamp_list: list[Stamp] = [] + for i, entry in enumerate(data, 1): + try: + if isinstance(entry, dict) and "enabled" in entry: + if not isinstance(entry["enabled"], bool): + raise StampError("'enabled' must be true or false") + if not entry["enabled"]: + continue + stamp = Stamp.from_dict(entry, base_dir=base_dir) + validate_stamp(stamp) + except StampError as exc: + raise StampError(f"{name}: signature #{i}: {exc}") from None + stamp_list.append(stamp) + return stamp_list + + +# --------------------------------------------------------------------------- +# PDF +# --------------------------------------------------------------------------- + +def page_count(writer) -> int: + """Number of pages of the document behind ``writer`` (does not dirty it).""" + return int(writer.root["/Pages"]["/Count"]) + + +def page_mediabox(writer, page_index: int) -> list[float]: + """MediaBox of page `page_index` (0-based, -1 = last), with inheritance + from the page tree.""" + page_ref, _ = writer.find_page_for_modification(page_index) + node = page_ref.get_object() + for _ in range(50): + if "/MediaBox" in node: + mb = node.raw_get("/MediaBox").get_object() + return [ + float(v.get_object() if hasattr(v, "get_object") else v) for v in mb + ] + parent = node.get("/Parent") + if parent is None: + break + node = parent.get_object() + return [0.0, 0.0, 595.276, 841.89] + + +def _pdf_number(value: float) -> bytes: + """A number for a content stream: fixed notation (``%g`` may emit an + exponent, which PDF does not allow), without trailing zeros.""" + return (b"%.4f" % value).rstrip(b"0").rstrip(b".") + + +def existing_signature_count(writer) -> int: + """Signatures and document timestamps the INPUT already carries + (0 = unsigned). Reads the previous revision only: it neither parses the + CMS nor dirties the writer.""" + return sum(1 for _ in enumerate_sig_fields(writer.prev, filled_status=True)) + + +def apply_stamps(writer, stamp_list, *, filename: str = "", + date: datetime.date | None = None, cache: dict | None = None) -> None: + """Stamp every element of ``stamp_list`` into ``writer`` (and nothing + else: the caller writes or signs it). + + Two passes: pages and content are resolved for ALL stamps before the + first write, so a bad stamp never leaves a half-stamped writer for the + caller to sign. A low-level primitive: it stamps whatever it is given, + including an already signed document (see ``existing_signature_count``).""" + day = date or datetime.date.today() + n = page_count(writer) + resolved = [] + for stamp in stamp_list: + if not stamp.is_placed: + raise StampError("the signature is not placed") + pages = stamp.page_indexes(n) # range errors BEFORE touching the writer + img, size = stamp_content(stamp, filename=filename, date=day, cache=cache) + resolved.append((stamp, pages, img, size)) + + def add_content(page_index: int, data: bytes, **kwargs) -> None: + writer.add_stream_to_page( + page_index, writer.add_object(generic.StreamObject(stream_data=data)), **kwargs) + + owned: set[int] = set() # pages whose /Contents is theirs alone + for stamp, pages, img, (w, h) in resolved: + # ONE image XObject per stamp, painted on N pages (alpha -> /SMask by + # pyHanko). The image is painted directly, not through a pyHanko + # stamp: an image fills the unit square, so the matrix carries the + # exact float size, whereas a stamp box is truncated to whole points + # (and cannot be under 1 pt). + image_ref = images.pil_image(img, writer) + for p in pages: + page_ref, _ = writer.find_page_for_modification(p) + page_obj = page_ref.get_object() + if "/Contents" not in page_obj: # blank page: /Contents is optional + page_obj["/Contents"] = writer.add_object(generic.StreamObject(stream_data=b"")) + elif p not in owned: + contents = page_obj.raw_get("/Contents") + if (isinstance(contents, generic.IndirectObject) + and isinstance(contents.get_object(), generic.ArrayObject)): + # pyHanko appends to an indirect array in place, and + # another page may point at the same one: give this page + # its own copy, or the stamp would land on that page too. + page_obj["/Contents"] = generic.ArrayObject(contents.get_object()) + owned.add(p) + # Always: a /Contents or /Resources that pyHanko sets on the page + # is not written unless the page itself is marked (the stamp + # would be invisible). + writer.mark_update(page_ref) + mb = page_mediabox(writer, p) # per-page origin + # A fresh name per page: several pages may share one /Resources. + name = "/Stamp" + uuid.uuid4().hex + # The existing content is wrapped in q/Q so that a transformation + # it leaves behind cannot move or scale the stamp. + add_content(p, b"q", prepend=True) + add_content(p, b"Q") + add_content( + p, + b"q %s 0 0 %s %s %s cm %s Do Q" % ( + _pdf_number(w), _pdf_number(h), + _pdf_number(mb[0] + stamp.x), _pdf_number(mb[1] + stamp.y), + name.encode("ascii")), + resources=generic.DictionaryObject({ + generic.pdf_name("/XObject"): generic.DictionaryObject( + {generic.pdf_name(name): image_ref})})) diff --git a/test_azure.py b/test_azure.py index 85f76b0..b0a1e8f 100644 --- a/test_azure.py +++ b/test_azure.py @@ -11,6 +11,7 @@ import base64 import datetime import json +import os import tempfile import unittest from pathlib import Path @@ -19,7 +20,26 @@ import azure_signer import sign_pdfs_beid as core -from test_sign_pdfs_beid import make_pdf +from stamps import Stamp +from test_sign_pdfs_beid import make_pdf, xobject_counts + +_PROFILE_SANDBOX = None + + +def setUpModule(): + """Safety net: no test of this module can reach the real user profile + (GuiAzurePanel's per-test directories sit on top of this).""" + global _PROFILE_SANDBOX + root = tempfile.mkdtemp(prefix="cachet-tests-") + _PROFILE_SANDBOX = mock.patch.dict(os.environ, { + "CACHET_CONFIG_DIR": os.path.join(root, "cfg"), + "CACHET_DATA_DIR": os.path.join(root, "data")}) + _PROFILE_SANDBOX.start() + + +def tearDownModule(): + _PROFILE_SANDBOX.stop() + # --------------------------------------------------------------------------- # Local key/cert helpers (cryptography) — no Azure, no network @@ -361,6 +381,43 @@ def test_ec_raw_rs_converted_to_der_and_validates(self): trust_anchors=[signer.signing_cert]) self.assertEqual(detail, "PAdES-B-B") + def test_stamps_then_azure_signature_validates(self): + # Visual signature + azure signature through the REAL core.sign_one: + # the stamp goes into the same writer before the signature, so the + # signature covers it — and still only the digest goes to Key Vault. + from pyhanko.pdf_utils.reader import PdfFileReader + from pyhanko.sign.validation import validate_pdf_signature + from pyhanko.sign.validation.status import ( + ModificationLevel, SignatureCoverageLevel) + from pyhanko_certvalidator import ValidationContext + + for ec_key, alg in ((False, "RS256"), (True, "ES256")): + with self.subTest(alg=alg): + signer, fake = self._build(ec_key=ec_key) + src = make_pdf(self.p(f"doc_{alg}.pdf"), [(595, 842)] * 2) + dst = self.p(f"signed_{alg}.pdf") + core.sign_one( + signer, src, dst, "Signature1", "b-b", + core.read_cert_identity(signer.signing_cert), + stamps=[Stamp(kind="text", text="Jane Doe\n{date}", + all_pages=True, x=360.0, y=120.0)]) + (call_alg, digest), = fake.calls + self.assertEqual(call_alg, alg) + self.assertEqual(len(digest), 32) + self.assertEqual( + core.verify_signed_pdf(dst, "b-b", + trust_anchors=[signer.signing_cert]), + "PAdES-B-B") + with dst.open("rb") as f: + reader = PdfFileReader(f, strict=False) + status = validate_pdf_signature( + reader.embedded_regular_signatures[0], + ValidationContext(trust_roots=[signer.signing_cert])) + self.assertTrue(status.bottom_line, status.summary()) + self.assertEqual(status.coverage, SignatureCoverageLevel.ENTIRE_FILE) + self.assertEqual(status.modification_level, ModificationLevel.NONE) + self.assertEqual(xobject_counts(dst), [1, 1]) # stamped on both pages + def test_vault_error_is_actionable(self): key_client = SimpleNamespace( get_key=mock.Mock(side_effect=OSError("connection refused"))) @@ -520,6 +577,28 @@ def test_default_vignette_and_placement_pass_through(self): self.assertEqual(kw.get("pos"), (10.0, 20.0)) self.assertIn("page 2", results[0].detail) + def test_stamps_forwarded_to_sign_one(self): + srcs = [make_pdf(self.p(f"d{i}.pdf"), [(595, 842)]) for i in range(2)] + extra = [Stamp(kind="text", text="Jane Doe", page_anchor="last", x=10.0, y=20.0), + Stamp(kind="text", text="JD", all_pages=True, x=540.0, y=20.0)] + cfg = core.RunConfig(inputs=srcs, output=self.p("out"), mode="azure", + azure_vault_url="https://v.example", stamps=extra) + calls, results = self._run(cfg) + self.assertTrue(all(r.ok for r in results)) + self.assertEqual(len(calls["sign"]), 2) + for _, kw in calls["sign"]: + self.assertEqual(kw["stamps"], extra) + self.assertIsInstance(kw["stamp_date"], datetime.date) + self.assertIsNone(kw.get("pos")) # the vignette keeps its default + # ONE cache for the whole batch + self.assertIsInstance(calls["sign"][0][1]["stamp_cache"], dict) + self.assertIs(calls["sign"][0][1]["stamp_cache"], + calls["sign"][1][1]["stamp_cache"]) + self.assertEqual( + results[0].detail, + "signed (Azure, jane@example.org) — vignette + 2 visual signature(s) " + "— PAdES-B-LTA, LTV ok") + class AzureSigningMaterial(TmpCase): """build_signing_material: azure uses the INTERNAL CA, never the LOTL.""" @@ -568,7 +647,19 @@ def setUp(self): tkinter.Tk().destroy() import gui # noqa: F401 except Exception as exc: # noqa: BLE001 + # A run that is SUPPOSED to exercise the GUI must not turn a + # broken gui.py or a missing display into a green "all skipped". + if os.environ.get("CACHET_REQUIRE_GUI") == "1": + self.fail("GUI tests are required (CACHET_REQUIRE_GUI=1) but " + f"unavailable: {exc!r}") self.skipTest(f"tkinter/GUI unavailable: {exc}") + # The GUI loads and auto-saves a user profile: never the real one. + self.tmp = Path(tempfile.mkdtemp()) + env = mock.patch.dict(os.environ, { + "CACHET_CONFIG_DIR": str(self.tmp / "cfg"), + "CACHET_DATA_DIR": str(self.tmp / "data")}) + env.start() + self.addCleanup(env.stop) def _wizard_to_mode_step(self, app): """Complete steps 1-4 with minimal state and enter the mode step.""" @@ -604,10 +695,11 @@ def test_azure_panel_toggles_with_mode(self): app._refresh_azure_visibility() app.update() self.assertFalse(app.azure_section.winfo_manager()) # hidden again - # the image picker lives on the placement step, image mode only + # placement step, image mode: the add buttons, and no vignette app._goto_step(5) app.update() - self.assertTrue(app.image_row.winfo_manager()) + self.assertTrue(app.add_image_btn.winfo_manager()) + self.assertNotIn(gui.VIGNETTE_ID, app._element_rows) finally: app.destroy() diff --git a/test_profile_store.py b/test_profile_store.py new file mode 100644 index 0000000..7416cce --- /dev/null +++ b/test_profile_store.py @@ -0,0 +1,933 @@ +#!/usr/bin/env python3 +"""Headless tests for profile_store.py (user profile + signature image store). + +Every test works in its own TemporaryDirectory with EXPLICIT directories: +nothing here may read or write the real user profile. +Run with: ./venv/bin/python -m unittest -v test_profile_store +""" + +import dataclasses +import hashlib +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from PIL import Image + +import profile_store +import stamps as stamplib +from profile_store import Placement, Profile, ProfileStore, Settings, Signature +from stamps import Stamp +from test_sign_pdfs_beid import make_png +from test_stamps import damaged_font + +A4 = (595.0, 842.0) +LETTER = (612.0, 792.0) +SETTING_KEYS = {"language", "mode", "pades_level", "output_dir", "azure_vault_url", + "azure_auth", "azure_trust_anchors", "vignette"} + + +class StoreCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.store = ProfileStore(config_dir=self.tmp / "cfg", data_dir=self.tmp / "data") + + def write_profile(self, content) -> Path: + """Put `content` (bytes, text or a JSON-able object) in place of profile.json.""" + path = self.store.profile_path + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content if isinstance(content, str) else json.dumps(content), + encoding="utf-8") + return path + + def load_signatures(self, entries) -> list: + self.write_profile({"version": 1, "signatures": entries}) + return self.store.load().signatures + + def assert_set_aside(self, original: bytes): + """The unusable file went to profile.json.bak, byte for byte.""" + bak = self.store.profile_path.with_name("profile.json.bak") + self.assertFalse(self.store.profile_path.exists()) + self.assertEqual(bak.read_bytes(), original) + + def text_entry(self, **extra) -> dict: + return {"kind": "text", "text": "Jane Doe", **extra} + + +class Dirs(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + + def env(self, **values): + """os.environ without the two override variables, plus `values`.""" + patcher = mock.patch.dict(os.environ, values) + patcher.start() + self.addCleanup(patcher.stop) + for name in (profile_store.ENV_CONFIG_DIR, profile_store.ENV_DATA_DIR): + if name not in values: + os.environ.pop(name, None) + + def test_env_override(self): + self.env(CACHET_CONFIG_DIR=str(self.tmp / "c"), CACHET_DATA_DIR=str(self.tmp / "d")) + self.assertEqual(profile_store.resolve_config_dir(), self.tmp / "c") + self.assertEqual(profile_store.resolve_data_dir(), self.tmp / "d") + + def test_explicit_argument_beats_env(self): + self.env(CACHET_CONFIG_DIR=str(self.tmp / "c"), CACHET_DATA_DIR=str(self.tmp / "d")) + self.assertEqual(profile_store.resolve_config_dir(self.tmp / "x"), self.tmp / "x") + self.assertEqual(profile_store.resolve_data_dir(str(self.tmp / "y")), self.tmp / "y") + + def test_default_uses_platformdirs(self): + import platformdirs + + self.env(CACHET_CONFIG_DIR="", CACHET_DATA_DIR="") # empty = not set + with mock.patch.object(platformdirs, "user_config_dir", + return_value=str(self.tmp / "pc")) as cfg, \ + mock.patch.object(platformdirs, "user_data_dir", + return_value=str(self.tmp / "pd")) as data: + self.assertEqual(profile_store.resolve_config_dir(), self.tmp / "pc") + self.assertEqual(profile_store.resolve_data_dir(), self.tmp / "pd") + store = ProfileStore() + cfg.assert_called_with("Cachet", appauthor=False) + data.assert_called_with("Cachet", appauthor=False) + self.assertEqual((store.config_dir, store.data_dir), (self.tmp / "pc", self.tmp / "pd")) + self.assertEqual(list(self.tmp.iterdir()), []) # nothing created on disk + + def test_constructor_creates_nothing(self): + store = ProfileStore(config_dir=self.tmp / "cfg", data_dir=self.tmp / "data") + self.assertEqual(store.profile_path, self.tmp / "cfg" / "profile.json") + self.assertEqual(store.signatures_dir, self.tmp / "data" / "signatures") + self.assertEqual(store.load(), Profile()) # loading creates nothing either + self.assertEqual(list(self.tmp.iterdir()), []) + + def test_default_constructor_reads_env(self): + # Regression: parameters named config_dir/data_dir must not shadow the + # module-level resolvers (hence their resolve_ prefix). + self.env(CACHET_CONFIG_DIR=str(self.tmp / "c"), CACHET_DATA_DIR=str(self.tmp / "d")) + store = ProfileStore() + self.assertEqual((store.config_dir, store.data_dir), (self.tmp / "c", self.tmp / "d")) + # resolved ONCE: a later change of the environment does not move the store + os.environ[profile_store.ENV_CONFIG_DIR] = str(self.tmp / "other") + self.assertEqual(store.config_dir, self.tmp / "c") + + def test_constructor_keywords(self): + self.env(CACHET_CONFIG_DIR=str(self.tmp / "c"), CACHET_DATA_DIR=str(self.tmp / "d")) + store = ProfileStore(config_dir=self.tmp / "a", data_dir=self.tmp / "b") + self.assertEqual((store.config_dir, store.data_dir), (self.tmp / "a", self.tmp / "b")) + mixed = ProfileStore(data_dir=self.tmp / "b") + self.assertEqual((mixed.config_dir, mixed.data_dir), (self.tmp / "c", self.tmp / "b")) + + def test_ids(self): + ids = {profile_store.new_id() for _ in range(50)} + self.assertEqual(len(ids), 50) + self.assertTrue(all(profile_store.is_valid_id(i) for i in ids)) + for bad in ("vignette", "", "0123456789a", "0123456789abc", "0123456789AB", + "0123456789ag", 123456789012, None, b"0123456789ab"): + with self.subTest(bad=bad): + self.assertFalse(profile_store.is_valid_id(bad)) + + +class LoadSave(StoreCase): + def full_profile(self) -> Profile: + stored = self.store.store_image(Image.new("RGBA", (40, 20), (0, 0, 255, 200))) + return Profile( + settings=Settings( + language="fr", mode="azure", pades_level="b-lt", output_dir="/home/jane/signed", + azure_vault_url="https://myorg-sign.vault.azure.net", azure_auth="interactive", + azure_trust_anchors="/home/jane/ca-chain.pem", + vignette=Placement(page=2, x=380.0, y=60.5, placed_on=A4)), + signatures=[ + Signature(id="3f9a1c0b77de", label="Sébastien", placed_on=A4, stamp=Stamp( + kind="text", text="Sébastien\nRead and approved, {date}", font="caveat", + color="#AA0000", font_size=18.0, page=2, x=360.0, y=120.25)), + Signature(id="b20c55e19a04", label="Initials", placed_on=LETTER, stamp=Stamp( + kind="image", image_path=stored, width_pt=60.0, all_pages=True, + x=540.0, y=20.0)), + Signature(id="77aa01f3c2d9", label="Company stamp", enabled=False, stamp=Stamp( + kind="image", image_path=stored, width_pt=150.0)), + ]) + + def test_missing_file_gives_defaults(self): + profile = self.store.load() + self.assertEqual(profile, Profile()) + self.assertEqual(profile.signatures, []) + self.assertTrue(all(v is None for v in dataclasses.asdict(profile.settings).values())) + self.assertFalse(self.store.config_dir.exists()) + + def test_roundtrip(self): + profile = self.full_profile() + self.store.save(profile) + first = self.store.profile_path.read_bytes() + loaded = self.store.load() + self.assertEqual(loaded, profile) + self.assertIsInstance(loaded.signatures[0].placed_on, tuple) + self.assertIsInstance(loaded.settings.vignette.placed_on, tuple) + # save -> load -> save is stable, and a fresh store reads the same thing + self.store.save(loaded) + self.assertEqual(self.store.profile_path.read_bytes(), first) + again = ProfileStore(config_dir=self.store.config_dir, data_dir=self.store.data_dir) + self.assertEqual(again.load(), profile) + self.assertFalse(self.store.profile_path.with_name("profile.json.bak").exists()) + + def test_saved_json_shape(self): + self.store.save(self.full_profile()) + raw = self.store.profile_path.read_bytes() + self.assertFalse(raw.startswith(b"\xef\xbb\xbf")) # UTF-8, no BOM + self.assertIn("Sébastien".encode("utf-8"), raw) # not \u-escaped + data = json.loads(raw.decode("utf-8")) + self.assertEqual(set(data), {"version", "settings", "signatures"}) + self.assertEqual(data["version"], 1) + self.assertEqual(set(data["settings"]), SETTING_KEYS) + self.assertEqual(data["settings"]["vignette"], + {"page": 2, "x": 380.0, "y": 60.5, "placed_on": [595.0, 842.0]}) + + def keys(node): + if isinstance(node, dict): + for key, value in node.items(): + yield key + yield from keys(value) + elif isinstance(node, list): + for value in node: + yield from keys(value) + + for key in keys(data): + for banned in ("pin", "token", "password", "secret", "key_name", "upn"): + self.assertNotIn(banned, key.lower()) + text, image, unplaced = data["signatures"] + self.assertEqual(text, { + "id": "3f9a1c0b77de", "label": "Sébastien", "enabled": True, "kind": "text", + "text": "Sébastien\nRead and approved, {date}", "font": "caveat", "color": "#AA0000", + "font_size": 18.0, "page": 2, "x": 360.0, "y": 120.25, "placed_on": [595.0, 842.0]}) + # a stored image is written as its bare, content-addressed file name + self.assertRegex(image["image_path"], r"^[0-9a-f]{32}\.png$") + self.assertEqual(set(image), {"id", "label", "enabled", "kind", "image_path", "width_pt", + "all_pages", "x", "y", "placed_on"}) + self.assertEqual(set(unplaced), {"id", "label", "enabled", "kind", "image_path", + "width_pt"}) + # only the settings that were set are written + self.store.save(Profile(settings=Settings(language="nl"))) + self.assertEqual(json.loads(self.store.profile_path.read_text(encoding="utf-8")), + {"version": 1, "settings": {"language": "nl"}, "signatures": []}) + + def test_external_image_written_absolute(self): + outside = make_png(self.tmp / "elsewhere.png") + self.store.save(Profile(signatures=[Signature( + id="0123456789ab", label="x", stamp=Stamp(kind="image", image_path=outside))])) + data = json.loads(self.store.profile_path.read_text(encoding="utf-8")) + self.assertEqual(data["signatures"][0]["image_path"], str(outside)) + self.assertEqual(self.store.load().signatures[0].stamp.image_path, outside) + + def test_corrupt_json_moved_to_bak(self): + original = b'{"version": 1, "signatures": [' + self.write_profile(original) + self.assertEqual(self.store.load(), Profile()) + self.assert_set_aside(original) + # a second corrupt file replaces the older .bak + self.write_profile(b"again{") + self.assertEqual(self.store.load(), Profile()) + self.assert_set_aside(b"again{") + + def test_non_object_top_level_is_corrupt(self): + for content in ("[]", '"x"', "3", "null"): + with self.subTest(content=content): + self.write_profile(content) + self.assertEqual(self.store.load(), Profile()) + self.assert_set_aside(content.encode()) + + def test_missing_version_is_corrupt(self): + for content in ({"settings": {"language": "fr"}}, {"version": "1"}, {"version": 1.0}, + {"version": None}): + with self.subTest(content=content): + path = self.write_profile(content) + original = path.read_bytes() + self.assertEqual(self.store.load(), Profile()) + self.assert_set_aside(original) + + def test_unknown_version_moved_to_bak(self): + path = self.write_profile({"version": 2, "settings": {"language": "fr"}, + "signatures": [self.text_entry()]}) + original = path.read_bytes() + self.assertEqual(self.store.load(), Profile()) + self.assert_set_aside(original) + # so a later save cannot destroy the newer profile + self.store.save(Profile(settings=Settings(language="en"))) + self.assert_bak_kept(original) + + def assert_bak_kept(self, original: bytes): + self.assertEqual(self.store.profile_path.with_name("profile.json.bak").read_bytes(), + original) + self.assertTrue(self.store.profile_path.exists()) + + def test_bad_entry_skipped_others_kept(self): + sigs = self.load_signatures([ + self.text_entry(label="first"), + "not an object", + None, + {"text": "no kind"}, + {"kind": "sticker", "text": "x"}, + {"kind": "text", "text": " "}, + {"kind": "text", "text": "x", "font_size": "big"}, + {"kind": "text", "text": "x", "font_size": 0}, + {"kind": "text", "text": "x", "color": "blue"}, + {"kind": "image"}, + {"kind": "image", "image_path": 5}, + self.text_entry(label="last"), + ]) + self.assertEqual([s.label for s in sigs], ["first", "last"]) + self.assertTrue(self.store.profile_path.exists()) # not set aside + + def test_missing_image_entry_kept(self): + name = "9c1185a5c5e9fc54612808977ee8f548.png" + sigs = self.load_signatures([ + {"kind": "image", "image_path": name, "width_pt": 60, "label": "Initials", + "page": 2, "x": 540, "y": 20, "placed_on": [595, 842]}, + {"kind": "text", "text": "Jane", "font": "/nowhere/mine.ttf", + "all_pages": True, "x": 1, "y": 2, "placed_on": [595, 842]}, + ]) + self.assertEqual(len(sigs), 2) # unusable, but deletable + image, text = sigs + self.assertEqual(image.stamp.image_path, self.store.signatures_dir / name) + self.assertEqual(text.stamp.font, "/nowhere/mine.ttf") + with self.assertRaises(stamplib.StampError): + stamplib.validate_stamp(image.stamp, placed=False) + # the content is unusable NOW; its valid placement is not thrown away + self.assertEqual((image.stamp.page, image.stamp.x, image.stamp.y), (2, 540.0, 20.0)) + self.assertEqual(image.placed_on, A4) + self.assertTrue(text.stamp.all_pages) + self.assertEqual(text.placed_on, A4) + # save -> load is an identity for it as well + before = self.store.load() + self.store.save(before) + self.assertEqual(self.store.load(), before) + + def test_half_placement_cleared(self): + sigs = self.load_signatures([ + self.text_entry(page=1, x=10, placed_on=[595, 842]), # x without y + self.text_entry(page=1, page_anchor="last", x=1, y=2), # two targets + self.text_entry(page=3, placed_on=[595, 842]), # no position + self.text_entry(page=0, x=1, y=2), # page < 1 + self.text_entry(page_anchor="middle", x=1, y=2), # unknown anchor + self.text_entry(x=1, y=2, placed_on=[595, 842]), # no target + self.text_entry(all_pages=True, x=1), # all pages, half + self.text_entry(), # never placed + ]) + self.assertEqual(len(sigs), 8) + for sig in sigs: + with self.subTest(sig=sig): + self.assertEqual(sig.stamp, Stamp(kind="text", text="Jane Doe")) + self.assertFalse(sig.stamp.is_placed) + self.assertIsNone(sig.placed_on) + + def test_wrong_typed_settings_dropped(self): + self.write_profile({"version": 1, "settings": { + "language": 5, "mode": ["beid"], "pades_level": "b-t", "output_dir": None, + "azure_vault_url": {"a": 1}, "azure_auth": True, "azure_trust_anchors": 1.5, + "vignette": "bottom", "unknown": "ignored"}}) + self.assertEqual(self.store.load().settings, Settings(pades_level="b-t")) + for settings in ("x", [], 3, None): + with self.subTest(settings=settings): + self.write_profile({"version": 1, "settings": settings, "signatures": "nope"}) + self.assertEqual(self.store.load(), Profile()) + self.assertTrue(self.store.profile_path.exists()) # tolerated, not corrupt + + def test_duplicate_and_missing_ids_regenerated(self): + sigs = self.load_signatures([ + self.text_entry(id="0123456789ab"), + self.text_entry(id="0123456789ab"), + self.text_entry(), + self.text_entry(id="aaaaaaaaaaaa"), + ]) + ids = [s.id for s in sigs] + self.assertEqual((ids[0], ids[3]), ("0123456789ab", "aaaaaaaaaaaa")) # first one wins + self.assertEqual(len(set(ids)), 4) + self.assertTrue(all(profile_store.is_valid_id(i) for i in ids)) + + def test_save_is_atomic(self): + self.store.save(Profile(settings=Settings(language="fr"))) + before = self.store.profile_path.read_bytes() + with mock.patch("profile_store.os.replace", side_effect=OSError("disk full")): + with self.assertRaises(OSError): + self.store.save(Profile(settings=Settings(language="de"))) + self.assertEqual(self.store.profile_path.read_bytes(), before) + self.assertEqual([p.name for p in self.store.config_dir.iterdir()], ["profile.json"]) + self.assertEqual(self.store.load().settings.language, "fr") + + def test_save_creates_parent_dirs(self): + store = ProfileStore(config_dir=self.tmp / "a" / "b" / "cfg", data_dir=self.tmp / "data") + store.save(Profile(settings=Settings(mode="image"))) + self.assertEqual(store.load().settings.mode, "image") + self.assertFalse((self.tmp / "data").exists()) # only what is needed + + @unittest.skipUnless(os.name == "posix", "POSIX file modes") + def test_file_mode_0600(self): + self.store.save(Profile(settings=Settings(language="fr"))) + self.assertEqual(self.store.profile_path.stat().st_mode & 0o777, 0o600) + self.store.save(Profile(settings=Settings(language="nl"))) # replacing keeps it private + self.assertEqual(self.store.profile_path.stat().st_mode & 0o777, 0o600) + stored = self.store.store_image(Image.new("RGBA", (4, 4))) + self.assertEqual(stored.stat().st_mode & 0o777, 0o600) + + def test_load_never_raises_on_unreadable(self): + self.store.profile_path.mkdir(parents=True) # a directory in its place + self.assertEqual(self.store.load(), Profile()) + self.assertTrue(self.store.profile_path.is_dir()) # left alone + self.assertEqual([p.name for p in self.store.config_dir.iterdir()], ["profile.json"]) + + def test_save_refused_after_unreadable_load(self): + # readable again later (a transient I/O error, a file owned by another + # user): saving the EMPTY profile that load() fell back to would + # destroy the library + self.store.save(Profile(signatures=[ + Signature(id="0123456789ab", label="Jane", stamp=Stamp(kind="text", text="Jane"))])) + before = self.store.profile_path.read_bytes() + with mock.patch.object(Path, "read_text", side_effect=PermissionError(13, "denied")): + profile = self.store.load() + self.assertEqual(profile, Profile()) + profile.settings.language = "fr" + with self.assertRaises(OSError): + self.store.save(profile) + self.assertEqual(self.store.profile_path.read_bytes(), before) + self.assertEqual([p.name for p in self.store.config_dir.iterdir()], ["profile.json"]) + # the next successful load lifts the refusal + profile = self.store.load() + self.assertEqual([sig.label for sig in profile.signatures], ["Jane"]) + profile.settings.language = "fr" + self.store.save(profile) + self.assertEqual(self.store.load().settings.language, "fr") + + def test_save_refused_when_set_aside_failed(self): + newer = json.dumps({"version": 2, "signatures": [{"future": True}]}).encode() + self.write_profile(newer) + bak = self.store.profile_path.with_name("profile.json.bak") + (bak / "keep").mkdir(parents=True) # the .bak slot is unusable + self.assertEqual(self.store.load(), Profile()) + self.assertEqual(self.store.profile_path.read_bytes(), newer) # still in place + with self.assertRaises(OSError): + self.store.save(Profile(settings=Settings(language="fr"))) + self.assertEqual(self.store.profile_path.read_bytes(), newer) + + def test_utf8_bom_is_not_corruption(self): + self.store.save(Profile(settings=Settings(language="nl"), signatures=[ + Signature(id="0123456789ab", label="Jane", stamp=Stamp(kind="text", text="Jane"))])) + path = self.store.profile_path + path.write_bytes(b"\xef\xbb\xbf" + path.read_bytes()) # e.g. PowerShell 5 + profile = self.store.load() + self.assertEqual(profile.settings.language, "nl") + self.assertEqual([sig.label for sig in profile.signatures], ["Jane"]) + self.assertEqual([p.name for p in self.store.config_dir.iterdir()], ["profile.json"]) + self.store.save(profile) # written back without it + self.assertFalse(path.read_bytes().startswith(b"\xef\xbb\xbf")) + + def test_binary_garbage_is_corrupt(self): + garbage = b"\xff\xfe\x00garbage" + self.write_profile(garbage) + self.assertEqual(self.store.load(), Profile()) + self.assert_set_aside(garbage) + + def test_deeply_nested_json_is_corrupt(self): + deep = "[" * 200000 + self.write_profile(deep) + self.assertEqual(self.store.load(), Profile()) # no RecursionError + self.assert_set_aside(deep.encode()) + + def test_bool_version_is_corrupt(self): + path = self.write_profile('{"version": true, "settings": {"language": "fr"}}') + original = path.read_bytes() + self.assertEqual(self.store.load(), Profile()) + self.assert_set_aside(original) + + def test_bad_id_types_regenerated(self): + bad_ids = [5, "vignette", "XYZ", "0123456789AB", None, ["a"], ""] + sigs = self.load_signatures( + [self.text_entry(id=bad) for bad in bad_ids] + [self.text_entry(id="0a1b2c3d4e5f")]) + self.assertEqual(len(sigs), len(bad_ids) + 1) + for sig, bad in zip(sigs, bad_ids): + with self.subTest(bad=bad): + self.assertNotEqual(sig.id, bad) + self.assertTrue(profile_store.is_valid_id(sig.id)) + self.assertEqual(sigs[-1].id, "0a1b2c3d4e5f") + self.assertEqual(len({s.id for s in sigs}), len(sigs)) + + def test_bad_label_replaced_and_long_label_truncated(self): + stored = self.store.store_image(Image.new("RGBA", (4, 4))) + sigs = self.load_signatures([ + self.text_entry(label=5), + self.text_entry(label=" "), + self.text_entry(), + {"kind": "text", "text": '"quoted"', "label": None}, + {"kind": "image", "image_path": stored.name, "label": ["x"]}, + self.text_entry(label="L" * 300), + {"kind": "text", "text": "T" * 200}, + self.text_entry(label=" Kept as typed "), + ]) + self.assertEqual([s.label for s in sigs[:5]], + ["Jane Doe", "Jane Doe", "Jane Doe", '"quoted"', stored.name]) + self.assertEqual(sigs[5].label, "L" * 80) + self.assertEqual(sigs[6].label, "T" * 30 + "…") + self.assertEqual(sigs[7].label, " Kept as typed ") + + def test_non_finite_placement_cleared(self): + self.write_profile( + '{"version": 1, "signatures": [' + '{"kind": "text", "text": "a", "page": 1, "x": NaN, "y": 2, "placed_on": [595, 842]},' + '{"kind": "text", "text": "b", "page": 1, "x": 1, "y": Infinity},' + '{"kind": "text", "text": "c", "all_pages": true, "x": -Infinity, "y": 2},' + '{"kind": "text", "text": "d", "page": 1, "x": 1e999, "y": 2}]}') + sigs = self.store.load().signatures + self.assertEqual([s.stamp.text for s in sigs], ["a", "b", "c", "d"]) # all kept + for sig in sigs: + self.assertEqual(sig.stamp, Stamp(kind="text", text=sig.stamp.text)) + self.assertIsNone(sig.placed_on) + + def test_huge_integers_never_raise(self): + huge = "1" + "0" * 400 # too large for a float + self.write_profile( + '{"version": 1, "settings": {"vignette": {"page": 1, "x": %s, "y": 2, ' + '"placed_on": [595, 842]}}, "signatures": [' + '{"kind": "text", "text": "a", "page": 1, "x": %s, "y": 2},' + '{"kind": "text", "text": "b", "page": %s, "x": 1, "y": 2, "placed_on": [%s, 842]},' + '{"kind": "text", "text": "c", "page": 1, "x": 1, "y": 2, "placed_on": [595, 842]}]}' + % (huge, huge, huge, huge)) + profile = self.store.load() + self.assertIsNone(profile.settings.vignette) + # a: not a number -> skipped; b: kept, its placed_on dropped; c: untouched + self.assertEqual([s.stamp.text for s in profile.signatures], ["b", "c"]) + self.assertIsNone(profile.signatures[0].placed_on) + self.assertEqual(profile.signatures[1].placed_on, A4) + self.assertFalse(profile_store.stamp_placement_applies( + profile.signatures[0].stamp, A4, [A4])) + self.store.save(profile) # still serialisable + + def test_hostile_paths_never_raise(self): + # an unknown ~user (RuntimeError in pathlib) and an over-long name + # (OSError): both are just files that are not there + # a NUL or a lone surrogate (ValueError in Path.resolve) must not make + # the NEXT save raise either + sigs = self.load_signatures([ + {"kind": "image", "image_path": "~no_such_user_zz/sig.png"}, + {"kind": "image", "image_path": "x" * 5000 + ".png"}, + {"kind": "image", "image_path": "a\x00b.png"}, + {"kind": "image", "image_path": "\ud800.png"}, + {"kind": "text", "text": "a", "font": "~no_such_user_zz/f.ttf"}, + {"kind": "text", "text": "b", "font": "x" * 5000}, + {"kind": "text", "text": "c", "font": "\ud800\x00.ttf"}, + self.text_entry(label="fine"), + ]) + self.assertEqual(len(sigs), 8) # kept, unusable + profile = Profile(signatures=list(sigs)) + self.store.save(profile) + self.assertEqual(self.store.load(), profile) + for sig in sigs[:4]: + self.assertFalse(self.store.is_store_file(sig.stamp.image_path)) + self.store.remove_signature(profile, sig.id) # deletable, harmlessly + self.assertEqual(len(profile.signatures), 4) + + def test_damaged_font_entry_kept(self): + # a font file FreeType opens but fails on (OSError while measuring): + # load must not raise; the entry is kept like any unusable font + font = damaged_font(self.tmp / "damaged.ttf") + sigs = self.load_signatures([ + self.text_entry(label="before"), + self.text_entry(label="damaged", font=str(font), page=1, x=1, y=2, + placed_on=[595, 842]), + self.text_entry(label="after"), + ]) + self.assertEqual([s.label for s in sigs], ["before", "damaged", "after"]) + self.assertEqual(sigs[1].stamp.font, str(font)) + self.assertEqual(sigs[1].placed_on, A4) # placement not thrown away + with self.assertRaises(stamplib.StampError) as cm: + stamplib.validate_stamp(sigs[1].stamp, placed=False) + self.assertTrue(str(cm.exception).startswith("cannot load font ")) + + def test_lone_surrogate_roundtrips(self): + # e.g. an output folder whose name is not valid UTF-8 (surrogateescape), + # or a "\ud800" escape typed into the file: save must not raise a + # ValueError, and the file must stay loadable UTF-8. + self.write_profile('{"version": 1, "settings": {"output_dir": "/data/caf\\udce9"}, ' + '"signatures": [{"kind": "text", "text": "J\\ud800D \\\\ é", ' + '"label": "L\\udfff", "id": "0123456789ab"}]}') + profile = self.store.load() + self.assertEqual(profile.settings.output_dir, "/data/caf\udce9") + self.assertEqual(profile.signatures[0].stamp.text, "J\ud800D \\ é") + self.store.save(profile) + raw = self.store.profile_path.read_bytes() + raw.decode("utf-8") # valid UTF-8 + self.assertIn("é".encode("utf-8"), raw) # the rest is not escaped + self.assertEqual(self.store.load(), profile) + self.assertFalse(self.store.profile_path.with_name("profile.json.bak").exists()) + + def test_bad_placed_on_dropped_placement_kept(self): + bad_values = ["x", [595], [595, "842"], [595, 842, 1], [True, 842], None, {"w": 1}] + entries = [self.text_entry(page=2, x=10, y=20, placed_on=bad) for bad in bad_values] + self.write_profile(json.dumps({"version": 1, "signatures": entries})[:-2] + + ', {"kind": "text", "text": "Jane Doe", "page": 2, "x": 10, ' + '"y": 20, "placed_on": [NaN, 842]}]}') + sigs = self.store.load().signatures + self.assertEqual(len(sigs), len(bad_values) + 1) + for sig in sigs: + self.assertEqual(sig.stamp, Stamp(kind="text", text="Jane Doe", page=2, x=10.0, y=20.0)) + self.assertIsNone(sig.placed_on) # simply not in force + good = self.load_signatures([self.text_entry(page=2, x=10, y=20, placed_on=[595, 842])]) + self.assertEqual(good[0].placed_on, A4) + + def test_all_pages_without_position_roundtrips(self): + profile = Profile(signatures=[Signature( + id="0123456789ab", label="Initials", stamp=Stamp(kind="text", text="JD", + all_pages=True))]) + self.store.save(profile) + loaded = self.store.load() + self.assertEqual(loaded, profile) + stamp = loaded.signatures[0].stamp + self.assertTrue(stamp.all_pages) + self.assertIsNone(stamp.x) + self.assertIsNone(stamp.y) + + def test_oversized_text_entry_skipped(self): + sigs = self.load_signatures([ + self.text_entry(label="before"), + {"kind": "text", "text": "W" * 2000, "font": "lato", "font_size": 400}, + self.text_entry(label="after"), + ]) + self.assertEqual([s.label for s in sigs], ["before", "after"]) + + def test_non_finite_vignette_dropped(self): + good = '{"page": 2, "x": 380, "y": 60, "placed_on": [595, 842]}' + self.write_profile('{"version": 1, "settings": {"vignette": %s}}' % good) + self.assertEqual(self.store.load().settings.vignette, + Placement(page=2, x=380.0, y=60.0, placed_on=A4)) + for bad in ('{"page": 2, "x": NaN, "y": 60, "placed_on": [595, 842]}', + '{"page": 2, "x": 380, "y": Infinity, "placed_on": [595, 842]}', + '{"page": 2, "x": 380, "y": 60, "placed_on": [NaN, 842]}', + '{"page": 2, "x": 380, "y": 60, "placed_on": [595]}', + '{"page": 2, "x": 380, "y": 60}', + '{"page": 0, "x": 380, "y": 60, "placed_on": [595, 842]}', + '{"page": true, "x": 380, "y": 60, "placed_on": [595, 842]}', + '{"page": 2.0, "x": 380, "y": 60, "placed_on": [595, 842]}', + '{"page": 2, "x": true, "y": 60, "placed_on": [595, 842]}', + '{"page": 2, "x": "380", "y": 60, "placed_on": [595, 842]}', + '{"x": 380, "y": 60, "placed_on": [595, 842]}', + '[2, 380, 60]', '"bottom-right"'): + with self.subTest(vignette=bad): + self.write_profile( + '{"version": 1, "settings": {"language": "fr", "vignette": %s}}' % bad) + settings = self.store.load().settings + self.assertIsNone(settings.vignette) + self.assertEqual(settings.language, "fr") # the rest is kept + + def test_incomplete_vignette_cannot_be_built(self): + # save() writes a vignette as it is: a partial one must not exist at all + with self.assertRaises(TypeError): + Placement(page=1, x=5.0) + + +class ImageStore(StoreCase): + def image_sig(self, path, sig_id="0123456789ab") -> Signature: + return Signature(id=sig_id, label="img", stamp=Stamp(kind="image", image_path=path)) + + def stored(self, color=(0, 0, 255, 255)) -> Path: + return self.store.store_image(Image.new("RGBA", (8, 8), color)) + + def test_import_is_content_addressed(self): + src = make_png(self.tmp / "My Signature.PNG") + stored = self.store.import_image(src) + digest = hashlib.sha256(src.read_bytes()).hexdigest() + self.assertEqual(stored, self.store.signatures_dir / f"{digest[:32]}.png") + self.assertEqual(stored.read_bytes(), src.read_bytes()) + self.assertTrue(self.store.is_store_file(stored)) + # the same bytes under another name: one file, left untouched + mtime = stored.stat().st_mtime_ns + copy = self.tmp / "copy.png" + copy.write_bytes(src.read_bytes()) + self.assertEqual(self.store.import_image(copy), stored) + self.assertEqual(stored.stat().st_mtime_ns, mtime) + self.assertEqual(len(list(self.store.signatures_dir.iterdir())), 1) + # other content -> another file; an exotic extension becomes .img + other = self.tmp / "scan.jfif" + Image.new("RGB", (5, 5), (9, 9, 9)).save(other, format="JPEG") + second = self.store.import_image(other) + self.assertEqual(second.suffix, ".img") + self.assertTrue(self.store.is_store_file(second)) + self.assertEqual(stamplib.load_image(second).size, (5, 5)) + self.assertEqual(len(list(self.store.signatures_dir.iterdir())), 2) + + def test_import_survives_moved_original(self): + src = make_png(self.tmp / "sig.png", size=(30, 10)) + stored = self.store.import_image(src) + src.unlink() + self.assertEqual(stamplib.load_image(stored).size, (30, 10)) + stamplib.validate_stamp(Stamp(kind="image", image_path=stored), placed=False) + + def test_import_rejects_non_image(self): + fake = self.tmp / "fake.png" + fake.write_text("not an image", encoding="utf-8") + for source in (fake, self.tmp / "missing.png"): + with self.subTest(source=source.name): + with self.assertRaises(stamplib.StampError): + self.store.import_image(source) + self.assertFalse(self.store.signatures_dir.exists()) # not even created + self.assertFalse(self.store.data_dir.exists()) + + def test_import_write_failure_is_oserror(self): + src = make_png(self.tmp / "sig.png") + self.store.data_dir.write_text("a file where the data dir should be", encoding="utf-8") + with self.assertRaises(OSError) as cm: + self.store.import_image(src) + self.assertNotIsInstance(cm.exception, stamplib.StampError) + + def test_store_image_writes_png_with_alpha(self): + drawn = stamplib.render_strokes_image([[(5, 5), (40, 20)]], "#102030") + stored = self.store.store_image(drawn) + self.assertRegex(stored.name, r"^[0-9a-f]{32}\.png$") + self.assertEqual(stored.parent, self.store.signatures_dir) + self.assertEqual(stored.read_bytes()[:8], b"\x89PNG\r\n\x1a\n") + back = stamplib.load_image(stored) + self.assertEqual((back.mode, back.size), ("RGBA", drawn.size)) + self.assertEqual(back.tobytes(), drawn.tobytes()) # alpha included + self.assertEqual(self.store.store_image(drawn), stored) # same pixels, same file + self.assertEqual(len(list(self.store.signatures_dir.iterdir())), 1) + + def test_remove_signature_deletes_unreferenced_file(self): + stored = self.stored() + profile = Profile(signatures=[ + self.image_sig(stored), + Signature(id="aaaaaaaaaaaa", label="t", stamp=Stamp(kind="text", text="x"))]) + self.store.remove_signature(profile, "0123456789ab") + self.assertEqual([s.id for s in profile.signatures], ["aaaaaaaaaaaa"]) + self.assertFalse(stored.exists()) + self.assertFalse(self.store.profile_path.exists()) # does NOT save + # removing a text signature touches no file; a missing file is ignored + self.store.remove_signature(profile, "aaaaaaaaaaaa") + self.assertEqual(profile.signatures, []) + gone = Profile(signatures=[self.image_sig(stored)]) + self.store.remove_signature(gone, "0123456789ab") + self.assertEqual(gone.signatures, []) + + def test_remove_keeps_file_shared_by_another_signature(self): + stored = self.stored() + profile = Profile(signatures=[self.image_sig(stored, "0123456789ab"), + self.image_sig(stored, "aaaaaaaaaaaa")]) + self.store.remove_signature(profile, "0123456789ab") + self.assertTrue(stored.exists()) + self.store.remove_signature(profile, "aaaaaaaaaaaa") # the last reference + self.assertFalse(stored.exists()) + + def test_remove_never_deletes_outside_store(self): + outside = make_png(self.tmp / "original.png") + lookalike = make_png(self.tmp / ("a" * 32 + ".png")) # right name, wrong place + profile = Profile(signatures=[self.image_sig(outside, "0123456789ab"), + self.image_sig(lookalike, "aaaaaaaaaaaa")]) + self.store.remove_signature(profile, "0123456789ab") + self.store.remove_signature(profile, "aaaaaaaaaaaa") + self.assertEqual(profile.signatures, []) + self.assertTrue(outside.exists()) + self.assertTrue(lookalike.exists()) + + def test_remove_never_follows_traversal(self): + self.stored() # signatures/ exists + plain = make_png(self.store.data_dir / "outside.png") + lookalike = make_png(self.store.data_dir / ("b" * 32 + ".png")) + sigs = self.load_signatures([ + {"kind": "image", "image_path": "../outside.png", "id": "0123456789ab"}, + {"kind": "image", "image_path": f"../{lookalike.name}", "id": "aaaaaaaaaaaa"}, + ]) + self.assertEqual(len(sigs), 2) # the files exist: usable + self.assertEqual(sigs[0].stamp.image_path, + self.store.signatures_dir / ".." / "outside.png") + profile = Profile(signatures=list(sigs)) + for sig_id in ("0123456789ab", "aaaaaaaaaaaa"): + self.assertFalse(self.store.is_store_file(profile.signatures[0].stamp.image_path)) + self.store.remove_signature(profile, sig_id) + self.assertEqual(profile.signatures, []) + self.assertTrue(plain.exists()) + self.assertTrue(lookalike.exists()) + # and saving such an entry never writes it as a store-relative name + self.store.save(Profile(signatures=sigs)) + saved = json.loads(self.store.profile_path.read_text(encoding="utf-8")) + self.assertEqual([e["image_path"] for e in saved["signatures"]], + [str(plain), str(lookalike)]) + + @unittest.skipUnless(os.name == "posix", "symlinks") + def test_remove_ignores_symlink(self): + self.stored() + secret = make_png(self.tmp / "secret.png") + link = self.store.signatures_dir / ("c" * 32 + ".png") # a correctly named link + link.symlink_to(secret) + self.assertFalse(self.store.is_store_file(link)) + profile = Profile(signatures=[self.image_sig(link)]) + self.store.remove_signature(profile, "0123456789ab") + self.assertEqual(profile.signatures, []) + self.assertTrue(secret.exists()) + self.assertTrue(link.is_symlink()) # nothing was unlinked + # a link to a REAL store file is not a store file either: deleting the + # signature that points at the link must not delete the link's target + stored = self.stored() + alias = self.store.signatures_dir / ("d" * 32 + ".png") + alias.symlink_to(stored) + profile = Profile(signatures=[self.image_sig(alias)]) + self.store.remove_signature(profile, "0123456789ab") + self.assertTrue(stored.exists()) + self.assertTrue(alias.is_symlink()) + + def test_remove_ignores_foreign_name_in_store(self): + self.stored() + mine = make_png(self.store.signatures_dir / "mine.png") + profile = Profile(signatures=[self.image_sig(mine)]) + self.store.remove_signature(profile, "0123456789ab") + self.assertEqual(profile.signatures, []) + self.assertTrue(mine.exists()) + + def test_is_store_file_rules(self): + stored = self.stored() + sig_dir = self.store.signatures_dir + name = "d" * 32 + ".png" + (sig_dir / "sub").mkdir() + rows = [ + (stored, True), + (str(stored), True), + (sig_dir / "sub" / ".." / stored.name, True), # resolves INTO the store + (sig_dir / name, True), # the name decides + (sig_dir / ".." / name, False), # traversal out of the store + (sig_dir / ".." / ".." / name, False), + (make_png(self.tmp / name), False), # absolute path elsewhere + (make_png(sig_dir / "mine.png"), False), # foreign name + (make_png(sig_dir / ("D" * 32 + ".png")), False), # upper-case hex + (make_png(sig_dir / ("d" * 31 + ".png")), False), # too short + (make_png(sig_dir / ("d" * 32 + ".PNG")), False), # upper-case extension + (make_png(sig_dir / "sub" / name), False), # sub-directory of the store + (sig_dir, False), + (Path(name), False), # relative to the cwd + ] + if os.name == "posix": + link = sig_dir / ("e" * 32 + ".png") + link.symlink_to(stored) # even to a real store file + rows.append((link, False)) + for path, expected in rows: + with self.subTest(path=str(path)): + self.assertIs(self.store.is_store_file(path), expected) + + def test_shared_file_compared_by_resolved_path(self): + stored = self.stored() + alias = self.store.signatures_dir / ".." / "signatures" / stored.name + self.assertNotEqual(alias, stored) # different spelling… + self.assertEqual(alias.resolve(), stored.resolve()) # …same file + profile = Profile(signatures=[self.image_sig(stored, "0123456789ab"), + self.image_sig(alias, "aaaaaaaaaaaa")]) + self.store.remove_signature(profile, "0123456789ab") + self.assertTrue(stored.exists()) # still referenced + self.store.remove_signature(profile, "aaaaaaaaaaaa") + self.assertFalse(stored.exists()) + + def test_remove_unknown_id_is_noop(self): + stored = self.stored() + profile = Profile(signatures=[self.image_sig(stored)]) + for sig_id in ("ffffffffffff", "vignette", None): + self.store.remove_signature(profile, sig_id) + self.assertEqual(len(profile.signatures), 1) + self.assertTrue(stored.exists()) + + def test_no_sweep_of_unreferenced_files(self): + orphan = self.stored((1, 2, 3, 255)) + used = self.stored((4, 5, 6, 255)) + profile = Profile(signatures=[self.image_sig(used)]) + self.store.save(profile) + self.store.load() + self.store.remove_signature(profile, "0123456789ab") + self.assertTrue(orphan.exists()) # never listed, never swept + self.assertFalse(used.exists()) + + +class PlacementRule(unittest.TestCase): + def placed(self, **kwargs) -> Stamp: + return Stamp(kind="text", text="x", x=10.0, y=20.0, **kwargs) + + def test_numbered_page_same_size_applies(self): + dims = [A4, LETTER] + self.assertTrue(profile_store.stamp_placement_applies(self.placed(page=1), A4, dims)) + self.assertTrue(profile_store.stamp_placement_applies(self.placed(page=2), LETTER, dims)) + # lists (as read from JSON) compare like tuples + self.assertTrue(profile_store.stamp_placement_applies( + self.placed(page=1), [595.0, 842.0], [[595.0, 842.0]])) + + def test_different_size_does_not_apply(self): + dims = [A4, LETTER] + self.assertFalse(profile_store.stamp_placement_applies(self.placed(page=2), A4, dims)) + # EXACT equality, no tolerance + self.assertFalse(profile_store.stamp_placement_applies( + self.placed(page=1), (595.0, 842.001), dims)) + + def test_page_beyond_template_does_not_apply(self): + self.assertFalse(profile_store.stamp_placement_applies(self.placed(page=3), A4, [A4, A4])) + self.assertFalse(profile_store.stamp_placement_applies(self.placed(page=0), A4, [A4, A4])) + self.assertFalse(profile_store.stamp_placement_applies(self.placed(page=1), A4, [])) + self.assertFalse(profile_store.stamp_placement_applies(self.placed(page=1), A4, None)) + + def test_unplaced_does_not_apply(self): + dims = [A4] + unplaced = Stamp(kind="text", text="x") + self.assertFalse(profile_store.stamp_placement_applies(unplaced, A4, dims)) + self.assertFalse(profile_store.stamp_placement_applies( + Stamp(kind="text", text="x", page=1, x=1.0), A4, dims)) # no y + self.assertFalse(profile_store.stamp_placement_applies( + Stamp(kind="text", text="x", x=1.0, y=1.0), A4, dims)) # no target + self.assertFalse(profile_store.stamp_placement_applies(self.placed(page=1), None, dims)) + self.assertFalse(profile_store.stamp_placement_applies( + Stamp(kind="text", text="x", all_pages=True), A4, dims)) # remembers the flag only + + def test_all_pages_applies_if_any_page_matches(self): + stamp = self.placed(all_pages=True) + self.assertTrue(profile_store.stamp_placement_applies(stamp, LETTER, [A4, LETTER, A4])) + self.assertFalse(profile_store.stamp_placement_applies(stamp, LETTER, [A4, A4])) + + def test_anchor_requires_anchor_page(self): + dims = [A4, A4] + stamp = self.placed(page=1) + self.assertFalse(profile_store.stamp_placement_applies(stamp, A4, dims, anchor="last")) + self.assertTrue(profile_store.stamp_placement_applies(stamp, A4, dims, anchor="first")) + self.assertTrue(profile_store.stamp_placement_applies( + self.placed(page=2), A4, dims, anchor="last")) + # a one-page template: page 1 is both the first and the last page + self.assertTrue(profile_store.stamp_placement_applies(stamp, A4, [A4], anchor="last")) + + def test_all_pages_under_anchor_needs_anchor_page_size(self): + stamp = self.placed(all_pages=True) + dims = [A4, LETTER] + self.assertTrue(profile_store.stamp_placement_applies(stamp, A4, dims, anchor="first")) + self.assertFalse(profile_store.stamp_placement_applies(stamp, A4, dims, anchor="last")) + self.assertTrue(profile_store.stamp_placement_applies(stamp, LETTER, dims, anchor="last")) + + def test_stamp_with_page_anchor_maps_to_template_page(self): + dims = [A4, LETTER, LETTER] + last, first = self.placed(page_anchor="last"), self.placed(page_anchor="first") + self.assertTrue(profile_store.stamp_placement_applies(last, LETTER, dims)) + self.assertFalse(profile_store.stamp_placement_applies(last, A4, dims)) + self.assertTrue(profile_store.stamp_placement_applies(first, A4, dims)) + self.assertTrue(profile_store.stamp_placement_applies(last, LETTER, dims, anchor="last")) + self.assertFalse(profile_store.stamp_placement_applies(last, LETTER, dims, anchor="first")) + + def test_vignette_rule(self): + dims = [A4, LETTER] + applies = profile_store.page_placement_applies + self.assertTrue(applies(1, A4, dims)) + self.assertTrue(applies(2, LETTER, dims)) + self.assertFalse(applies(2, A4, dims)) + self.assertFalse(applies(3, A4, dims)) + self.assertFalse(applies(None, A4, dims)) + self.assertFalse(applies(1, None, dims)) + self.assertFalse(applies(1, A4, [])) + self.assertFalse(applies(1, A4, dims, anchor="last")) + self.assertTrue(applies(2, LETTER, dims, anchor="last")) + self.assertTrue(applies(1, A4, dims, anchor="first")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_sign_pdfs_beid.py b/test_sign_pdfs_beid.py index f076253..df35499 100644 --- a/test_sign_pdfs_beid.py +++ b/test_sign_pdfs_beid.py @@ -6,34 +6,30 @@ Run with: ./venv/bin/python -m unittest -v """ +import dataclasses +import datetime +import gc +import json +import os +import shutil import subprocess import sys import tempfile import unittest import warnings from pathlib import Path +from unittest import mock from PIL import Image import sign_pdfs_beid as core +import stamps as stamplib +from stamps import Stamp, StampError -def make_pdf(path, page_sizes) -> Path: - """Writes a minimal but valid PDF (correct xref) with the given pages, - each described by a (width, height) in points. Each page has a (empty) - /Contents stream — required by pyHanko in order to stamp.""" - n = len(page_sizes) - objs = [b"<>"] # obj 1 - kids = " ".join(f"{3 + 2 * i} 0 R" for i in range(n)) - objs.append(f"<>".encode()) # obj 2 - content = b"q Q\n" - for i, (w, h) in enumerate(page_sizes): - objs.append( - f"<>".encode() - ) - objs.append(b"<>\nstream\n" % len(content) + content + b"\nendstream") - +def write_pdf_objects(path, objs) -> Path: + """Writes a PDF (correct xref) from the bodies of its objects: `objs[0]` + is object 1 and must be the catalog.""" out = bytearray(b"%PDF-1.4\n") offsets = [] for i, body in enumerate(objs, start=1): @@ -49,11 +45,72 @@ def make_pdf(path, page_sizes) -> Path: return Path(path) +def pdf_stream(data: bytes) -> bytes: + """Body of a stream object holding `data`.""" + return b"<>\nstream\n" % len(data) + data + b"\nendstream" + + +def make_pdf(path, page_sizes, origin=(0, 0)) -> Path: + """Writes a minimal but valid PDF (correct xref) with the given pages, + each described by a (width, height) in points. Each page has a (empty) + /Contents stream — required by pyHanko in order to stamp. `origin` is the + lower-left corner of every MediaBox.""" + n = len(page_sizes) + ox, oy = origin + objs = [b"<>"] # obj 1 + kids = " ".join(f"{3 + 2 * i} 0 R" for i in range(n)) + objs.append(f"<>".encode()) # obj 2 + for i, (w, h) in enumerate(page_sizes): + objs.append( + f"<>".encode() + ) + objs.append(pdf_stream(b"q Q\n")) + return write_pdf_objects(path, objs) + + def make_png(path, size=(300, 120)) -> Path: Image.new("RGBA", size, (255, 0, 0, 128)).save(path) return Path(path) +def xobject_counts(pdf_path) -> list[int]: + """Number of XObjects referenced by each page of a PDF: one per visual + signature stamped on it (the signature vignette is an annotation, not a + page XObject).""" + # Lazy: test_stamps imports this module at import time. + from test_stamps import page_xobjects, pdf_pages + return [len(page_xobjects(page)) for page in pdf_pages(pdf_path)] + + +def text_stamp(text="Jane Doe", **kwargs) -> Stamp: + """A text stamp; placed on page 1 at (50, 50) unless told otherwise.""" + if not {"page", "page_anchor", "all_pages"} & kwargs.keys(): + kwargs["page"] = 1 + kwargs.setdefault("x", 50.0) + kwargs.setdefault("y", 50.0) + return Stamp(kind="text", text=text, **kwargs) + + +_PROFILE_SANDBOX = None + + +def setUpModule(): + """Safety net: no test of this module can reach the real user profile, + even one that builds `gui.CachetApp` without `_GuiTestBase` (whose + per-test directories sit on top of this).""" + global _PROFILE_SANDBOX + root = tempfile.mkdtemp(prefix="cachet-tests-") + _PROFILE_SANDBOX = mock.patch.dict(os.environ, { + "CACHET_CONFIG_DIR": os.path.join(root, "cfg"), + "CACHET_DATA_DIR": os.path.join(root, "data")}) + _PROFILE_SANDBOX.start() + + +def tearDownModule(): + _PROFILE_SANDBOX.stop() + + class TmpCase(unittest.TestCase): def setUp(self): self.tmp = Path(tempfile.mkdtemp()) @@ -126,13 +183,111 @@ def test_anchor_keeps_strict_check_for_same_page_count(self): self.assertFalse(r.ok) self.assertIn("page 1", r.reason) + def test_two_anchors_both_checked(self): + dims = core.page_dimensions(self.tpl) + both = ("first", "last") + # first page matches, last page differs -> rejected, naming the last page + odd_last = make_pdf(self.p("odd_last.pdf"), + [(595, 842), (595, 842), (200, 200)]) + r = core.validate_against_template(dims, odd_last, anchors=both) + self.assertFalse(r.ok) + self.assertIn("last page", r.reason) + # ... and the other way round names the first page + odd_first = make_pdf(self.p("odd_first.pdf"), + [(200, 200), (595, 842), (595, 842)]) + r = core.validate_against_template(dims, odd_first, anchors=both) + self.assertFalse(r.ok) + self.assertIn("first page", r.reason) + # both anchor pages match -> accepted + more = make_pdf(self.p("more.pdf"), [(595, 842), (100, 100), (595, 842)]) + r = core.validate_against_template(dims, more, anchors=both) + self.assertTrue(r.ok, r.reason) + self.assertIn("first and last pages", r.reason) + # the order of the collection does not matter + self.assertEqual( + core.validate_against_template(dims, more, anchors={"last", "first"}).reason, + r.reason) + + def test_blocker_rejects_with_reason(self): + dims = core.page_dimensions(self.tpl) + fewer = make_pdf(self.p("fewer.pdf"), [(595, 842)]) + blocker = "signature #1 targets page 1" + # even with a matching anchor: ONE non-anchored element is enough + r = core.validate_against_template(dims, fewer, anchors=("last",), blocker=blocker) + self.assertFalse(r.ok) + self.assertIn("1 page(s), the template has 2", r.reason) + self.assertIn(blocker, r.reason) + self.assertIn("only first/last", r.reason) + # a blocker never rejects a file with the template's page count + same = make_pdf(self.p("same.pdf"), [(595, 842), (595, 842)]) + r = core.validate_against_template(dims, same, anchors=(), blocker=blocker) + self.assertEqual((r.ok, r.reason), (True, "")) + + def test_single_anchor_reason_unchanged(self): + dims = core.page_dimensions(self.tpl) + fewer = make_pdf(self.p("fewer.pdf"), [(595, 842)]) + odd_last = make_pdf(self.p("odd_last.pdf"), + [(595, 842), (595, 842), (200, 200)]) + accepted = "1 page(s), the template has 2 — signed on the last page" + rejected = ("3 page(s), the template has 2; last page 200.00×200.00 pt " + "≠ template 595.00×842.00 pt") + # historical keyword and its generalisation give the very same strings + for kwargs in ({"page_anchor": "last"}, {"anchors": ("last",)}): + r = core.validate_against_template(dims, fewer, **kwargs) + self.assertEqual((r.ok, r.reason), (True, accepted)) + r = core.validate_against_template(dims, odd_last, **kwargs) + self.assertEqual((r.ok, r.reason), (False, rejected)) + # no anchor at all: the bare count message, as before + r = core.validate_against_template(dims, fewer) + self.assertEqual((r.ok, r.reason), (False, "1 page(s), the template has 2")) + + def test_anchors_keyword_overrides_page_anchor(self): + dims = core.page_dimensions(self.tpl) + odd_last = make_pdf(self.p("odd_last.pdf"), + [(595, 842), (595, 842), (200, 200)]) + # page_anchor alone would accept ("first" matches) ... + self.assertTrue( + core.validate_against_template(dims, odd_last, page_anchor="first").ok) + # ... but anchors replaces it + r = core.validate_against_template(dims, odd_last, page_anchor="first", + anchors=("last",)) + self.assertFalse(r.ok) + self.assertIn("last page", r.reason) + # an EMPTY collection also replaces it: no anchored element at all + r = core.validate_against_template(dims, odd_last, page_anchor="first", anchors=()) + self.assertEqual((r.ok, r.reason), (False, "3 page(s), the template has 2")) + + def test_one_page_document_must_match_both_anchor_pages(self): + # its only page is the first AND the last one + one = make_pdf(self.p("one.pdf"), [(595, 842)]) + both = ("first", "last") + mixed = make_pdf(self.p("mixed.pdf"), [(595, 842), (300, 300)]) + r = core.validate_against_template(core.page_dimensions(mixed), one, anchors=both) + self.assertFalse(r.ok) + self.assertIn("last page", r.reason) + self.assertTrue(core.validate_against_template( + core.page_dimensions(mixed), one, anchors=("first",)).ok) + r = core.validate_against_template(core.page_dimensions(self.tpl), one, anchors=both) + self.assertTrue(r.ok, r.reason) + self.assertIn("first and last pages", r.reason) + class ImageInsertion(TmpCase): + @staticmethod + def insert(src, dst, png, **placement) -> bool: + """ONE image stamp through the image-mode path.""" + return core.apply_stamps_one( + src, dst, [Stamp(kind="image", image_path=png, **placement)]) + + @staticmethod + def size_pt(image, **kwargs) -> tuple[float, float]: + return stamplib.stamp_content(Stamp(kind="image", image_path=image, **kwargs))[1] + def test_insert_preserves_pages_and_writes_output(self): src = make_pdf(self.p("src.pdf"), [(400, 600), (400, 600)]) png = make_png(self.p("sig.png")) dst = self.p("out.pdf") - core.insert_image_one(src, dst, png, page_index=1, x=50, y=60) + self.insert(src, dst, png, page=2, x=50, y=60) self.assertTrue(dst.exists()) self.assertGreater(dst.stat().st_size, src.stat().st_size) # the page count must be unchanged @@ -140,15 +295,15 @@ def test_insert_preserves_pages_and_writes_output(self): def test_image_size_keeps_aspect(self): png = make_png(self.p("s.png"), size=(300, 150)) - w, h = core.image_size_pt(png) - self.assertEqual(w, core._IMG_TARGET_W_PT) - self.assertAlmostEqual(h, core._IMG_TARGET_W_PT * 0.5) + w, h = self.size_pt(png) + self.assertEqual(w, stamplib.DEFAULT_IMAGE_WIDTH_PT) + self.assertAlmostEqual(h, stamplib.DEFAULT_IMAGE_WIDTH_PT * 0.5) def test_page_out_of_range_clear_error(self): src = make_pdf(self.p("src.pdf"), [(400, 600), (400, 600)]) png = make_png(self.p("sig.png")) with self.assertRaises(ValueError) as cm: - core.insert_image_one(src, self.p("o.pdf"), png, page_index=5, x=10, y=10) + self.insert(src, self.p("o.pdf"), png, page=6, x=10, y=10) self.assertIn("out of range", str(cm.exception)) def test_inserted_image_has_no_black_border(self): @@ -158,7 +313,7 @@ def test_inserted_image_has_no_black_border(self): png = self.p("s.png") Image.new("RGB", (160, 80), (210, 210, 210)).save(png) dst = self.p("o.pdf") - core.insert_image_one(src, dst, png, page_index=0, x=60, y=160) + self.insert(src, dst, png, page=1, x=60, y=160) img = core.render_page_image(dst, 0, px_width=300) if img is None: self.skipTest("pdftoppm (poppler) unavailable") @@ -168,6 +323,32 @@ def test_inserted_image_has_no_black_border(self): if sum(px[xx, yy]) < 90) self.assertLess(black, 30, f"{black} (near-)black pixels -> border present") + def test_page_target_selects_the_stamped_page(self): + src = make_pdf(self.p("src.pdf"), [(400, 600)] * 3) + png = make_png(self.p("sig.png")) + for name, target, expected in (("last", {"page_anchor": "last"}, [0, 0, 1]), + ("first", {"page_anchor": "first"}, [1, 0, 0]), + ("2", {"page": 2}, [0, 1, 0])): + dst = self.p(f"out{name}.pdf") + self.assertIs(self.insert(src, dst, png, x=50.5, y=60, **target), False) + self.assertEqual(xobject_counts(dst), expected) + with self.assertRaises(ValueError) as cm: + self.insert(src, self.p("o.pdf"), png, page=4, x=10, y=10) + self.assertEqual(str(cm.exception), + "page 4 out of range (the document has 3 page(s))") + self.assertFalse(self.p("o.pdf").exists()) + + def test_image_size_follows_width_and_orientation(self): + png = make_png(self.p("s.png"), size=(300, 150)) + self.assertEqual(self.size_pt(png, width_pt=80), (80, 40.0)) + # EXIF orientation is applied first: the size is the one that gets stamped + jpg = self.p("rot.jpg") + exif = Image.Exif() + exif[0x0112] = 6 # rotate 90° to display + Image.new("RGB", (40, 20), (200, 0, 0)).save(jpg, exif=exif) + self.assertEqual(self.size_pt(jpg), (stamplib.DEFAULT_IMAGE_WIDTH_PT, + stamplib.DEFAULT_IMAGE_WIDTH_PT * 2)) + class BatchImageMode(TmpCase): """Shared CLI/GUI path: validation + image insertion end-to-end.""" @@ -214,6 +395,292 @@ def test_anchor_signs_count_mismatches_on_their_last_page(self): self.assertFalse((outdir / "long_bad_signe.pdf").exists()) +class EffectiveStamps(TmpCase): + """Pure helpers: the visual signatures of a run and what they require + from template validation.""" + + def _cfg(self, **kw): + kw.setdefault("inputs", [Path("x.pdf")]) + kw.setdefault("output", Path("out")) + return core.RunConfig(**kw) + + def test_legacy_image_becomes_one_stamp(self): + png = self.p("s.png") + cfg = self._cfg(mode="image", image_path=png, page=2, x=10, y=20) + self.assertEqual(core.effective_stamps(cfg), + [Stamp(kind="image", image_path=png, page=2, x=10, y=20)]) + # unspecified page / position: page 1, (0, 0) — as before + (s,) = core.effective_stamps(self._cfg(mode="image", image_path=png)) + self.assertEqual((s.page, s.page_anchor, s.all_pages, s.x, s.y), + (1, None, False, 0.0, 0.0)) + self.assertEqual(s.width_pt, stamplib.DEFAULT_IMAGE_WIDTH_PT) + # an anchor replaces the page number + (s,) = core.effective_stamps( + self._cfg(mode="image", image_path=png, page_anchor="last", x=5, y=6)) + self.assertEqual((s.page, s.page_anchor, s.x, s.y), (None, "last", 5, 6)) + self.assertTrue(s.is_placed) + + def test_legacy_image_then_cfg_stamps_in_order(self): + png = self.p("s.png") + extra = [text_stamp("A"), text_stamp("B", all_pages=True)] + cfg = self._cfg(mode="image", image_path=png, page=1, x=1, y=2, stamps=extra) + got = core.effective_stamps(cfg) + self.assertEqual([s.kind for s in got], ["image", "text", "text"]) + self.assertEqual(got[1:], extra) + self.assertEqual(cfg.stamps, extra) # cfg is not mutated + # without the legacy image: cfg.stamps alone (a copy, not the list itself) + cfg = self._cfg(mode="image", stamps=extra) + self.assertEqual(core.effective_stamps(cfg), extra) + self.assertIsNot(core.effective_stamps(cfg), cfg.stamps) + + def test_beid_ignores_image_path(self): + png = self.p("s.png") + for mode in ("beid", "azure"): + self.assertEqual( + core.effective_stamps(self._cfg(mode=mode, image_path=png, page=1, x=1, y=2)), + []) + extra = [text_stamp()] + self.assertEqual( + core.effective_stamps(self._cfg(mode=mode, image_path=png, stamps=extra)), + extra) + + def test_anchor_requirements_table(self): + png = self.p("s.png") + first = text_stamp(page_anchor="first") + last = text_stamp(page_anchor="last") + numbered = text_stamp(page=3) + every = text_stamp(all_pages=True) + rows = [ + ("all anchored", dict(mode="image", stamps=[last, first, last]), + (("first", "last"), None)), + ("legacy image anchored", dict(mode="image", image_path=png, page_anchor="last"), + (("last",), None)), + ("numbered stamp", dict(mode="image", stamps=[last, numbered]), + (("last",), "signature #2 targets page 3")), + ("legacy numbered image", dict(mode="image", image_path=png), + ((), "signature #1 targets page 1")), + ("all-pages stamp", dict(mode="image", stamps=[every, last]), + (("last",), "signature #1 targets every page")), + ("first blocker wins", dict(mode="image", stamps=[numbered, every]), + ((), "signature #1 targets page 3")), + ("no element", dict(mode="image"), ((), None)), + ("beid default vignette", dict(mode="beid"), + ((), "the vignette has no first/last page target")), + ("beid default vignette + anchored stamp", dict(mode="beid", stamps=[last]), + (("last",), "the vignette has no first/last page target")), + ("beid numbered vignette", dict(mode="beid", page=2, x=1, y=2), + ((), "the vignette targets page 2")), + ("beid placed vignette, default page", dict(mode="azure", x=1, y=2), + ((), "the vignette targets page 1")), + ("beid anchored default vignette", dict(mode="beid", page_anchor="first"), + (("first",), None)), + ("beid page_anchor + anchored stamps", + dict(mode="beid", page_anchor="last", x=1, y=2, stamps=[first, last]), + (("first", "last"), None)), + ("stamp blocker wins over the vignette", + dict(mode="azure", stamps=[every]), + ((), "signature #1 targets every page")), + ] + for label, kwargs, expected in rows: + with self.subTest(label): + self.assertEqual(core.anchor_requirements(self._cfg(**kwargs)), expected) + + +class BatchVisualStamps(TmpCase): + """Image mode = visual signatures only: text and image stamps through the + real process_batch (no card).""" + + def setUp(self): + super().setUp() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.out = self.p("out") + + def _cfg(self, inputs, stamp_list=(), **kw): + return core.RunConfig(inputs=list(inputs), output=self.out, mode="image", + stamps=list(stamp_list), **kw) + + def test_text_image_and_all_pages(self): + src = make_pdf(self.p("doc.pdf"), [(595, 842)] * 3) + png = make_png(self.p("sig.png")) + (res,) = core.process_batch(self._cfg([src], [ + text_stamp(page=1, x=360, y=120), + Stamp(kind="image", image_path=png, page_anchor="last", x=60, y=700), + text_stamp("JD", font="caveat", font_size=14, all_pages=True, x=540, y=20), + ])) + self.assertTrue(res.ok, res.detail) + self.assertEqual(res.output, self.out / "doc_signe.pdf") + self.assertEqual(xobject_counts(res.output), [2, 1, 2]) + self.assertEqual(xobject_counts(src), [0, 0, 0]) # the input is untouched + self.assertEqual(len(core.page_dimensions(res.output)), 3) + + def test_detail_wording(self): + src = make_pdf(self.p("doc.pdf"), [(595, 842)] * 2) + png = make_png(self.p("sig.png")) + (res,) = core.process_batch(core.RunConfig( + inputs=[src], output=self.out, mode="image", + image_path=png, page=1, x=360, y=150)) + self.assertEqual(res.detail, + "1 visual signature(s) applied — image page 1 @ (360, 150)") + (res,) = core.process_batch(self._cfg([src], [ + text_stamp(page=1, x=360, y=120), + text_stamp("JD", all_pages=True, x=540, y=20), + ])) + self.assertIn("2 visual signature(s) applied", res.detail) + self.assertEqual( + res.detail, + "2 visual signature(s) applied — text page 1 @ (360, 120); " + "text all pages @ (540, 20)") + + def test_count_mismatch_numbered_rejected_with_reason(self): + tpl = make_pdf(self.p("tpl.pdf"), [(595, 842), (595, 842)]) + short = make_pdf(self.p("short.pdf"), [(595, 842)]) + results = core.process_batch( + self._cfg([short, tpl], [text_stamp(page=1)], template=tpl)) + by_name = {r.path.name: r for r in results} + self.assertFalse(by_name["short.pdf"].ok) + self.assertEqual( + by_name["short.pdf"].detail, + "rejected — 1 page(s), the template has 2; signature #1 targets page 1 " + "(only first/last page targets accept a different page count)") + self.assertFalse((self.out / "short_signe.pdf").exists()) + self.assertTrue(by_name["tpl.pdf"].ok) # same page count: signed + + def test_count_mismatch_all_anchored_accepted(self): + tpl = make_pdf(self.p("tpl.pdf"), [(595, 842), (595, 842)]) + short = make_pdf(self.p("short.pdf"), [(595, 842)]) + long_bad = make_pdf(self.p("long_bad.pdf"), # first page differs + [(200, 200), (595, 842), (595, 842)]) + results = core.process_batch(self._cfg( + [short, long_bad], + [text_stamp(page_anchor="last"), text_stamp("JD", page_anchor="first", x=300)], + template=tpl)) + by_name = {r.path.name: r for r in results} + self.assertTrue(by_name["short.pdf"].ok, by_name["short.pdf"].detail) + self.assertIn("last page", by_name["short.pdf"].detail) + self.assertEqual(xobject_counts(self.out / "short_signe.pdf"), [2]) + self.assertFalse(by_name["long_bad.pdf"].ok) + self.assertIn("first page", by_name["long_bad.pdf"].detail) + self.assertFalse((self.out / "long_bad_signe.pdf").exists()) + + def test_all_pages_rejects_count_mismatch(self): + tpl = make_pdf(self.p("tpl.pdf"), [(595, 842), (595, 842)]) + longer = make_pdf(self.p("longer.pdf"), [(595, 842)] * 3) + (res,) = core.process_batch(self._cfg( + [longer], [text_stamp(page_anchor="last"), text_stamp(all_pages=True)], + template=tpl)) + self.assertFalse(res.ok) + self.assertIn("rejected", res.detail) + self.assertIn("signature #2 targets every page", res.detail) + self.assertFalse((self.out / "longer_signe.pdf").exists()) + # without a template there is nothing to reject: every page is stamped + (res,) = core.process_batch(self._cfg([longer], [text_stamp(all_pages=True)])) + self.assertTrue(res.ok, res.detail) + self.assertEqual(xobject_counts(res.output), [1, 1, 1]) + + def test_placeholders_resolved_per_document(self): + a = make_pdf(self.p("a.pdf"), [(595, 842)]) + b = make_pdf(self.p("b.pdf"), [(595, 842)]) + rendered = [] + real = stamplib.render_text_image + + def spy(text, *args, **kwargs): + rendered.append(text) + return real(text, *args, **kwargs) + + with mock.patch.object(stamplib, "render_text_image", spy): + results = core.process_batch( + self._cfg([a, b], [text_stamp("{filename} {date}"), + text_stamp("static", y=200)]), + today=datetime.date(2026, 10, 7)) + self.assertTrue(all(r.ok for r in results), [r.detail for r in results]) + self.assertEqual([t for t in rendered if t != "static"], + ["a 07/10/2026", "b 07/10/2026"]) + # static content is rendered ONCE for the whole batch + self.assertEqual(rendered.count("static"), 1) + # the run date is the injected one, whatever the day the suite runs + del rendered[:] + with mock.patch.object(stamplib, "render_text_image", spy): + core.process_batch(self._cfg([a], [text_stamp("{date}")]), + today=datetime.date(2001, 2, 3)) + self.assertEqual(rendered, ["03/02/2001"]) + + def test_unreadable_image_fails_document_not_batch(self): + a = make_pdf(self.p("a.pdf"), [(595, 842)]) + b = make_pdf(self.p("b.pdf"), [(595, 842)]) + fake = self.p("fake.png") + fake.write_text("not an image", encoding="utf-8") + seen = [] + # a readable stamp FIRST: nothing of it may reach an output either + results = core.process_batch( + self._cfg([a, b], [text_stamp(), + Stamp(kind="image", image_path=fake, page=1, x=10, y=10)]), + on_progress=seen.append) + self.assertEqual(len(results), 2) # the batch went on + self.assertEqual(seen, results) + for res in results: + self.assertFalse(res.ok) + self.assertIsNone(res.output) + self.assertTrue(res.detail.startswith("failed — cannot read image"), res.detail) + self.assertEqual(list(self.out.glob("*.pdf")), []) # no partial output + + def test_page_out_of_range_detail(self): + one = make_pdf(self.p("one.pdf"), [(595, 842)]) + three = make_pdf(self.p("three.pdf"), [(595, 842)] * 3) + results = core.process_batch(self._cfg([one, three], [text_stamp(page=3)])) + self.assertFalse(results[0].ok) + self.assertEqual(results[0].detail, + "failed — page 3 out of range (the document has 1 page(s))") + self.assertFalse((self.out / "one_signe.pdf").exists()) + self.assertTrue(results[1].ok, results[1].detail) # the next one is processed + self.assertEqual(xobject_counts(results[1].output), [0, 0, 1]) + + def test_never_overwrites(self): + src = make_pdf(self.p("doc.pdf"), [(595, 842)]) + cfg = self._cfg([src], [text_stamp()]) + (first,) = core.process_batch(cfg) + kept = first.output.read_bytes() + (second,) = core.process_batch(cfg) + self.assertEqual(first.output.name, "doc_signe.pdf") + self.assertEqual(second.output.name, "doc_signe - 1.pdf") + self.assertEqual(first.output.read_bytes(), kept) # intact + self.assertTrue(second.output.exists()) + + def test_image_mode_without_stamp_fails_document(self): + # programmatic misuse (validate_config was skipped): never a silent no-op + a = make_pdf(self.p("a.pdf"), [(595, 842)]) + b = make_pdf(self.p("b.pdf"), [(595, 842)]) + results = core.process_batch( + core.RunConfig(inputs=[a, b], output=self.out, mode="image")) + self.assertEqual([(r.ok, r.output, r.detail) for r in results], + [(False, None, "failed — no visual signature to apply")] * 2) + self.assertEqual(list(self.out.glob("*.pdf")), []) + + def test_blank_page_is_stamped(self): + from test_stamps import strip_page_contents # lazy: circular at import time + src = strip_page_contents(make_pdf(self.p("doc.pdf"), [(595, 842)] * 2), 1) + (res,) = core.process_batch(self._cfg([src], [text_stamp(all_pages=True)])) + self.assertTrue(res.ok, res.detail) + self.assertEqual(xobject_counts(res.output), [1, 1]) + + def test_malformed_form_is_still_stamped(self): + # /AcroForm that is not a dictionary: the "already signed?" probe cannot + # walk it. Image mode stamped such a file before and still does. + from pyhanko.pdf_utils import generic + from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter + plain = make_pdf(self.p("plain.pdf"), [(595, 842)]) + src = self.p("odd_form.pdf") + with plain.open("rb") as f: + writer = IncrementalPdfFileWriter(f, strict=False) + writer.root[generic.pdf_name("/AcroForm")] = generic.ArrayObject([]) + writer.update_root() + with src.open("wb") as out: + writer.write(out) + (res,) = core.process_batch(self._cfg([src], [text_stamp()])) + self.assertTrue(res.ok, res.detail) + self.assertNotIn("WARNING", res.detail) + self.assertEqual(xobject_counts(res.output), [1]) + + class BatchBeidWiring(TmpCase): """beid vignette placement wiring (no card: sign_one is mocked).""" @@ -275,6 +742,70 @@ def test_anchor_without_position_moves_default_vignette(self): self.assertIsNone(kw.get("pos")) self.assertIn("first page", results[0].detail) + def test_count_mismatch_needs_every_element_on_the_anchor(self): + # a file whose page count differs from the template is signed only + # when the vignette AND every visual signature target first/last + tpl = make_pdf(self.p("t.pdf"), [(595, 842), (595, 842)]) + short = make_pdf(self.p("short.pdf"), [(595, 842)]) + base = dict(inputs=[short], output=self.p("o"), mode="beid", template=tpl, + stamps=[text_stamp(page_anchor="last")]) + calls, results = self._run(core.RunConfig(page_anchor="last", **base)) + self.assertTrue(results[0].ok, results[0].detail) + ((_, kw),) = calls + self.assertEqual(kw.get("page_index"), -1) # the vignette on ITS last page + self.assertEqual(kw["stamps"], base["stamps"]) + calls, results = self._run(core.RunConfig(**base)) # default vignette: not anchored + self.assertFalse(results[0].ok) + self.assertIn("the vignette has no first/last page target", results[0].detail) + self.assertEqual(calls, []) # never signed + + def test_stamps_forwarded_to_sign_one(self): + srcs = [make_pdf(self.p(f"d{i}.pdf"), [(595, 842)]) for i in range(2)] + extra = [text_stamp("{filename}"), text_stamp("JD", all_pages=True)] + cfg = core.RunConfig(inputs=srcs, output=self.p("o"), mode="beid", stamps=extra) + calls, results = self._run(cfg) + self.assertTrue(all(r.ok for r in results)) + self.assertEqual(len(calls), 2) + for _, kw in calls: + self.assertEqual(kw["stamps"], core.effective_stamps(cfg)) + self.assertEqual(kw["stamps"], extra) + self.assertIsInstance(kw["stamp_date"], datetime.date) + self.assertIsInstance(kw["stamp_cache"], dict) + self.assertIsNone(kw.get("pos")) # the vignette is untouched + # ONE cache and ONE date for the whole batch + self.assertIs(calls[0][1]["stamp_cache"], calls[1][1]["stamp_cache"]) + self.assertEqual(calls[0][1]["stamp_date"], calls[1][1]["stamp_date"]) + + def test_detail_mentions_visual_signatures(self): + src = make_pdf(self.p("d.pdf"), [(595, 842), (595, 842)]) + extra = [text_stamp(), text_stamp("JD", all_pages=True)] + cfg = core.RunConfig(inputs=[src], output=self.p("o"), mode="beid", + page=2, x=100, y=50, stamps=extra) + _, results = self._run(cfg) + self.assertIn("+ 2 visual signature(s)", results[0].detail) + self.assertEqual( + results[0].detail, + "signed (eID) — vignette page 2 @ (100, 50) + 2 visual signature(s) " + "— PAdES-B-LTA, LTV ok") + # default vignette: the count follows the vignette phrase as well + cfg = core.RunConfig(inputs=[src], output=self.p("o"), mode="beid", + stamps=extra[:1]) + _, results = self._run(cfg) + self.assertEqual( + results[0].detail, + "signed (eID) — vignette + 1 visual signature(s) — PAdES-B-LTA, LTV ok") + + def test_no_stamps_detail_unchanged(self): + src = make_pdf(self.p("d.pdf"), [(595, 842)]) + png = make_png(self.p("s.png")) + # image_path is ignored in beid mode (as before): no stamp at all + cfg = core.RunConfig(inputs=[src], output=self.p("o"), mode="beid", + image_path=png) + calls, results = self._run(cfg) + self.assertEqual(results[0].detail, + "signed (eID) — vignette — PAdES-B-LTA, LTV ok") + self.assertEqual(calls[0][1]["stamps"], []) + class OutputNaming(TmpCase): def test_unique_output_path_increments(self): @@ -321,6 +852,25 @@ def test_anchor_page_index(self): self.assertEqual(core.anchor_page_index("first"), 0) self.assertEqual(core.anchor_page_index("last"), -1) + def test_default_vignette_rect(self): + # pure geometry behind the default box, relative to the page corner + self.assertEqual( + core.default_vignette_rect(595, 842), + (595 - core._STAMP_MARGIN - core._STAMP_W, core._STAMP_MARGIN, + core._STAMP_W, core._STAMP_H)) + # a page too small for the box: clamped inside it, 4 pt from the edges + x, y, w, h = core.default_vignette_rect(100, 30) + self.assertEqual((x, y), (4, core._STAMP_MARGIN)) + self.assertEqual((x + w, y + h), (100 - core._STAMP_MARGIN, 30 - 4)) + + def test_box_follows_mediabox_origin(self): + # non-zero MediaBox origin: the page-relative rect, shifted by the origin + with mock.patch.object(core, "page_mediabox", + lambda writer, page_index: [100.0, 200.0, 695.0, 1042.0]): + box = core._default_vignette_box(None, -1) + x, y, w, h = core.default_vignette_rect(595, 842) + self.assertEqual(box, (100 + x, 200 + y, 100 + x + w, 200 + y + h)) + class PageRender(TmpCase): def test_render_page_image(self): @@ -421,6 +971,78 @@ def test_page_anchor_config_rules(self): core.validate_config(core.RunConfig(inputs=[self.a], output=self.tmp, page_anchor="middle")) + def test_image_mode_requires_a_signature(self): + with self.assertRaises(ValueError) as cm: + core.validate_config(core.RunConfig(inputs=[self.a], output=self.tmp, + mode="image")) + self.assertIn("--signatures", str(cm.exception)) + self.assertIn("--text", str(cm.exception)) + # the historical checks of the legacy image keep their messages + with self.assertRaises(ValueError) as cm: + core.validate_config(core.RunConfig( + inputs=[self.a], output=self.tmp, mode="image", + image_path=self.p("missing.png"))) + self.assertTrue(str(cm.exception).startswith("Image not found:")) + with self.assertRaises(ValueError) as cm: + core.validate_config(core.RunConfig( + inputs=[self.a], output=self.tmp, mode="image", + image_path=make_png(self.p("s.png")), page=0)) + self.assertEqual(str(cm.exception), "--page must be >= 1.") + + def test_bad_stamp_reported_with_index(self): + good = text_stamp() + for bad, fragment in ( + (text_stamp(" "), "text is empty"), + (text_stamp(color="blue"), "bad color"), + (text_stamp(font="no-such-font"), "unknown font"), + (Stamp(kind="text", text="x", page=1), "x and y are required"), + (Stamp(kind="image", image_path=self.p("nope.png"), page=1, x=0, y=0), + "image not found"), + ): + with self.subTest(fragment): + with self.assertRaises(ValueError) as cm: + core.validate_config(core.RunConfig( + inputs=[self.a], output=self.tmp, mode="image", stamps=[bad])) + self.assertTrue(str(cm.exception).startswith("Signature #1: "), + cm.exception) + self.assertIn(fragment, str(cm.exception)) + # the index is the position in effective_stamps (1-based) + with self.assertRaises(ValueError) as cm: + core.validate_config(core.RunConfig( + inputs=[self.a], output=self.tmp, mode="image", + stamps=[good, text_stamp("")])) + self.assertEqual(str(cm.exception), "Signature #2: text is empty.") + with self.assertRaises(ValueError) as cm: # legacy image = #1, cfg.stamps follow + core.validate_config(core.RunConfig( + inputs=[self.a], output=self.tmp, mode="image", + image_path=make_png(self.p("s.png")), stamps=[text_stamp("")])) + self.assertEqual(str(cm.exception), "Signature #2: text is empty.") + + def test_oversized_text_rejected_by_validate_config(self): + huge = text_stamp("W" * 300, font_size=400) + with self.assertRaises(ValueError) as cm: + core.validate_config(core.RunConfig( + inputs=[self.a], output=self.tmp, mode="image", stamps=[huge])) + self.assertTrue( + str(cm.exception).startswith("Signature #1: text is too large to render"), + cm.exception) + + def test_beid_accepts_stamps(self): + for mode, extra in (("beid", {}), ("azure", {"azure_vault_url": "https://v.example"})): + # stamps are optional in the cryptographic modes ... + core.validate_config(core.RunConfig( + inputs=[self.a], output=self.tmp, mode=mode, pades_level="b-b", **extra)) + # ... accepted when valid ... + core.validate_config(core.RunConfig( + inputs=[self.a], output=self.tmp, mode=mode, pades_level="b-b", + stamps=[text_stamp(), text_stamp(all_pages=True)], **extra)) + # ... and validated like anywhere else + with self.assertRaises(ValueError) as cm: + core.validate_config(core.RunConfig( + inputs=[self.a], output=self.tmp, mode=mode, pades_level="b-b", + stamps=[text_stamp(x=None)], **extra)) + self.assertEqual(str(cm.exception), "Signature #1: x and y are required.") + class LevelMapping(unittest.TestCase): """Pure mapping pades_level -> PdfSignatureMetadata kwargs (R3-R6).""" @@ -567,6 +1189,216 @@ def test_tsa_url_precedence_flag_env_default(self): self.assertEqual(core.resolve_tsa_url(None), core.DEFAULT_TSA_URL) +class SignaturesCli(TmpCase): + """CLI flags of the visual signatures (--text…, --signatures) through the + real arg parser, and who owns --page/--x/--y.""" + + def setUp(self): + super().setUp() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.a = make_pdf(self.p("a.pdf"), [(595, 842)]) + self.png = make_png(self.p("sig.png")) + self.entries = [ + {"kind": "text", "text": "Jane Doe\nRead and approved, {date}", + "font": "great-vibes", "color": "#1A2B8C", "font_size": 24, + "page_anchor": "last", "x": 360, "y": 120}, + {"kind": "image", "image_path": "sig.png", "width_pt": 120, + "page": 1, "x": 60, "y": 700}, + ] + self.sigs = self.p("sigs.json") + self.sigs.write_text(json.dumps(self.entries), encoding="utf-8") + + def parse(self, *extra): + argv = ["--input", str(self.a), "--output", str(self.p("out")), *extra] + return core.resolve_config(core.build_arg_parser().parse_args(argv)) + + def assert_rejected(self, *extra) -> str: + with self.assertRaises(ValueError) as cm: + self.parse(*extra) + return str(cm.exception) + + def test_text_flags_build_one_text_stamp(self): + cfg = self.parse("--mode", "image", "--text", "Jane Doe", "--font", "caveat", + "--color", "#FF0000", "--font-size", "30", + "--page", "2", "--x", "10", "--y", "20.5") + self.assertEqual(cfg.stamps, [Stamp( + kind="text", text="Jane Doe", font="caveat", color="#FF0000", + font_size=30.0, page=2, x=10.0, y=20.5)]) + self.assertEqual(core.effective_stamps(cfg), cfg.stamps) + self.assertIsNone(cfg.image_path) + + def test_explicit_zero_font_size_rejected(self): + # "is not None", not "or": 0 must not silently become the default size + self.assertIn("font_size must be greater than 0", self.assert_rejected( + "--mode", "image", "--text", "JD", "--font-size", "0")) + + def test_text_defaults(self): + (s,) = self.parse("--mode", "image", "--text", "Jane Doe").stamps + self.assertEqual(s, Stamp( + kind="text", text="Jane Doe", font=stamplib.DEFAULT_FONT, + color=stamplib.DEFAULT_COLOR, font_size=24.0, page=1, x=0.0, y=0.0)) + + def test_text_newline_escape(self): + # the two characters backslash + n, as typed in any shell + (s,) = self.parse("--mode", "image", "--text", r"Jane Doe\nRead and approved").stamps + self.assertEqual(s.text, "Jane Doe\nRead and approved") + (s,) = self.parse("--mode", "image", "--text", "{date} {x} {}").stamps + self.assertEqual(s.text, "{date} {x} {}") # placeholders: not at parse time + + def test_text_with_page_last(self): + cfg = self.parse("--mode", "image", "--text", "JD", "--page", "last", + "--x", "5", "--y", "6") + (s,) = cfg.stamps + self.assertEqual((s.page, s.page_anchor, s.x, s.y), (None, "last", 5.0, 6.0)) + self.assertEqual((cfg.page, cfg.page_anchor), (None, "last")) + self.assertEqual(core.anchor_requirements(cfg), (("last",), None)) + + def test_font_without_text_rejected(self): + for flags in (("--font", "caveat"), ("--color", "#FF0000"), ("--font-size", "12")): + for mode in (("--mode", "image", "--image-path", str(self.png)), + ("--mode", "image", "--signatures", str(self.sigs)), + ("--mode", "beid", "--pades-level", "b-b")): + with self.subTest(flags=flags, mode=mode): + self.assertEqual(self.assert_rejected(*mode, *flags), + "--font, --color and --font-size need --text.") + + def test_text_with_image_path_rejected(self): + msg = self.assert_rejected("--mode", "image", "--text", "JD", + "--image-path", str(self.png)) + self.assertIn("mutually exclusive", msg) + self.assertIn("--signatures", msg) + + def test_text_in_beid_rejected(self): + for mode in (("--mode", "beid"), + ("--mode", "azure", "--azure-vault-url", "https://v.example")): + with self.subTest(mode=mode): + msg = self.assert_rejected(*mode, "--pades-level", "b-b", "--text", "JD") + self.assertIn("--text only applies to --mode image", msg) + self.assertIn("--signatures", msg) + + def test_signatures_file_loaded(self): + cfg = self.parse("--mode", "image", "--signatures", str(self.sigs)) + self.assertEqual(cfg.stamps, [ + Stamp(kind="text", text="Jane Doe\nRead and approved, {date}", + font="great-vibes", color="#1A2B8C", font_size=24.0, + page_anchor="last", x=360.0, y=120.0), + # relative image_path: against the directory of the JSON file + Stamp(kind="image", image_path=self.sigs.resolve().parent / "sig.png", + width_pt=120.0, page=1, x=60.0, y=700.0), + ]) + self.assertIsNone(cfg.image_path) + self.assertEqual((cfg.page, cfg.page_anchor, cfg.x, cfg.y), (None, None, None, None)) + # an empty list is not "a signature": image mode still needs one + empty = self.p("empty.json") + empty.write_text("[]", encoding="utf-8") + self.assertIn("at least one visual signature", + self.assert_rejected("--mode", "image", "--signatures", str(empty))) + + def test_signatures_plus_text_convenience_first(self): + cfg = self.parse("--mode", "image", "--signatures", str(self.sigs), + "--text", "JD", "--page", "2", "--x", "1", "--y", "2") + self.assertEqual([s.kind for s in cfg.stamps], ["text", "text", "image"]) + self.assertEqual((cfg.stamps[0].text, cfg.stamps[0].page, cfg.stamps[0].x, + cfg.stamps[0].y), ("JD", 2, 1.0, 2.0)) + # --page/--x/--y went to the convenience stamp only + self.assertEqual(cfg.stamps[1].page_anchor, "last") + self.assertEqual((cfg.stamps[2].page, cfg.stamps[2].x), (1, 60.0)) + + def test_signatures_plus_image_path_additive(self): + cfg = self.parse("--mode", "image", "--signatures", str(self.sigs), + "--image-path", str(self.png), "--page", "last", + "--x", "7", "--y", "8") + self.assertEqual(len(cfg.stamps), 2) # the file's entries + got = core.effective_stamps(cfg) + self.assertEqual([s.kind for s in got], ["image", "text", "image"]) + self.assertEqual((got[0].image_path, got[0].page_anchor, got[0].x, got[0].y, + got[0].width_pt), (self.png, "last", 7.0, 8.0, 150.0)) + self.assertEqual(got[1:], cfg.stamps) + + def test_page_xy_without_convenience_stamp_rejected(self): + for flags in (("--page", "2"), ("--page", "last"), ("--x", "5"), ("--y", "0"), + ("--page", "1", "--x", "5", "--y", "6")): + with self.subTest(flags=flags): + msg = self.assert_rejected("--mode", "image", "--signatures", + str(self.sigs), *flags) + self.assertIn("--page/--x/--y need --text or --image-path", msg) + # and with no signature at all the missing signature is what is reported + self.assertIn("at least one visual signature", + self.assert_rejected("--mode", "image", "--page", "2")) + + def test_legacy_image_invocation_fields_unchanged(self): + cfg = self.parse("--mode", "image", "--image-path", str(self.png), + "--page", "1", "--x", "360", "--y", "150") + self.assertEqual((cfg.mode, cfg.image_path, cfg.page, cfg.page_anchor, cfg.x, cfg.y), + ("image", self.png, 1, None, 360.0, 150.0)) + self.assertEqual(cfg.stamps, []) + self.assertEqual(core.effective_stamps(cfg), [Stamp( + kind="image", image_path=self.png, page=1, x=360.0, y=150.0)]) + self.assertEqual(core.describe_stamps(cfg), + ["image page 1 @ (360, 150) — sig.png"]) + + def test_beid_page_xy_stay_vignette_with_signatures(self): + for mode in (("--mode", "beid"), + ("--mode", "azure", "--azure-vault-url", "https://v.example")): + with self.subTest(mode=mode): + cfg = self.parse(*mode, "--pades-level", "b-b", "--signatures", + str(self.sigs), "--page", "2", "--x", "100", "--y", "50") + self.assertEqual((cfg.page, cfg.page_anchor, cfg.x, cfg.y), + (2, None, 100.0, 50.0)) + self.assertEqual(core.describe_placement(cfg), + "vignette page 2 @ (100, 50)") + # the file's entries, with their OWN placement + self.assertEqual(core.effective_stamps(cfg), cfg.stamps) + self.assertEqual([(s.kind, s.target_label()) for s in cfg.stamps], + [("text", "last page"), ("image", "page 1")]) + # --image-path stays ignored there (as before) + cfg = self.parse(*mode, "--pades-level", "b-b", + "--image-path", str(self.png)) + self.assertEqual(core.effective_stamps(cfg), []) + + def test_bad_signatures_file_is_value_error(self): + bad_json = self.p("bad.json") + bad_json.write_text("[{", encoding="utf-8") + not_a_list = self.p("obj.json") + not_a_list.write_text('{"kind": "text"}', encoding="utf-8") + bad_entry = self.p("entry.json") + bad_entry.write_text(json.dumps([self.entries[0], {"kind": "text", "text": ""}]), + encoding="utf-8") + for path, fragment in ((bad_json, "invalid JSON"), + (not_a_list, "expected a JSON list"), + (self.p("missing.json"), "cannot read signatures file"), + (bad_entry, "entry.json: signature #2: text is empty")): + for mode in (("--mode", "image"), ("--mode", "beid", "--pades-level", "b-b")): + with self.subTest(path=path.name, mode=mode): + with self.assertRaises(ValueError) as cm: # what main() turns into exit 2 + self.parse(*mode, "--signatures", str(path)) + self.assertIsInstance(cm.exception, StampError) + self.assertIn(fragment, str(cm.exception)) + + def test_profile_is_never_read(self): + # A GUI profile full of signatures in the user folder: a headless run + # must not pick any of it up (deterministic CLI). + import profile_store + cfg_dir, data_dir = self.p("cfg"), self.p("data") + store = profile_store.ProfileStore(config_dir=cfg_dir, data_dir=data_dir) + store.save(profile_store.Profile(signatures=[profile_store.Signature( + id=profile_store.new_id(), label="Jane", stamp=text_stamp(all_pages=True), + placed_on=(595.0, 842.0))])) + self.assertEqual(len(store.load().signatures), 1) # the profile is real + before = store.profile_path.read_bytes() + env = {profile_store.ENV_CONFIG_DIR: str(cfg_dir), + profile_store.ENV_DATA_DIR: str(data_dir)} + with mock.patch.dict(os.environ, env): + beid = self.parse("--pades-level", "b-b") + image = self.parse("--mode", "image", "--image-path", str(self.png)) + self.assertEqual(beid.stamps, []) + self.assertEqual(core.effective_stamps(beid), []) + self.assertEqual(image.stamps, []) + self.assertIn("at least one visual signature", + self.assert_rejected("--mode", "image")) + self.assertEqual(store.profile_path.read_bytes(), before) + + class SigningMaterialWiring(unittest.TestCase): """build_signing_material: timestamper/context per level (offline-safe).""" @@ -766,6 +1598,246 @@ def test_legacy_subfilter_rejected_as_non_pades(self): with self.assertRaises(core.SelfVerificationError): self._verify(dst, "b-b") + # --- visual signatures + cryptographic signature ------------------------- + # Real core.sign_one with the in-memory signer of this class: only the + # PKCS#11 hardware is replaced, the stamping + signing + validation path + # is the production one. + + def _sign_one(self, src, dst, *, field="Signature1", level="b-b", stamps=()) -> Path: + networked = level != "b-b" + core.sign_one(self.signer, src, dst, field, level, + core.CardIdentity("Jane Doe", None), + timestamper=self.timestamper if networked else None, + validation_context=self.signing_vc if networked else None, + stamps=stamps) + return dst + + def _statuses(self, pdf) -> list: + """pyHanko validation status of EVERY signature of `pdf`, in order + (verify_signed_pdf only looks at the last one).""" + from pyhanko.pdf_utils.reader import PdfFileReader + from pyhanko.sign.validation import validate_pdf_signature + from pyhanko_certvalidator import ValidationContext + + with Path(pdf).open("rb") as f: + reader = PdfFileReader(f, strict=False) + return [validate_pdf_signature( + emb, ValidationContext(trust_roots=[self.root])) + for emb in reader.embedded_regular_signatures] + + def assert_covers_whole_file(self, status): + from pyhanko.sign.validation.status import ( + ModificationLevel, SignatureCoverageLevel) + self.assertTrue(status.bottom_line, status.summary()) + self.assertEqual(status.coverage, SignatureCoverageLevel.ENTIRE_FILE) + self.assertEqual(status.modification_level, ModificationLevel.NONE) + + def _batch_beid(self, cfg): + """process_batch in beid mode with the card replaced by this class's + signer: ONLY the session, the signer factory and the identity read are + mocked — sign_one, the stamps and the verification are real.""" + with mock.patch.object(core, "open_eid_session", lambda *a, **k: object()), \ + mock.patch.object(core, "BEIDSigner", lambda session: self.signer), \ + mock.patch.object(core, "read_card_identity", + lambda session: core.CardIdentity("Jane Doe", None)): + return core.process_batch(cfg) + + def test_stamps_then_signature_validates_unmodified(self): + from test_stamps import pdf_pages # lazy: circular at import time + src = make_pdf(self.p("doc.pdf"), [(595, 842)] * 3) + png = make_png(self.p("sig.png")) + dst = self._sign_one(src, self.p("doc_signed.pdf"), stamps=[ + text_stamp("JD {date}", all_pages=True, x=540, y=20), + Stamp(kind="image", image_path=png, page=1, x=60, y=700), + ]) + self.assertEqual(core.verify_signed_pdf(dst, "b-b", trust_anchors=[self.root]), + "PAdES-B-B") + (status,) = self._statuses(dst) + self.assert_covers_whole_file(status) # the signature covers the stamps + self.assertEqual(xobject_counts(dst), [2, 1, 1]) + pages = pdf_pages(dst) + self.assertEqual(len(pages[-1]["/Annots"]), 1) # the vignette, last page only + self.assertNotIn("/Annots", pages[0]) + self.assertNotIn("/Annots", pages[1]) + + def test_stamps_then_b_lta(self): + src = make_pdf(self.p("doc.pdf"), [(595, 842)] * 2) + dst = self._sign_one(src, self.p("doc_signed.pdf"), level="b-lta", stamps=[ + text_stamp(all_pages=True), text_stamp("JD", page_anchor="last", y=200)]) + self.assertEqual(self._verify(dst, "b-lta"), "PAdES-B-LTA, LTV ok") + self.assertEqual(xobject_counts(dst), [1, 2]) + + def test_stamp_after_signature_is_flagged(self): + # Negative control: a stamp added AFTER the signature is an + # illegal modification — which is why sign_one stamps first. + signed = self._sign_one(make_pdf(self.p("doc.pdf"), [(595, 842)]), + self.p("signed.pdf")) + self.assertEqual(core.verify_signed_pdf(signed, "b-b", trust_anchors=[self.root]), + "PAdES-B-B") + out = self.p("restamped.pdf") + self.assertIs(core.apply_stamps_one(signed, out, [text_stamp()]), True) + self.assertTrue(out.exists()) + # (pyHanko logs the offending objects of its diff analysis: captured here) + with self.assertLogs("pyhanko", level="WARNING"), \ + self.assertRaises(core.SelfVerificationError): + core.verify_signed_pdf(out, "b-b", trust_anchors=[self.root]) + # an unsigned input is not reported as signed + plain = make_pdf(self.p("plain.pdf"), [(595, 842)]) + self.assertIs(core.apply_stamps_one(plain, self.p("plain_out.pdf"), [text_stamp()]), + False) + + def test_existing_signature_count(self): + from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter + + def count(pdf) -> int: + with Path(pdf).open("rb") as f: + return core.existing_signature_count( + IncrementalPdfFileWriter(f, strict=False)) + + plain = make_pdf(self.p("plain.pdf"), [(595, 842)]) + self.assertEqual(count(plain), 0) + self.assertEqual(count(self._sign_one(plain, self.p("bb.pdf"))), 1) + # b-lta = the signature + its archival document timestamp + self.assertEqual(count(self._sign_one(plain, self.p("lta.pdf"), level="b-lta")), 2) + + def test_stamps_on_already_signed_input_refused(self): + first = self._sign_one(make_pdf(self.p("doc.pdf"), [(595, 842)]), + self.p("first.pdf"), field="Other1") + dst = self.p("second.pdf") + signer = mock.Mock() # must never be asked to sign + with self.assertRaises(StampError) as cm: + core.sign_one(signer, first, dst, "Signature1", "b-b", + core.CardIdentity("Jane Doe", None), stamps=[text_stamp()]) + self.assertIn("already signed", str(cm.exception)) + self.assertEqual(signer.mock_calls, []) + self.assertFalse(dst.exists()) + # the same holds for an input that only carries a document timestamp chain + lta = self._sign_one(make_pdf(self.p("doc2.pdf"), [(595, 842)]), + self.p("lta.pdf"), field="Other1", level="b-lta") + with self.assertRaises(StampError): + self._sign_one(lta, dst, stamps=[text_stamp()]) + self.assertFalse(dst.exists()) + (status,) = self._statuses(first) # the input is intact + self.assert_covers_whole_file(status) + + def test_countersign_without_stamps_keeps_first_signature_valid(self): + from pyhanko.sign.validation.status import ModificationLevel + first = self._sign_one(make_pdf(self.p("doc.pdf"), [(595, 842)]), + self.p("first.pdf"), field="Other1") + both = self._sign_one(first, self.p("both.pdf"), stamps=()) + earlier, ours = self._statuses(both) + self.assertTrue(earlier.bottom_line, earlier.summary()) + self.assertEqual(earlier.modification_level, ModificationLevel.FORM_FILLING) + self.assert_covers_whole_file(ours) + + def test_image_mode_on_signed_input_warns(self): + signed = self._sign_one(make_pdf(self.p("doc.pdf"), [(595, 842)]), + self.p("signed.pdf")) + plain = make_pdf(self.p("plain.pdf"), [(595, 842)]) + out = self.p("out") + results = core.process_batch(core.RunConfig( + inputs=[signed, plain], output=out, mode="image", stamps=[text_stamp()])) + by_name = {r.path.name: r for r in results} + # historical outcome kept: the output is written, the document is "ok" + self.assertTrue(by_name["signed.pdf"].ok) + self.assertTrue((out / "signed_signe.pdf").exists()) + self.assertIn("WARNING: the document was already signed", + by_name["signed.pdf"].detail) + self.assertTrue(by_name["signed.pdf"].detail.startswith( + "1 visual signature(s) applied — text page 1 @ (50, 50) — WARNING")) + self.assertTrue(by_name["plain.pdf"].ok) + self.assertNotIn("WARNING", by_name["plain.pdf"].detail) + + def test_batch_beid_refuses_stamps_on_signed_input(self): + signed = self._sign_one(make_pdf(self.p("doc.pdf"), [(595, 842)]), + self.p("signed.pdf"), field="Other1") + plain = make_pdf(self.p("plain.pdf"), [(595, 842)]) + out = self.p("out") + results = self._batch_beid(core.RunConfig( + inputs=[signed, plain], output=out, mode="beid", pades_level="b-b", + stamps=[text_stamp()])) + by_name = {r.path.name: r for r in results} + refused = by_name["signed.pdf"] + self.assertFalse(refused.ok) + self.assertIsNone(refused.output) + self.assertTrue( + refused.detail.startswith("failed — the document is already signed"), + refused.detail) + self.assertFalse((out / "signed_signe.pdf").exists()) + # the batch went on: the unsigned input is stamped and signed + self.assertTrue(by_name["plain.pdf"].ok, by_name["plain.pdf"].detail) + (status,) = self._statuses(out / "plain_signe.pdf") + self.assert_covers_whole_file(status) + # without stamps the very same input is countersigned, as before + (res,) = self._batch_beid(core.RunConfig( + inputs=[signed], output=out, mode="beid", pades_level="b-b")) + self.assertTrue(res.ok, res.detail) + self.assertEqual(len(self._statuses(res.output)), 2) + + def test_blank_page_stamped_then_signed(self): + from test_stamps import strip_page_contents # lazy: circular at import time + src = strip_page_contents(make_pdf(self.p("doc.pdf"), [(595, 842)] * 2), 1) + dst = self._sign_one(src, self.p("doc_signed.pdf"), + stamps=[text_stamp(all_pages=True)]) + (status,) = self._statuses(dst) + self.assert_covers_whole_file(status) + self.assertEqual(xobject_counts(dst), [1, 1]) + + def test_process_batch_beid_with_stamps_real_signature(self): + src = make_pdf(self.p("doc.pdf"), [(595, 842)] * 2) + png = make_png(self.p("sig.png")) + out = self.p("out") + (res,) = self._batch_beid(core.RunConfig( + inputs=[src], output=out, mode="beid", pades_level="b-b", + page=2, x=100, y=50, + stamps=[text_stamp("{filename}", all_pages=True, x=540, y=20), + Stamp(kind="image", image_path=png, page_anchor="first", x=60, y=700)])) + self.assertTrue(res.ok, res.detail) + self.assertEqual( + res.detail, + "signed (eID) — vignette page 2 @ (100, 50) + 2 visual signature(s) — PAdES-B-B") + self.assertEqual(res.output, out / "doc_signe.pdf") + self.assertEqual(core.verify_signed_pdf(res.output, "b-b", trust_anchors=[self.root]), + "PAdES-B-B") + (status,) = self._statuses(res.output) + self.assert_covers_whole_file(status) + self.assertEqual(xobject_counts(res.output), [2, 1]) + + def test_failing_stamp_leaves_no_signed_output(self): + one = make_pdf(self.p("one.pdf"), [(595, 842)]) + out = self.p("out") + signer = mock.Mock() # must never be asked to sign + with mock.patch.object(core, "open_eid_session", lambda *a, **k: object()), \ + mock.patch.object(core, "BEIDSigner", lambda session: signer), \ + mock.patch.object(core, "read_card_identity", + lambda session: core.CardIdentity("Jane Doe", None)): + (res,) = core.process_batch(core.RunConfig( + inputs=[one], output=out, mode="beid", pades_level="b-b", + stamps=[text_stamp(), text_stamp(page=9)])) + self.assertFalse(res.ok) + self.assertIsNone(res.output) + self.assertEqual(res.detail, + "failed — page 9 out of range (the document has 1 page(s))") + self.assertEqual(signer.mock_calls, []) + self.assertEqual(list(out.glob("*")), []) # no _signe.pdf, nothing partial + + def test_sign_one_resolves_placeholders(self): + # {filename} is the source STEM, {date} the run date handed to sign_one + src = make_pdf(self.p("contract.pdf"), [(595, 842)]) + rendered = [] + real = stamplib.render_text_image + + def spy(text, *args, **kwargs): + rendered.append(text) + return real(text, *args, **kwargs) + + with mock.patch.object(stamplib, "render_text_image", spy): + core.sign_one(self.signer, src, self.p("out.pdf"), "Signature1", "b-b", + core.CardIdentity("Jane Doe", None), + stamps=[text_stamp("{filename} {date}")], + stamp_date=datetime.date(2001, 2, 3)) + self.assertEqual(rendered, ["contract 03/02/2001"]) + class SummaryOutput(unittest.TestCase): def _capture(self, **kw): @@ -784,6 +1856,165 @@ def test_no_rrn_note_otherwise(self): self.assertNotIn("national register number", self._capture()) +class CliEndToEnd(TmpCase): + """The real CLI in a subprocess: visual signatures end to end (image + mode: no card, no network), exit codes included.""" + + def setUp(self): + super().setUp() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.doc = make_pdf(self.p("doc.pdf"), [(595, 842)] * 2) + self.png = make_png(self.p("sig.png")) + self.out = self.p("out") + + def run_cli(self, *argv, encoding="utf-8"): + # The CLI never reads the GUI profile; the directories are pinned to + # temp ones anyway so that nothing can reach the real user folder. + env = dict(os.environ, PYTHONIOENCODING=encoding, + CACHET_CONFIG_DIR=str(self.p("cfg")), + CACHET_DATA_DIR=str(self.p("data"))) + proc = subprocess.run( + [sys.executable, "sign_pdfs_beid.py", *argv], + capture_output=True, text=True, encoding=encoding, env=env, + cwd=str(Path(__file__).parent), timeout=120) + self.assertFalse(self.p("cfg").exists()) # no profile was created + self.assertFalse(self.p("data").exists()) + return proc + + def test_text_mode_exit_0_and_output(self): + proc = self.run_cli( + "--mode", "image", "--input", str(self.doc), "--output", str(self.out), + "--text", r"Jane Doe\nSigned on {date}", "--font", "caveat", + "--color", "#0A6B3C", "--font-size", "18", + "--page", "last", "--x", "360", "--y", "120") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(xobject_counts(self.out / "doc_signe.pdf"), [0, 1]) + self.assertIn("Mode: visual signatures — 1 element(s):", proc.stdout) + self.assertIn(' 1. text last page @ (360, 120) — "Jane Doe"', proc.stdout) + self.assertIn("1 visual signature(s) applied — text last page @ (360, 120)", + proc.stdout) + self.assertIn("1/1 document(s) processed successfully", proc.stdout) + + def test_text_outside_the_stdout_encoding(self): + # redirected output on Windows is the ANSI code page, strict: a name + # it cannot encode must not kill the run before any document + proc = self.run_cli( + "--mode", "image", "--input", str(self.doc), "--output", str(self.out), + "--text", "Ayşe Yılmaz", "--font", "lato", encoding="cp1252") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(xobject_counts(self.out / "doc_signe.pdf"), [1, 0]) + self.assertIn(r'"Ay\u015fe Y\u0131lmaz"', proc.stdout) # escaped, not fatal + self.assertIn("1/1 document(s) processed successfully", proc.stdout) + + def test_signatures_file_exit_0(self): + sigs = self.p("sigs.json") + sigs.write_text(json.dumps([ + {"kind": "text", "text": "JD", "font": "caveat", "font_size": 14, + "all_pages": True, "x": 540, "y": 20}, + {"kind": "image", "image_path": "sig.png", "width_pt": 120, + "page": 1, "x": 60, "y": 700}, + {"kind": "text", "text": "skipped", "enabled": False, + "page": 1, "x": 0, "y": 0}, + ]), encoding="utf-8") + proc = self.run_cli( + "--mode", "image", "--input", str(self.doc), "--output", str(self.out), + "--signatures", str(sigs)) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(xobject_counts(self.out / "doc_signe.pdf"), [2, 1]) + self.assertIn("Mode: visual signatures — 2 element(s):", proc.stdout) + self.assertIn("2 visual signature(s) applied", proc.stdout) + + def test_legacy_image_invocation_exit_0(self): + # the invocation documented since 1.x, template validation included + other = make_pdf(self.p("other.pdf"), [(595, 842)] * 2) + proc = self.run_cli( + "--mode", "image", "--template", str(self.doc), + "--input", str(self.doc), str(other), "--output", str(self.out), + "--image-path", str(self.png), "--page", "1", "--x", "360", "--y", "150") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + for name in ("doc_signe.pdf", "other_signe.pdf"): + self.assertEqual(xobject_counts(self.out / name), [1, 0]) + self.assertIn("1 visual signature(s) applied — image page 1 @ (360, 150)", + proc.stdout) + self.assertIn("2/2 document(s) processed successfully", proc.stdout) + + def test_bad_signatures_file_exit_2(self): + sigs = self.p("sigs.json") + sigs.write_text(json.dumps([ + {"kind": "text", "text": "ok", "page": 1, "x": 0, "y": 0}, + {"kind": "text", "text": "no position", "page": 1}, + ]), encoding="utf-8") + proc = self.run_cli( + "--mode", "image", "--input", str(self.doc), "--output", str(self.out), + "--signatures", str(sigs)) + self.assertEqual(proc.returncode, 2, proc.stdout + proc.stderr) # argparse error + self.assertIn("error: sigs.json: signature #2: x and y are required", proc.stderr) + self.assertNotIn("Traceback", proc.stderr) + self.assertFalse(self.out.exists()) # nothing was started + + def test_rejected_document_exit_2(self): + short = make_pdf(self.p("short.pdf"), [(595, 842)]) + proc = self.run_cli( + "--mode", "image", "--template", str(self.doc), + "--input", str(self.doc), str(short), "--output", str(self.out), + "--text", "JD", "--page", "1", "--x", "10", "--y", "10") + self.assertEqual(proc.returncode, 2, proc.stdout + proc.stderr) + self.assertIn("rejected — 1 page(s), the template has 2; signature #1 targets " + "page 1 (only first/last page targets accept a different page count)", + proc.stdout) + self.assertTrue((self.out / "doc_signe.pdf").exists()) # the valid one is done + self.assertFalse((self.out / "short_signe.pdf").exists()) + self.assertIn("1/2 document(s) processed successfully", proc.stdout) + + def test_template_note_only_when_every_element_is_anchored(self): + longer = make_pdf(self.p("longer.pdf"), [(595, 842)] * 3) + common = ("--mode", "image", "--template", str(self.doc), + "--input", str(longer), "--output", str(self.out)) + last = {"kind": "text", "text": "JD", "page_anchor": "last", "x": 10, "y": 10} + first = dict(last, page_anchor="first") + numbered = {"kind": "text", "text": "JD", "page": 1, "x": 10, "y": 10} + + def run_with(*entries): + sigs = self.p("sigs.json") + sigs.write_text(json.dumps(list(entries)), encoding="utf-8") + return self.run_cli(*common, "--signatures", str(sigs)) + + proc = self.run_cli(*common, "--text", "JD", "--page", "last", + "--x", "10", "--y", "10") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("accepted and signed on their last page.", proc.stdout) + self.assertEqual(xobject_counts(self.out / "longer_signe.pdf"), [0, 0, 1]) + proc = run_with(last, first) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("accepted and signed on their first and last pages.", proc.stdout) + self.assertEqual(xobject_counts(self.out / "longer_signe - 1.pdf"), [1, 0, 1]) + # ONE numbered target blocks page-count mismatches: no such promise + proc = run_with(last, numbered) + self.assertEqual(proc.returncode, 2, proc.stdout + proc.stderr) + self.assertNotIn("accepted and signed", proc.stdout) + self.assertIn("signature #2 targets page 1", proc.stdout) + + def test_beid_banner_lists_visual_signatures(self): + # No card is faked: the run stops at the PKCS#11 library load (a dummy + # file), AFTER the banner that announces what would be applied. + sigs = self.p("sigs.json") + sigs.write_text(json.dumps([ + {"kind": "text", "text": "JD", "all_pages": True, "x": 540, "y": 20}, + ]), encoding="utf-8") + lib = self.p("dummy.so") + lib.write_bytes(b"") + proc = self.run_cli( + "--mode", "beid", "--pades-level", "b-b", "--lib", str(lib), + "--input", str(self.doc), "--output", str(self.out), + "--signatures", str(sigs)) + self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) + self.assertIn("Cannot load the PKCS#11 library", proc.stderr) + self.assertIn("Mode: eID — vignette bottom-right, last page " + "+ 1 visual signature(s). PKCS#11 lib:", proc.stdout) + self.assertIn(' 1. text all pages @ (540, 20) — "JD"', proc.stdout) + self.assertFalse(self.out.exists()) # nothing was signed + + class _GuiTestBase(unittest.TestCase): """Shared display-guarded setup for the wizard tests.""" @@ -794,16 +2025,37 @@ def setUp(self): tkinter.Tk().destroy() # detects the absence of a display import gui # noqa: F401 - requires customtkinter+tk except Exception as exc: # noqa: BLE001 + # A run that is SUPPOSED to exercise the GUI must not turn a + # broken gui.py or a missing display into a green "all skipped". + if os.environ.get("CACHET_REQUIRE_GUI") == "1": + self.fail("GUI tests are required (CACHET_REQUIRE_GUI=1) but " + f"unavailable: {exc!r}") self.skipTest(f"tkinter/GUI unavailable: {exc}") + # Cyclic GC stays off while a GUI test runs. Step widgets are rebuilt + # constantly, which leaves CTkFont objects in reference cycles; when + # the collector happens to run in the batch WORKER thread, each + # Font.__del__ is a Tk call from a non-main thread that waits a full + # second for a mainloop these tests never enter (they pump update()), + # and the batch then outlives the test's wait loop. Which thread + # collects depends on allocation counts, i.e. on unrelated code. + gc.disable() + self.addCleanup(gc.enable) self.tmp = Path(tempfile.mkdtemp()) + # The GUI loads and auto-saves a user profile: each test gets its own + # (never the real one), so tests stay independent of each other. + env = mock.patch.dict(os.environ, { + "CACHET_CONFIG_DIR": str(self.tmp / "cfg"), + "CACHET_DATA_DIR": str(self.tmp / "data")}) + env.start() + self.addCleanup(env.stop) self.types = types - def make_app(self): + def make_app(self, store=None): import contextlib import tkinter import gui - app = gui.CachetApp(self.types.SimpleNamespace(lib=None)) + app = gui.CachetApp(self.types.SimpleNamespace(lib=None), store=store) app.update() def _cleanup(): @@ -844,7 +2096,7 @@ def test_launch_populates_report_and_writes_output(self): self.wizard_with_state(app, tpl, out) self.assertEqual(len(app.valid_paths), 1) app.mode_var.set("image") - app.image_path = png + app._add_image_signature(png) # before step 6 is built; it is selected app._goto_step(5) # placement step builds the canvas app._draw_page() app.update() @@ -894,33 +2146,36 @@ def boom(*a, **k): class GuiBeidPlacement(_GuiTestBase): - """Placement step in beid mode: 3:1 placeholder (1/5 page), no image - picker, click → target-page sync, canvas that follows the window.""" + """Placement step in beid mode: the vignette heads the element list, a + 3:1 box (1/5 page); click → target-page sync, canvas that follows the + window.""" def test_beid_placeholder_and_resize(self): + import gui tpl = make_pdf(self.tmp / "t.pdf", [(600, 800), (600, 800)]) app = self.make_app() - app.geometry("900x950") + app.geometry("1500x950") app.update() self.wizard_with_state(app, tpl, self.tmp) app.mode_var.set("beid") app._goto_step(5) # placement step app.update() - # beid mode: no image picker (row not handled by a manager) - self.assertEqual(app.image_row.winfo_manager(), "") + # beid mode: the vignette is the first element of the list + self.assertIn(gui.VIGNETTE_ID, app._element_rows) # placeholder = 3:1 vignette, width = page/5 - iw, ih = app._placeholder_size_pt() + iw, ih = app._element_size_pt(gui.VIGNETTE_ID) self.assertAlmostEqual(iw, 600 / 5) self.assertAlmostEqual(iw / ih, 3.0) # a click sets the position (in beid mode too, not just image) and # syncs the manual target-page field fw, fh, ox, oy = app._frame_geom app._on_canvas_click(self.types.SimpleNamespace(x=ox + fw * 0.5, y=oy + fh * 0.5)) - self.assertIsNotNone(app.place_x) + self.assertIsNotNone(app.profile.settings.vignette.x) self.assertEqual(app.page_text, "1") big = app.canvas.winfo_reqwidth() # shrink the window -> the canvas shrinks, proportions preserved - app.geometry("520x680") + # (both sizes are above the width where the canvas hits its floor) + app.geometry("1000x680") app.update() small = app.canvas.winfo_reqwidth() app.destroy() @@ -1042,7 +2297,7 @@ def test_selector_locks_page_and_signs_short_file(self): self.assertEqual(app.cur_page, 0) # end-to-end in image mode: the 1-page file gets signed on page 1 app.mode_var.set("image") - app.image_path = png + app._add_image_signature(png) app._goto_step(5) # placement step, locked app.update() self.assertEqual(app.cur_page, 0) @@ -1330,10 +2585,11 @@ def test_card_box_only_in_beid_mode(self): app._goto_step(6) app.update() self.assertTrue(app.card_box.winfo_manager()) - # image mode: step 6 needs an image + a position to be complete + # image mode: step 6 needs a placed visual signature to be complete app.mode_var.set("image") - app.image_path = make_png(self.tmp / "s.png") - app.place_page, app.place_x, app.place_y = 1, 10.0, 10.0 + sig = app._add_image_signature(make_png(self.tmp / "s.png")) + sig.stamp = dataclasses.replace(sig.stamp, page=1, x=10.0, y=10.0) + sig.placed_on = app.template_dims[0] app._goto_step(6) app.update() self.assertEqual(app.step_index, 6) @@ -1467,6 +2723,1461 @@ def test_logo_loads_and_degrades_gracefully(self): gui._logo_pil = orig_cache +class _GuiEditorBase(_GuiTestBase): + """Shared fixtures of the step-6 editor and persistence tests: a 2-page + template, an app parked on the placement step, synthetic clicks. Native + pickers are always patched and dialogs are driven through their handlers + — nothing here blocks.""" + + def setUp(self): + super().setUp() + import gui + import i18n + self.gui, self.i18n, self.tr = gui, i18n, i18n.tr + self.addCleanup(i18n.set_language, "en") + self.tpl = make_pdf(self.tmp / "t.pdf", [(595, 842), (595, 842)]) + self.out = self.tmp / "out" + + def app_at_place(self, mode="image", inputs=None): + """A fresh app on the placement step (step 6) in `mode`.""" + app = self.make_app() + self.wizard_with_state(app, self.tpl, self.out, inputs=inputs) + app.mode_var.set(mode) + app._goto_step(5) + app.update() + self.assertEqual(app.step_index, 5) + return app + + def pump(self, app, until=lambda: False, rounds=120): + """Pump the Tk loop (these tests never enter mainloop) until + `until()` holds, for at most ~rounds × 50 ms.""" + import time + for _ in range(rounds): + app.update() + time.sleep(0.05) + if until(): + break + app.update() + + def click(self, app, fx=0.5, fy=0.5): + """Click on the page preview, at a fraction of the page frame.""" + fw, fh, ox, oy = app._frame_geom + app._on_canvas_click(self.types.SimpleNamespace(x=ox + fw * fx, y=oy + fh * fy)) + app.update() + + def add_text(self, app, text="Jane", label=None, **stamp_kw): + """An (unplaced) text signature straight into the library.""" + return app._add_signature(Stamp(kind="text", text=text, **stamp_kw), + label or text) + + @staticmethod + def fill(entry, value): + entry.delete(0, "end") + entry.insert(0, value) + + @staticmethod + def type_text(app, text): + """Replace the text of the text dialog, as typing does (each key + release refreshes the preview).""" + app.dlg_text_box.delete("1.0", "end") + app.dlg_text_box.insert("1.0", text) + app._on_text_dialog_edit() + + def draw(self, app, *strokes): + """Freehand strokes on the draw dialog, through its mouse handlers.""" + event = self.types.SimpleNamespace + for points in strokes: + x, y = points[0] + app._draw_start(event(x=x, y=y)) + for x, y in points[1:]: + app._draw_move(event(x=x, y=y)) + + @staticmethod + def items(app, element_id, kind=None): + """Canvas items of one element (optionally of one canvas type).""" + return [i for i in app.canvas.find_withtag(f"el:{element_id}") + if kind is None or app.canvas.type(i) == kind] + + @staticmethod + def saved(app) -> dict: + """The profile as it is on disk right now ({} if never saved).""" + path = app.store.profile_path + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + + @staticmethod + def modal_button(app, text): + """A button of the open confirmation modal (its last child row).""" + import customtkinter as ctk + top = [w for w in app.winfo_children() if isinstance(w, ctk.CTkToplevel)][-1] + return next(b for b in top.winfo_children()[-1].winfo_children() + if isinstance(b, ctk.CTkButton) and b.cget("text") == text) + + def launch_capturing_config(self, app) -> core.RunConfig: + """Start the batch from the signing step with `process_batch` + replaced: returns the RunConfig the GUI built.""" + seen = [] + with mock.patch.object(core, "process_batch", + side_effect=lambda cfg, **_k: seen.append(cfg) or []): + app._launch() + self.pump(app, lambda: not app._running, rounds=60) + (cfg,) = seen + return cfg + + +class GuiSignatureEditor(_GuiEditorBase): + """Step 6 as a multi-element editor: the element list (vignette + the + library of visual signatures), the three dialogs (text, drawing, image), + click-to-place of the SELECTED element, the to-scale preview of every + element, the step predicates, the anchor lock and the launched config.""" + + def test_vignette_row_only_in_crypto_modes(self): + app = self.app_at_place("beid") + self.assertIn(self.gui.VIGNETTE_ID, app._element_rows) + self.assertEqual(app.selected_id, self.gui.VIGNETTE_ID) + app.mode_var.set("image") + app._goto_step(5) + self.assertNotIn(self.gui.VIGNETTE_ID, app._element_rows) + self.assertIsNone(app.selected_id) # empty library: nothing to select + app.mode_var.set("azure") + app.azure_anchors_path = self.tpl # completes step 5 in azure mode + app._goto_step(5) + self.assertIn(self.gui.VIGNETTE_ID, app._element_rows) + + # ---------------------------------------------------------- text dialog + def test_add_text_dialog_creates_selected_signature(self): + app = self.app_at_place() + app.add_text_btn.invoke() + app.update() + dialog = app._dialog + self.assertTrue(dialog.winfo_exists()) + self.assertEqual(dialog.title(), self.tr("sig.text_title_add")) + self.type_text(app, "Jane Doe\nRead and approved") + app.dlg_save_btn.invoke() + app.update() + (sig,) = app.profile.signatures + # default font, colour and size; not placed yet + self.assertEqual(sig.stamp, Stamp(kind="text", text="Jane Doe\nRead and approved")) + self.assertEqual(sig.label, "Jane Doe") # first line of the text + self.assertEqual(app.selected_id, sig.id) + self.assertIn(sig.id, app._element_rows) + self.assertIsNone(app._dialog) + self.assertFalse(dialog.winfo_exists()) + self.assertEqual([s["text"] for s in self.saved(app)["signatures"]], + ["Jane Doe\nRead and approved"]) + self.assertEqual(app._place_error(), self.tr("place.unplaced", name="Jane Doe")) + # a second one, with an explicit name + app._open_text_dialog() + self.fill(app.dlg_name_entry, " Initials ") + self.type_text(app, "JD") + app._apply_text_dialog() + self.assertEqual([s.label for s in app.profile.signatures], + ["Jane Doe", "Initials"]) + self.assertEqual(len(self.saved(app)["signatures"]), 2) + + def test_text_dialog_validation_errors(self): + app = self.app_at_place() + app._open_text_dialog() + app.update() + blank = app._blank_ctk_image() + self.assertEqual(app.dlg_error_lbl.cget("text"), "") # untouched: no error yet + app._apply_text_dialog() # no text at all + self.assertEqual(app.dlg_error_lbl.cget("text"), self.tr("sig.err_text_empty")) + app._update_text_preview() # a refresh keeps it after a failed save + self.assertEqual(app.dlg_error_lbl.cget("text"), self.tr("sig.err_text_empty")) + self.type_text(app, " \n ") # blank text + app._apply_text_dialog() + self.assertEqual(app.dlg_error_lbl.cget("text"), self.tr("sig.err_text_empty")) + self.type_text(app, "Jane") + for bad in ("abc", "0", "-3", "401", "nan", ""): + with self.subTest(size=bad): + self.fill(app.dlg_size_entry, bad) + app._apply_text_dialog() + self.assertEqual(app.dlg_error_lbl.cget("text"), + self.tr("sig.err_size", max=400)) + self.assertTrue(app._dialog.winfo_exists()) # stays open + self.assertEqual(app.profile.signatures, []) # nothing was added + # A valid preview followed by an error: the previous picture must go + # (CTkLabel.configure(image=None) would leave it on screen) ... + self.fill(app.dlg_size_entry, "24") + app._update_text_preview() + self.assertEqual(app.dlg_error_lbl.cget("text"), "") + self.assertIsNot(app.dlg_preview_lbl._cachet_img, blank) + self.fill(app.dlg_size_entry, "abc") + app._update_text_preview() + self.assertIs(app.dlg_preview_lbl._cachet_img, blank) + self.assertIs(app.dlg_preview_lbl.cget("image"), blank) + # ... and the next valid keystroke renders again, also once the + # dropped picture has really been freed (TclError "image doesn't + # exist" otherwise). + gc.collect() + app.update() + self.fill(app.dlg_size_entry, "30") + app._update_text_preview() + app.update() + self.assertIsNot(app.dlg_preview_lbl._cachet_img, blank) + self.assertEqual(app.dlg_error_lbl.cget("text"), "") + + def test_oversized_text_refused_and_not_persisted(self): + import time + app = self.app_at_place() + app._open_text_dialog() + self.fill(app.dlg_size_entry, "400") + start = time.monotonic() + self.type_text(app, "W" * 300) # ~500 MP at 300 dpi + self.assertLess(time.monotonic() - start, 2.0) # refused by measuring alone + refusal = app.dlg_error_lbl.cget("text") + self.assertTrue(refusal.startswith(self.tr("sig.err_render", error="")), refusal) + self.assertIn("too large", refusal) + self.assertIs(app.dlg_preview_lbl.cget("image"), app._blank_ctk_image()) + app._apply_text_dialog() + self.assertTrue(app._dialog.winfo_exists()) # kept open + self.assertEqual(app.dlg_error_lbl.cget("text"), refusal) + self.assertEqual(app.profile.signatures, []) + self.assertEqual(self.saved(app).get("signatures", []), []) + + def test_text_box_does_not_wrap(self): + # The stamp breaks lines only at real newlines, so the box must not + # wrap a long line visually: it scrolls sideways instead. (CTkTextbox + # 5.2.2 has no cget("wrap"): the behaviour itself is asserted.) + app = self.app_at_place() + app._open_text_dialog() + self.type_text(app, "A sentence typed without ever pressing Enter. " * 8) + self.pump(app, rounds=3) + first, last = app.dlg_text_box.xview() + self.assertEqual(first, 0.0) + self.assertLess(last, 1.0) # part of the line is off to the right + + def test_preview_shows_size_on_page(self): + app = self.app_at_place() + app._open_text_dialog() + self.type_text(app, "Jane Doe") + at_24 = app.dlg_size_lbl.cget("text") + self.assertRegex(at_24, r"\d+ × \d+ pt") + self.fill(app.dlg_size_entry, "48") + app._update_text_preview() + # the size ON THE PAGE (what the stamping code gives), not the + # size of the scaled-down preview + _img, (w_pt, h_pt) = stamplib.stamp_content( + Stamp(kind="text", text="Jane Doe", font_size=48.0)) + self.assertEqual(app.dlg_size_lbl.cget("text"), + self.tr("sig.preview_size", w=round(w_pt), h=round(h_pt))) + self.assertNotEqual(app.dlg_size_lbl.cget("text"), at_24) + # a line wider than the page: the preview is scaled down to its box, + # the size line still tells the truth + wide = "A sentence typed without ever pressing Enter. " * 3 + self.type_text(app, wide) + _img, (w_pt, h_pt) = stamplib.stamp_content( + Stamp(kind="text", text=wide, font_size=48.0)) + self.assertGreater(w_pt, 595) + width, height = app.dlg_preview_lbl.cget("image").cget("size") + self.assertTrue(width <= 500 and height <= 120, (width, height)) + self.assertEqual(app.dlg_size_lbl.cget("text"), + self.tr("sig.preview_size", w=round(w_pt), h=round(h_pt))) + self.fill(app.dlg_size_entry, "abc") + app._update_text_preview() + self.assertEqual(app.dlg_size_lbl.cget("text"), "") + + def test_picker_after_dialog_closed_is_harmless(self): + # the dialog can be closed while a native picker is open + app = self.app_at_place() + font = Path(shutil.copy(stamplib.resolve_font("caveat"), self.tmp / "hand.ttf")) + + def closing_picker(value): + def picker(*_a, **_k): + app._close_dialog() + return value + return picker + + app._open_text_dialog() + with mock.patch.object(self.gui.filedialog, "askopenfilename", + side_effect=closing_picker(str(font))): + app._on_dialog_font(self.tr("sig.font_custom")) + self.assertIsNone(app._dialog) + for opener in (app._open_text_dialog, app._open_draw_dialog): + opener() + with mock.patch.object(self.gui.colorchooser, "askcolor", + side_effect=closing_picker(((255, 0, 0), "#ff0000"))): + app._pick_dialog_color() + self.assertIsNone(app._dialog) + app.update() + self.assertEqual(app.profile.signatures, []) + + def test_text_dialog_colour_and_custom_font(self): + app = self.app_at_place() + font = Path(shutil.copy(stamplib.resolve_font("caveat"), self.tmp / "My Hand.ttf")) + custom = self.tr("sig.font_custom") + app._open_text_dialog() + self.type_text(app, "Jane") + self.assertEqual(app.dlg_font_menu.cget("values"), + [name for _id, name in stamplib.font_choices()] + [custom]) + with mock.patch.object(self.gui.colorchooser, "askcolor", + return_value=((255, 0, 0), "#ff0000")) as ask: + app.dlg_color_btn.invoke() + self.assertIs(ask.call_args.kwargs["parent"], app._dialog) # not behind the grab + self.assertEqual(app.dlg_color_btn.cget("text"), "#FF0000") + self.assertEqual(app.dlg_color_btn.cget("fg_color"), "#FF0000") + with mock.patch.object(self.gui.colorchooser, "askcolor", + return_value=(None, None)): # cancelled + app._pick_dialog_color() + self.assertEqual(app._dialog_state["color"], "#FF0000") + with mock.patch.object(self.gui.filedialog, "askopenfilename", + return_value=str(font)) as ask: + app._on_dialog_font(custom) + self.assertIs(ask.call_args.kwargs["parent"], app._dialog) + self.assertEqual(app.dlg_font_menu.get(), "My Hand.ttf") + with mock.patch.object(self.gui.filedialog, "askopenfilename", return_value=""): + app.dlg_font_menu.set(custom) # what picking the entry shows first + app._on_dialog_font(custom) # cancelled: back to the font in use + self.assertEqual(app.dlg_font_menu.get(), "My Hand.ttf") + self.assertEqual(app.dlg_error_lbl.cget("text"), "") + app._apply_text_dialog() + (sig,) = app.profile.signatures + self.assertEqual((sig.stamp.font, sig.stamp.color), (str(font), "#FF0000")) + thumb, _size = app._content(sig) # rendered with that colour + self.assertEqual([c for _n, c in thumb.convert("RGB").getcolors()], [(255, 0, 0)]) + # editing it shows the custom font's file name; the menu goes back + # to a bundled font + app._edit_selected() + self.assertEqual(app.dlg_font_menu.get(), "My Hand.ttf") + self.assertEqual(app.dlg_color_btn.cget("text"), "#FF0000") + app._on_dialog_font("Lato") + app._apply_text_dialog() + self.assertEqual(sig.stamp.font, "lato") + + def test_text_preview_renders(self): + app = self.app_at_place() + app._open_text_dialog() + app.update() + blank = app._blank_ctk_image() + self.assertIs(app.dlg_preview_lbl.cget("image"), blank) # nothing typed yet + # ... and no reproach for it: not before the user typed or tried to save + self.assertEqual(app.dlg_error_lbl.cget("text"), "") + app._on_dialog_font("Lato") # another refresh, still untouched + self.assertEqual(app.dlg_error_lbl.cget("text"), "") + self.type_text(app, "") # typed, then emptied: now it is said + self.assertEqual(app.dlg_error_lbl.cget("text"), self.tr("sig.err_text_empty")) + app._on_dialog_font("Great Vibes") + self.assertEqual(app.dlg_error_lbl.cget("text"), self.tr("sig.err_text_empty")) + self.type_text(app, "Jane Doe\n{date} {filename}") + app.update() + shown = app.dlg_preview_lbl.cget("image") + self.assertIsNot(shown, blank) + self.assertEqual(app.dlg_error_lbl.cget("text"), "") + width, height = shown.cget("size") + self.assertTrue(0 < width <= 500 and 0 < height <= 120, (width, height)) + # the preview IS the stamping code's output, for a sample document + with mock.patch.object(self.gui.stamplib, "stamp_content", + wraps=stamplib.stamp_content) as spy: + self.type_text(app, "JD") + self.assertEqual(spy.call_args.args[0], + Stamp(kind="text", text="JD")) + self.assertEqual(spy.call_args.kwargs["filename"], "t") + self.assertIsNot(app.dlg_preview_lbl.cget("image"), shown) # a fresh picture + # whatever the renderer raises becomes the dialog's message + with mock.patch.object(self.gui.stamplib, "stamp_content", + side_effect=MemoryError()): + self.type_text(app, "Jane") + self.assertEqual(app.dlg_error_lbl.cget("text"), + self.tr("sig.err_render", error="MemoryError")) + self.assertIs(app.dlg_preview_lbl.cget("image"), blank) + + def test_edit_text_keeps_placement(self): + app = self.app_at_place() + sig = self.add_text(app, "Jane", label="My signature") + app._turn_page(1) + self.click(app, 0.3, 0.6) + placement = (sig.stamp.page, sig.stamp.x, sig.stamp.y, sig.placed_on) + size_before = app._element_size_pt(sig.id) + app.edit_btn.invoke() + app.update() + self.assertEqual(app._dialog.title(), self.tr("sig.text_title_edit")) + self.assertEqual(app.dlg_name_entry.get(), "My signature") # seeded + self.assertEqual(app.dlg_text_box.get("1.0", "end-1c"), "Jane") + self.assertEqual(app.dlg_size_entry.get(), "24") + self.assertEqual(app.dlg_font_menu.get(), "Great Vibes") + self.type_text(app, "Jane Doe") + self.fill(app.dlg_size_entry, "36,5") # decimal comma + app._on_dialog_font("Caveat") + app._apply_text_dialog() + self.assertIsNone(app._dialog) + self.assertEqual([s.id for s in app.profile.signatures], [sig.id]) # in place + self.assertEqual((sig.stamp.text, sig.stamp.font, sig.stamp.font_size), + ("Jane Doe", "caveat", 36.5)) + self.assertEqual((sig.stamp.page, sig.stamp.x, sig.stamp.y, sig.placed_on), + placement) + self.assertEqual(placement[0], 2) + self.assertTrue(app._sig_placed(sig)) + self.assertEqual(sig.label, "My signature") + self.assertNotEqual(app._element_size_pt(sig.id), size_before) # re-rendered + saved = self.saved(app)["signatures"][0] + self.assertEqual((saved["text"], saved["font_size"], saved["page"]), + ("Jane Doe", 36.5, 2)) + # an emptied name falls back on the first non-empty line (40 chars) + app._edit_selected() + self.fill(app.dlg_name_entry, "") + self.type_text(app, "\n " + "x" * 50 + "\nsecond line") + app._apply_text_dialog() + self.assertEqual(sig.label, "x" * 40) + + # ------------------------------------------------------- image signatures + def test_add_image_copies_into_store(self): + app = self.app_at_place() + png = make_png(self.tmp / "s.png") + with mock.patch.object(self.gui.filedialog, "askopenfilename", + return_value=str(png)): + app.add_image_btn.invoke() + (sig,) = app.profile.signatures + self.assertEqual(sig.label, "s") + self.assertEqual(app.selected_id, sig.id) + self.assertTrue(app.store.is_store_file(sig.stamp.image_path)) + self.assertTrue(str(sig.stamp.image_path).startswith(str(self.tmp))) + self.assertEqual(self.saved(app)["signatures"][0]["image_path"], + sig.stamp.image_path.name) + # the stored COPY is used: the original may go away + png.unlink() + app._preview_cache.clear() + app._scaled_cache.clear() + self.assertIsNotNone(app._content(sig)) + self.click(app) + self.assertEqual(len(self.items(app, sig.id, "image")), 1) + with mock.patch.object(self.gui.filedialog, "askopenfilename", return_value=""): + app._pick_image_signature() # cancelled picker: nothing happens + self.assertEqual(len(app.profile.signatures), 1) + + def test_unreadable_image_reports_error(self): + app = self.make_app() + self.wizard_with_state(app, self.tpl, self.out) + app.mode_var.set("image") + bad = self.tmp / "bad.png" + bad.write_text("not an image") + self.assertIsNone(app._add_image_signature(bad)) # step 6 is not built yet + self.assertEqual(app.profile.signatures, []) + notice = app._element_notice + self.assertTrue(notice.startswith(self.tr("sig.err_image", error="")), notice) + app._goto_step(5) + app._update_place_labels() + self.assertEqual(app.place_warn_lbl.cget("text"), notice) + app._save_profile() # a successful save does not erase it + app._update_place_labels() + self.assertEqual(app.place_warn_lbl.cget("text"), notice) + sig = self.add_text(app) # (a new operation clears it ...) + app._element_notice = notice + app._select(sig.id) # ... and so does selecting + self.assertIsNone(app._element_notice) + self.assertNotEqual(app.place_warn_lbl.cget("text"), notice) + + def test_image_store_failure_adds_nothing(self): + # no fallback to the original file when the copy cannot be made + app = self.app_at_place() + with mock.patch.object(app.store, "import_image", side_effect=OSError("disk full")): + self.assertIsNone(app._add_image_signature(make_png(self.tmp / "s.png"))) + self.assertEqual(app.profile.signatures, []) + self.assertEqual(app._element_notice, self.tr("sig.err_store", error="disk full")) + self.assertIsNone(app._profile_error) # not a profile-save failure + self.assertEqual(app.place_warn_lbl.cget("text"), app._element_notice) + + def test_edit_image_width(self): + app = self.app_at_place() + sig = app._add_image_signature(make_png(self.tmp / "s.png")) # 300×120 px + self.click(app) + placement = (sig.stamp.page, sig.stamp.x, sig.stamp.y, sig.placed_on) + app.edit_btn.invoke() + app.update() + self.assertEqual(app._dialog.title(), self.tr("sig.image_title")) + self.assertEqual((app.dlg_name_entry.get(), app.dlg_width_entry.get()), + ("s", "150")) + self.assertIsNot(app.dlg_preview_lbl.cget("image"), app._blank_ctk_image()) + for bad in ("abc", "0", "5001", ""): + with self.subTest(width=bad): + self.fill(app.dlg_width_entry, bad) + app._apply_image_dialog() + self.assertEqual(app.dlg_error_lbl.cget("text"), + self.tr("sig.err_width", max=5000)) + self.assertTrue(app._dialog.winfo_exists()) + self.assertEqual(sig.stamp.width_pt, 150.0) + self.fill(app.dlg_width_entry, "60,5") + self.fill(app.dlg_name_entry, "Company stamp") + app.dlg_save_btn.invoke() + self.assertIsNone(app._dialog) + self.assertEqual((sig.stamp.width_pt, sig.label), (60.5, "Company stamp")) + self.assertEqual((sig.stamp.page, sig.stamp.x, sig.stamp.y, sig.placed_on), + placement) + width, height = app._element_size_pt(sig.id) # the preview follows + self.assertAlmostEqual(width, 60.5) + self.assertAlmostEqual(height, 60.5 * 120 / 300) + self.assertEqual(self.saved(app)["signatures"][0]["width_pt"], 60.5) + # stored file gone: no preview, the reason, and the dialog still opens + sig.stamp.image_path.unlink() + app._preview_cache.clear() + app._scaled_cache.clear() + app._edit_selected() + self.assertIs(app.dlg_preview_lbl.cget("image"), app._blank_ctk_image()) + self.assertEqual(app.dlg_error_lbl.cget("text"), + self.tr("sig.err_image", error=app._sig_error(sig))) + + # ----------------------------------------------------------- draw dialog + def test_draw_dialog_creates_transparent_png(self): + app = self.app_at_place() + app.draw_btn.invoke() + app.update() + self.assertEqual(app._dialog.title(), self.tr("sig.draw_title")) + self.draw(app, [(100, 100), (200, 60), (400, 140)], [(250, 200)]) # a line, a dot + self.assertEqual(len(app._dialog_state["strokes"]), 2) + self.assertEqual(len(app.dlg_draw_canvas.find_all()), 4) # the on-screen echo + with mock.patch.object(self.gui.colorchooser, "askcolor", + return_value=((0, 128, 0), "#008000")): + app.dlg_color_btn.invoke() + for item in app.dlg_draw_canvas.find_all(): # recoloured + self.assertEqual(app.dlg_draw_canvas.itemcget(item, "fill"), "#008000") + app.dlg_save_btn.invoke() + app.update() + self.assertIsNone(app._dialog) + (sig,) = app.profile.signatures + self.assertEqual((sig.stamp.kind, sig.label), ("image", self.tr("sig.drawn_label"))) + self.assertEqual(app.selected_id, sig.id) + self.assertTrue(app.store.is_store_file(sig.stamp.image_path)) + self.assertEqual(sig.stamp.image_path.suffix, ".png") + with Image.open(sig.stamp.image_path) as png: + self.assertEqual(png.mode, "RGBA") + self.assertEqual(png.getchannel("A").getextrema(), (0, 255)) + self.assertEqual([c for _n, c in png.convert("RGB").getcolors()], + [(0, 128, 0)]) + # 0.4 pt per canvas px: 300 px of strokes (+ the pen) -> ~121 pt + self.assertAlmostEqual(sig.stamp.width_pt, 303 * 0.4, delta=2) + self.assertEqual(self.saved(app)["signatures"][0]["image_path"], + sig.stamp.image_path.name) + self.click(app) # it is placed like any image + self.assertEqual(len(self.items(app, sig.id, "image")), 1) + + def test_draw_points_are_clamped_to_the_canvas(self): + # a drag goes on outside the canvas: far-away points must not turn + # the drawing into a gigapixel raster + app = self.app_at_place() + app._open_draw_dialog() + self.draw(app, [(10, 10), (99999, -99999)]) + self.assertEqual(app._dialog_state["strokes"], [[(10, 10), (620, 0)]]) + app._apply_draw_dialog() + (sig,) = app.profile.signatures + self.assertEqual(sig.stamp.width_pt, 240.0) # widest allowed + with Image.open(sig.stamp.image_path) as png: + self.assertLessEqual(png.width, (620 + 6) * 4) + + def test_draw_dialog_empty_is_refused(self): + app = self.app_at_place() + app._open_draw_dialog() + app._apply_draw_dialog() + self.assertTrue(app._dialog.winfo_exists()) + self.assertEqual(app.dlg_error_lbl.cget("text"), self.tr("sig.err_draw_empty")) + self.draw(app, [(10, 10), (50, 50)]) + self.assertEqual(app.dlg_error_lbl.cget("text"), "") # no stale message + app._dialog_error("x") + app.dlg_clear_btn.invoke() # empties the model and its echo + self.assertEqual(app._dialog_state["strokes"], []) + self.assertEqual(app.dlg_draw_canvas.find_all(), ()) + self.assertEqual(app.dlg_error_lbl.cget("text"), "") + app._apply_draw_dialog() + self.assertEqual(app.dlg_error_lbl.cget("text"), self.tr("sig.err_draw_empty")) + self.assertEqual(app.profile.signatures, []) + self.assertFalse(app.store.signatures_dir.exists()) # nothing was stored + # a drawing over the pixel budget says so (not "draw something first") + self.draw(app, [(10, 10), (50, 50)]) + with mock.patch.object(stamplib, "MAX_TEXT_PIXELS", 10): + app._apply_draw_dialog() + self.assertEqual(app.dlg_error_lbl.cget("text"), self.tr("sig.err_draw_too_large")) + self.assertTrue(app._dialog.winfo_exists()) + self.assertEqual(app.profile.signatures, []) + # a drawing that cannot be stored is not added either + with mock.patch.object(app.store, "store_image", side_effect=OSError("disk full")): + app._apply_draw_dialog() + self.assertEqual(app.dlg_error_lbl.cget("text"), + self.tr("sig.err_store", error="disk full")) + self.assertTrue(app._dialog.winfo_exists()) + self.assertEqual(app.profile.signatures, []) + + def test_dialog_cancel_and_window_close_add_nothing(self): + app = self.app_at_place() + app._open_text_dialog() + self.type_text(app, "Jane") + app.dlg_cancel_btn.invoke() + self.assertIsNone(app._dialog) + app._open_draw_dialog() + app.update() + dialog = app._dialog + self.draw(app, [(10, 10), (50, 50)]) + app.tk.call(dialog.protocol("WM_DELETE_WINDOW")) # the window's close button + self.assertFalse(dialog.winfo_exists()) + self.assertIsNone(app._dialog) + self.assertEqual(app._dialog_state, {}) + self.assertEqual(app.profile.signatures, []) + self.assertFalse(app.store.signatures_dir.exists()) + # only one dialog at a time: opening another closes the previous one + app._open_text_dialog() + first = app._dialog + app._open_draw_dialog() + self.assertFalse(first.winfo_exists()) + self.assertEqual(app._dialog_state["kind"], "draw") + + # ------------------------------------------- delete, enable, every page + def test_delete_removes_signature_and_file(self): + app = self.app_at_place() + sig = app._add_image_signature(make_png(self.tmp / "s.png")) + stored = sig.stamp.image_path + self.assertTrue(stored.exists()) + app.delete_btn.invoke() + app.update() + self.modal_button(app, self.tr("sig.delete_keep")).invoke() # changed my mind + app.update() + self.assertEqual(app.profile.signatures, [sig]) + app._delete_selected() + app.update() + self.modal_button(app, self.tr("sig.delete_confirm")).invoke() + app.update() + self.assertEqual(app.profile.signatures, []) + self.assertFalse(stored.exists()) + self.assertIsNone(app.selected_id) + self.assertEqual(self.saved(app)["signatures"], []) + self.assertEqual(str(app.delete_btn.cget("state")), "disabled") + self.assertEqual(str(app.edit_btn.cget("state")), "disabled") + + def test_enable_toggle_persists_and_ungates(self): + app = self.app_at_place("beid") + sig = self.add_text(app) + self.assertEqual(app._place_error(), self.tr("place.unplaced", name="Jane")) + self.assertEqual(str(app._btn_next.cget("state")), "disabled") + app._element_checks[sig.id].toggle() # what a click on the box does + app.update() + self.assertFalse(sig.enabled) + self.assertIsNone(app._place_error()) # an unticked signature never blocks + self.assertEqual(str(app._btn_next.cget("state")), "normal") + self.assertFalse(self.saved(app)["signatures"][0]["enabled"]) + self.assertEqual(app._element_checks[sig.id].get(), 0) + app._toggle_enabled(sig.id) + self.assertTrue(sig.enabled) + self.assertEqual(app._element_checks[sig.id].get(), 1) + self.assertTrue(self.saved(app)["signatures"][0]["enabled"]) + + def test_toggle_all_pages_flips_model(self): + # direct calls: the direction comes from the model, not the checkbox + app = self.app_at_place() + sig = self.add_text(app) + app._toggle_all_pages() + self.assertTrue(sig.stamp.all_pages) + self.assertEqual(app.all_pages_chk.get(), 1) # the widget follows + self.assertIs(self.saved(app)["signatures"][0]["all_pages"], True) + app._toggle_all_pages() # off, no position yet + self.assertFalse(sig.stamp.all_pages) + self.assertIsNone(sig.stamp.page) + self.assertEqual(app.all_pages_chk.get(), 0) + app._toggle_all_pages() + self.click(app) + self.assertTrue(sig.stamp.all_pages) + self.assertIsNone(sig.stamp.page) + self.assertEqual(app.page_text, "") # no single target page + self.assertTrue(app._sig_placed(sig)) + app._turn_page(1) + app._toggle_all_pages() # off, with a position + self.assertEqual(sig.stamp.page, app.cur_page + 1) + self.assertEqual((sig.stamp.page, app.page_text), (2, "2")) + self.assertEqual(app.all_pages_chk.get(), 0) + app.all_pages_chk.toggle() # a real click flips too + app.update() + self.assertTrue(sig.stamp.all_pages) + self.assertEqual(app.all_pages_chk.get(), 1) + + def test_untick_all_pages_keeps_a_stale_placement_unplaced(self): + app = self.app_at_place() + sig = self.add_text(app) + app._toggle_all_pages() + self.click(app, 0.5, 0.05) # near the top of an A4 page + self.assertTrue(app._sig_placed(sig)) + before = (sig.stamp.x, sig.stamp.y, tuple(sig.placed_on)) + app.template_dims = [(612.0, 792.0)] * 2 # as with another template: Letter + app._refresh_place() + self.assertFalse(app._sig_placed(sig)) # saved for another page size + app._toggle_all_pages() # untick: must NOT place it by itself + self.assertFalse(sig.stamp.all_pages) + self.assertIsNone(sig.stamp.page) + self.assertEqual((sig.stamp.x, sig.stamp.y, tuple(sig.placed_on)), before) # kept + self.assertFalse(app._sig_placed(sig)) + self.assertIsNotNone(app._place_error()) # still blocks until a click + self.click(app) + self.assertTrue(app._sig_placed(sig)) + self.assertEqual((sig.stamp.page, tuple(sig.placed_on)), (1, (612.0, 792.0))) + + def test_toggle_all_pages_ignored_for_vignette_and_anchor(self): + app = self.app_at_place("beid") + app._toggle_all_pages() # the vignette is selected + self.assertEqual(str(app.all_pages_chk.cget("state")), "disabled") + self.assertEqual(app.all_pages_chk.get(), 0) + app.destroy() + short = make_pdf(self.tmp / "short.pdf", [(595, 842)]) + app = self.app_at_place("image", inputs=[short]) + sig = self.add_text(app) + self.assertEqual(app._page_anchor(), "last") + app._toggle_all_pages() # every element goes on the anchor page + self.assertFalse(sig.stamp.all_pages) + self.assertEqual(str(app.all_pages_chk.cget("state")), "disabled") + + # ------------------------------------------------- selection and placing + def test_click_places_only_the_selected_element(self): + app = self.app_at_place("beid") + a = self.add_text(app, "A") + b = self.add_text(app, "B") + app._select(a.id) + self.click(app, 0.3, 0.3) + self.assertTrue(app._sig_placed(a)) + self.assertFalse(app._sig_placed(b)) + self.assertIsNone(app.profile.settings.vignette) + self.assertEqual((a.stamp.page, a.placed_on), (1, (595.0, 842.0))) + self.assertEqual(app.page_text, "1") + app._select(self.gui.VIGNETTE_ID) + self.click(app, 0.6, 0.6) + vignette = app.profile.settings.vignette + self.assertEqual((vignette.page, vignette.placed_on), (1, (595.0, 842.0))) + self.assertFalse(app._sig_placed(b)) + saved = self.saved(app) + self.assertIn("vignette", saved["settings"]) + self.assertIn("x", saved["signatures"][0]) + self.assertNotIn("x", saved["signatures"][1]) + # "Reset position" also acts on the selected element only + app.reset_btn.invoke() + self.assertIsNone(app.profile.settings.vignette) + self.assertEqual(app.page_text, "") + self.assertTrue(app._sig_placed(a)) + app._select(a.id) + app._reset_selected_position() + self.assertFalse(app._sig_placed(a)) + self.assertIsNone(a.placed_on) + + def test_disabled_selected_signature_is_outlined(self): + app = self.app_at_place() + a = self.add_text(app, "A") + b = self.add_text(app, "B") + app._toggle_enabled(a.id) # untick A, select it, click + app._select(a.id) + self.click(app) + self.assertIsNotNone(a.stamp.x) # the position is stored + self.assertEqual(self.items(app, a.id, "image"), []) # outline only + (rect,) = self.items(app, a.id, "rectangle") + self.assertEqual(app.canvas.itemcget(rect, "outline"), "#777") + self.assertTrue(app.canvas.itemcget(rect, "dash")) + app._select(b.id) # no longer selected: not drawn + self.assertEqual(self.items(app, a.id), []) + + def test_page_field_follows_selection(self): + app = self.app_at_place() + a = self.add_text(app, "A") + b = self.add_text(app, "B") + app._select(a.id) + app._turn_page(1) + self.click(app) + self.assertEqual((a.stamp.page, app.page_text), (2, "2")) + app._turn_page(-1) + app._select(b.id) # unplaced: no page + self.assertEqual((app.page_text, app.page_entry.get()), ("", "")) + app._select(a.id) # back on its own page + self.assertEqual((app.page_text, app.cur_page, app.page_entry.get()), + ("2", 1, "2")) + app._delete_signature(a.id) # the field must not keep "2" + self.assertEqual(app.page_text, "") + # a new, shorter template: a stale page number would block the step + one_page = make_pdf(self.tmp / "one.pdf", [(595, 842)]) + app.page_text = "2" + with mock.patch.object(self.gui.filedialog, "askopenfilename", + return_value=str(one_page)): + app._pick_template() + self.assertEqual(app.page_text, "") + self.assertIsNone(app._page_entry_error()) + + def test_page_entry_retargets_selected_element(self): + app = self.app_at_place("beid") + a = self.add_text(app, "A") + self.click(app) # A on page 1 + self.fill(app.page_entry, "2") + app._on_page_entry_change() + self.assertEqual((a.stamp.page, app.cur_page), (2, 1)) + self.assertEqual(self.saved(app)["signatures"][0]["page"], 2) + app._select(self.gui.VIGNETTE_ID) + self.click(app) # vignette on the previewed page 2 + self.assertEqual(app.profile.settings.vignette.page, 2) + self.fill(app.page_entry, "1") + app._on_page_entry_change() + self.assertEqual(app.profile.settings.vignette.page, 1) + self.assertEqual(a.stamp.page, 2) # only the selected element moved + + def test_page_entry_retarget_follows_the_page_size(self): + # pages of different sizes: an element moved through the page field + # is "placed on" its NEW page, so the placement stays in force + self.tpl = make_pdf(self.tmp / "mixed.pdf", [(595, 842), (842, 595)]) + app = self.app_at_place("beid") + a = self.add_text(app, "A") + self.click(app) # A on page 1 (portrait) + self.assertEqual(tuple(a.placed_on), (595.0, 842.0)) + self.fill(app.page_entry, "2") + app._on_page_entry_change() + self.assertEqual((a.stamp.page, tuple(a.placed_on)), (2, (842.0, 595.0))) + self.assertTrue(app._sig_placed(a)) + self.assertIsNone(app._place_error()) + app._select(self.gui.VIGNETTE_ID) + self.click(app) # vignette on page 2 (landscape) + self.fill(app.page_entry, "1") + app._on_page_entry_change() + vignette = app.profile.settings.vignette + self.assertEqual((vignette.page, tuple(vignette.placed_on)), (1, (595.0, 842.0))) + self.assertTrue(app._vignette_placed()) + + def test_page_beyond_template_blocks(self): + app = self.app_at_place("beid") + self.assertIsNone(app._place_error()) + self.fill(app.page_entry, "7") + app._on_page_entry_change() + self.assertEqual(app._place_error(), + self.tr("place.page_beyond", page=7, total=2)) + self.assertEqual(str(app._btn_next.cget("state")), "disabled") # blocking + self.assertEqual(app.place_warn_lbl.cget("text"), app._place_error()) + # "²" (one key on an AZERTY keyboard) is a digit for str.isdigit() + # but not a number for int(): invalid, not a ValueError in every + # navigation callback; so is a pasted number too long for int() + for bad in ("x", "²", "1²", "9" * 5000): + with self.subTest(page=bad): + self.fill(app.page_entry, bad) + app._on_page_entry_change() + self.assertEqual(app._place_error(), self.tr("place.page_invalid")) + app._refresh_chrome() + self.assertEqual(str(app._btn_next.cget("state")), "disabled") + self.fill(app.page_entry, "") + app._on_page_entry_change() + self.assertIsNone(app._place_error()) + self.assertEqual(str(app._btn_next.cget("state")), "normal") + + # ----------------------------------------------------------------- layout + def test_empty_list_has_hint_and_no_gap(self): + import customtkinter as ctk + app = self.app_at_place() # image mode, empty library + + def assert_only_the_hint(): + app.update() + (hint,) = app.elements_list.winfo_children() + self.assertIsInstance(hint, ctk.CTkLabel) + self.assertEqual(hint.cget("text"), self.tr("place.list_empty")) + # an empty CTkFrame would keep its 200×200 default size + self.assertLess(app.elements_list.winfo_reqheight(), 80) + + assert_only_the_hint() + sig = self.add_text(app) + app.update() + app._delete_signature(sig.id) + assert_only_the_hint() + self.assertEqual(app.pos_lbl.cget("text"), self.tr("place.select_hint")) + self.click(app) # nothing to place: no crash + self.assertEqual(app.pos_lbl.cget("text"), self.tr("place.select_hint")) + + def test_long_label_does_not_widen_panel(self): + app = self.app_at_place("beid") + sig = self.add_text(app, label="W" * 60) + app.update() + self.assertLessEqual(app.elements_panel.winfo_reqwidth(), + self.gui._ELEMENTS_PANEL_W + 12) + shown = app._element_labels[sig.id][0].cget("text") + self.assertTrue(shown.endswith("…") and len(shown) < 60, shown) + self.assertEqual(sig.label, "W" * 60) # only the display is elided + + def test_layout_fits_default_window(self): + # the layout contract at the default window size, in the two + # languages with the longest step-6 labels + for lang in ("pt", "nl"): + with self.subTest(lang=lang): + self.i18n.set_language(lang) + app = self.make_app() + app.geometry("1180x950") + app.update() + self.wizard_with_state(app, self.tpl, self.out) + app.mode_var.set("beid") + self.add_text(app, "A") + self.add_text(app, "B") + app._goto_step(5) + self.pump(app, rounds=5) + self.assertEqual(app.winfo_width(), 1180) + self.assertLessEqual(app.elements_panel.winfo_reqwidth(), + self.gui._ELEMENTS_PANEL_W + 12) + visible_right = (app._content_left.winfo_rootx() + + app._content_left.winfo_width()) + for widget in (app.page_next_btn, app.canvas): + self.assertLessEqual( + widget.winfo_rootx() + widget.winfo_width(), visible_right, + widget) + app.destroy() + + # ---------------------------------------------------------------- preview + def test_content_failure_never_reaches_chrome(self): + app = self.app_at_place() + with mock.patch.object(self.gui.stamplib, "stamp_content", + side_effect=MemoryError()) as spy: + sig = self.add_text(app) + app._refresh_chrome() # none of these may raise + app._goto_step(4) + app._goto_step(5) + app._refresh_chrome() + self.click(app) + self.assertEqual(app._place_error(), self.tr( + "place.el_broken", name="Jane", error="MemoryError")) + self.assertEqual(spy.call_count, 1) # never rendered twice + self.assertEqual(app._element_labels[sig.id][1].cget("text"), + self.tr("place.st_error")) + self.assertEqual(len(self.items(app, sig.id, "text")), 1) # the ⚠ sign + self.assertEqual(self.items(app, sig.id, "image"), []) + + def test_all_elements_drawn_with_live_image_refs(self): + app = self.app_at_place() + a = self.add_text(app, "A") + self.click(app, 0.3, 0.3) + b = app._add_image_signature(make_png(self.tmp / "s.png")) + self.click(app, 0.6, 0.6) + + def element_images(): + return [i for i in app.canvas.find_withtag("element") + if app.canvas.type(i) == "image"] + + self.assertEqual(len(app._canvas_imgs), 2) + self.assertEqual(len(element_images()), 2) + # the selected element is drawn last (on top), in red; the other in blue + (rect_a,) = self.items(app, a.id, "rectangle") + (rect_b,) = self.items(app, b.id, "rectangle") + self.assertEqual(app.canvas.find_withtag("element")[-1], rect_b) + self.assertEqual(app.canvas.itemcget(rect_b, "outline"), "#c00") + self.assertEqual(app.canvas.itemcget(rect_a, "outline"), "#3B8ED0") + app.geometry("1500x1000") # a resize redraws everything + self.pump(app, rounds=4) + self.assertEqual(len(app._canvas_imgs), 2) + images = element_images() + self.assertEqual(len(images), 2) + for item in images: # a freed PhotoImage has no size + x0, _y0, x1, _y1 = app.canvas.bbox(item) + self.assertGreater(x1 - x0, 3) + + def test_scaled_preview_cached_across_redraws(self): + app = self.app_at_place() + self.add_text(app, "A") + self.click(app, 0.3, 0.3) + app._add_image_signature(make_png(self.tmp / "s.png")) + self.click(app, 0.6, 0.6) + app._preview_cache.clear() + app._scaled_cache.clear() + with mock.patch.object(self.gui.stamplib, "stamp_content", + wraps=stamplib.stamp_content) as spy: + for _ in range(4): + app._draw_page() + self.assertEqual(spy.call_count, 2) # once per signature + scaled = dict(app._scaled_cache) + self.assertEqual(len(scaled), 2) + app._draw_page() + for key, bitmap in scaled.items(): # the very same bitmaps are reused + self.assertIs(app._scaled_cache[key], bitmap) + app.geometry("1500x1000") # a resize rescales the cached + self.pump(app, rounds=4) # content, it never re-renders + app._refresh_chrome() + self.assertEqual(spy.call_count, 2) + self.assertGreater(len(app._scaled_cache), 2) + + def test_all_pages_shown_on_every_preview_page(self): + app = self.app_at_place() + sig = self.add_text(app) + app._toggle_all_pages() + self.click(app) + self.assertEqual(len(self.items(app, sig.id, "image")), 1) + app._turn_page(1) + self.assertEqual(len(self.items(app, sig.id, "image")), 1) + self.assertEqual(app._element_labels[sig.id][1].cget("text"), + self.tr("place.st_all")) + self.assertEqual(app.pos_lbl.cget("text"), self.tr( + "place.pos_all", x=f"{sig.stamp.x:.0f}", y=f"{sig.stamp.y:.0f}")) + app._toggle_all_pages() # now on page 2 only + app._turn_page(-1) + self.assertEqual(self.items(app, sig.id), []) + + def test_default_vignette_drawn_on_last_page_only(self): + vignette = self.gui.VIGNETTE_ID + app = self.app_at_place("beid") + self.assertEqual(self.items(app, vignette), []) # page 1 of 2 + app._turn_page(1) + (rect,) = self.items(app, vignette, "rectangle") + self.assertTrue(app.canvas.itemcget(rect, "dash")) # dashed: default + x0, y0, _x1, _y1 = app.canvas.coords(rect) + fw, fh, ox, oy = app._frame_geom + self.assertGreater(x0, ox + fw / 2) # bottom-right corner + self.assertGreater(y0, oy + fh / 2) + self.click(app, 0.2, 0.2) # placed on page 2 + (rect,) = self.items(app, vignette, "rectangle") + self.assertFalse(app.canvas.itemcget(rect, "dash")) # solid: placed + self.assertEqual(app._element_labels[vignette][1].cget("text"), + self.tr("place.st_page", page=2)) + app._turn_page(-1) + self.assertEqual(self.items(app, vignette), []) + + # ------------------------------------------------- predicates and the run + def test_step_completion_rules(self): + app = self.app_at_place() # image mode + self.assertEqual(app._place_error(), self.tr("place.need_signature")) + sig = self.add_text(app) + self.assertEqual(app._place_error(), self.tr("place.unplaced", name="Jane")) + self.assertFalse(app._step_complete(5)) + app._toggle_enabled(sig.id) # disabled: ignored, so none is left + self.assertEqual(app._place_error(), self.tr("place.need_signature")) + app._toggle_enabled(sig.id) + self.click(app) + self.assertIsNone(app._place_error()) + self.assertTrue(app._step_complete(5)) + app.mode_var.set("beid") # beid: complete without any signature + app._toggle_enabled(sig.id) + self.assertIsNone(app._place_error()) + app._delete_signature(sig.id) + self.assertIsNone(app._place_error()) + + def test_anchor_lock_forces_every_element(self): + short = make_pdf(self.tmp / "short.pdf", [(595, 842)]) # 1 page vs 2 + app = self.app_at_place("image", inputs=[short]) + self.assertEqual(app._page_anchor(), "last") + a = self.add_text(app, "A") + a.stamp = dataclasses.replace(a.stamp, page=1, x=10.0, y=10.0) + a.placed_on = app.template_dims[0] + b = self.add_text(app, "B", all_pages=True, x=5.0, y=5.0) + b.placed_on = app.template_dims[0] + app._refresh_place() + self.assertFalse(app._sig_placed(a)) # page 1, but the anchor is "last" + self.assertTrue(app._sig_placed(b)) # forced onto the anchor page + self.assertEqual(app._element_labels[a.id][1].cget("text"), + self.tr("place.st_unplaced")) + self.assertEqual(app._element_labels[b.id][1].cget("text"), + self.tr("anchor.last_page")) + self.assertEqual(str(app.all_pages_chk.cget("state")), "disabled") + self.assertEqual(str(app.page_entry.cget("state")), "disabled") + app._set_page_anchor_choice("first") # nothing was lost + app.update() + self.assertTrue(app._sig_placed(a)) + app._set_page_anchor_choice("last") + app.update() + app._select(a.id) + self.click(app) # places it on the locked page + self.assertTrue(app._sig_placed(a)) + self.assertEqual((a.stamp.page, app.page_text), (2, "")) + self.assertTrue(b.stamp.all_pages) + app._goto_step(6) + app.update() + self.assertEqual(app.step_index, 6) + cfg = self.launch_capturing_config(app) + self.assertEqual(len(cfg.stamps), 2) + for stamp in cfg.stamps: # resolved per document + self.assertEqual((stamp.page_anchor, stamp.page, stamp.all_pages), + ("last", None, False)) + self.assertEqual((cfg.page, cfg.page_anchor, cfg.x, cfg.y), + (None, None, None, None)) # image mode: no vignette + self.assertTrue(b.stamp.all_pages) # the library itself is untouched + + def test_run_summary_lists_elements(self): + import customtkinter as ctk + app = self.app_at_place("beid") + a = self.add_text(app, "A", label="Alpha") + self.click(app) + b = self.add_text(app, "B", label="Beta") + app._toggle_all_pages() + self.click(app) + c = self.add_text(app, "C", label="Gamma") + app._toggle_enabled(c.id) # unticked: not part of the run + app._goto_step(6) + app.update() + self.assertEqual(app.step_index, 6) + + def label_texts(widget): + for child in widget.winfo_children(): + if isinstance(child, ctk.CTkLabel): + yield child.cget("text") + yield from label_texts(child) + + summary = next(t for t in label_texts(app._content_left) + if self.tr("run.summary_stamps", count=2) in t) + self.assertIn(self.tr("run.summary_place", place=self.tr("run.place_default")), + summary) + self.assertIn(self.tr("run.stamp_line", name="Alpha", place=self.tr( + "run.place_custom", page=1, x=f"{a.stamp.x:.0f}", y=f"{a.stamp.y:.0f}")), + summary) + self.assertIn(self.tr("run.stamp_line", name="Beta", place=self.tr( + "run.place_all", x=f"{b.stamp.x:.0f}", y=f"{b.stamp.y:.0f}")), summary) + self.assertNotIn("Gamma", summary) + + def test_end_to_end_two_elements_image_mode(self): + app = self.app_at_place() + self.add_text(app, "Jane Doe\n{date} {filename}") + self.click(app, 0.3, 0.3) # page 1 + app._add_image_signature(make_png(self.tmp / "s.png")) + app._toggle_all_pages() + self.click(app, 0.6, 0.6) # every page + app._goto_step(6) + app._launch() # the real worker thread + self.assertTrue(app._running) + self.pump(app, lambda: not app._running, rounds=160) + self.assertIsNone(app.run_error) + (result,) = app.run_results + self.assertTrue(result.ok, result.detail) + self.assertIn("2 visual signature(s) applied", result.detail) + self.assertEqual(app.step_index, 7) # auto-advanced to the report + self.assertEqual(xobject_counts(self.out / "t_signe.pdf"), [2, 1]) + # The report never truncates the detail: its column is at least as + # wide as the text and takes the width the other two leave, whatever + # the window size (a scrollbar reaches what the window cannot show). + from tkinter import font as tkfont, ttk + table = app.summary_table + doc, status, detail = table["columns"] + text_px = tkfont.Font(font=ttk.Style().lookup("Treeview", "font")).measure( + result.detail) + self.assertGreater(text_px, 360) # wider than the column used to be + for geometry in ("1180x950", "1700x950"): + app.geometry(geometry) + self.pump(app, rounds=5) + widths = [table.column(c, "width") for c in (doc, status, detail)] + self.assertEqual(widths[:2], [240, 130], geometry) + self.assertGreaterEqual(widths[2], text_px, geometry) + self.assertGreaterEqual(sum(widths) + 4, table.winfo_width(), geometry) + if sum(widths) > table.winfo_width(): # e.g. at the default size + self.assertLess(table.xview()[1], 1, geometry) + + def test_beid_launch_passes_stamps_and_vignette(self): + app = self.app_at_place("beid") + a = self.add_text(app, "A") + self.click(app, 0.3, 0.3) + b = self.add_text(app, "B") + app._toggle_enabled(b.id) # unticked: left out + app._select(self.gui.VIGNETTE_ID) + app._turn_page(1) + self.click(app, 0.5, 0.5) # vignette on page 2 + app._goto_step(6) + app.update() + cfg = self.launch_capturing_config(app) + self.assertEqual(cfg.mode, "beid") + self.assertEqual(cfg.stamps, [a.stamp]) + vignette = app.profile.settings.vignette + # the legacy fields carry the VIGNETTE placement only + self.assertEqual((cfg.page, cfg.x, cfg.y, cfg.page_anchor), + (2, vignette.x, vignette.y, None)) + self.assertIsNone(cfg.image_path) + + def test_beid_anchored_launch_targets_the_anchor_page(self): + # a short file accepted at the validation step: the batch must get + # the SAME anchor for the vignette and every signature, or it would + # reject at run time what the wizard showed as accepted + short = make_pdf(self.tmp / "short.pdf", [(595, 842)]) # 1 page vs 2 + app = self.app_at_place("beid", inputs=[short]) + self.assertEqual(app._page_anchor(), "last") + self.add_text(app, "A") + self.click(app, 0.3, 0.3) + app._select(self.gui.VIGNETTE_ID) + self.click(app, 0.5, 0.5) + app._goto_step(6) + app.update() + self.assertEqual(app.step_index, 6) + cfg = self.launch_capturing_config(app) + vignette = app.profile.settings.vignette + self.assertIsNotNone(vignette.x) + self.assertEqual((cfg.page, cfg.page_anchor, cfg.x, cfg.y), + (None, "last", vignette.x, vignette.y)) + self.assertEqual([(s.page_anchor, s.page, s.all_pages) for s in cfg.stamps], + [("last", None, False)]) + self.assertEqual(core.anchor_requirements(cfg), (("last",), None)) + self.assertEqual(cfg.inputs, [short]) # accepted by the wizard too + + def test_language_switch_closes_dialog_and_keeps_library(self): + app = self.app_at_place("beid") + sig = self.add_text(app, "A", label="Alpha") + self.click(app) + app._open_text_dialog() # its texts are language-bound + app.update() + dialog = app._dialog + app._on_wizard_language_change("Français") + app.update() + self.assertFalse(dialog.winfo_exists()) + self.assertIsNone(app._dialog) + self.assertEqual(self.i18n.get_language(), "fr") + self.assertEqual(app.step_index, 5) + self.assertEqual(app.selected_id, sig.id) + self.assertEqual(app._element_labels[sig.id][0].cget("text"), "Alpha") # user data + self.assertEqual(app.add_text_btn.cget("text"), "Ajouter un texte…") + self.assertTrue(app._sig_placed(sig)) + self.assertEqual(self.saved(app)["settings"]["language"], "fr") + # leaving the wizard closes it too + app._open_text_dialog() + dialog = app._dialog + app._finish() + self.assertFalse(dialog.winfo_exists()) + self.assertEqual([s.label for s in app.profile.signatures], ["Alpha"]) + + +class GuiPersistence(_GuiEditorBase): + """The user profile behind the GUI: library, placements and wizard + settings are saved on every change and reloaded at start; Cancel and + Finish keep them; nothing here ever touches the real user folders.""" + + def test_profile_lives_in_the_temp_dirs(self): + app = self.make_app() + self.assertTrue(str(app.store.profile_path).startswith(str(self.tmp))) + self.assertTrue(str(app.store.signatures_dir).startswith(str(self.tmp))) + + def test_library_and_settings_survive_restart(self): + app = self.app_at_place() # image mode + app.pades_level_var.set("b-b") + sig = self.add_text(app, color="#FF0000") + self.click(app) + stamp = sig.stamp + app.destroy() + app = self.make_app() # "restart" on the same folders + self.assertEqual((app.mode_var.get(), app.pades_level_var.get()), + ("image", "b-b")) + (restored,) = app.profile.signatures + self.assertEqual((restored.id, restored.label, restored.stamp, restored.enabled), + (sig.id, "Jane", stamp, True)) + # same template: placed, and step 6 is complete without a click + self.wizard_with_state(app, self.tpl, self.out) + self.assertEqual(app.mode_var.get(), "image") # (Start reloads, never wipes) + self.assertTrue(app._sig_placed(app.profile.signatures[0])) + self.assertTrue(app._step_complete(5)) + app._goto_step(6) + app.update() + self.assertEqual(app.step_index, 6) + + def test_restored_placement_shown_on_its_page(self): + app = self.app_at_place() + self.add_text(app) + app._turn_page(1) + self.click(app) # placed on page 2 of 2 + app.destroy() + app = self.make_app() + self.wizard_with_state(app, self.tpl, self.out) + app._goto_step(5) + app.update() + self.assertEqual((app.cur_page, app.page_text, app.page_entry.get()), + (1, "2", "2")) + self.assertIn("2/2", app.page_lbl.cget("text")) + + def test_placement_not_reapplied_on_other_page_size(self): + app = self.app_at_place() + sig = self.add_text(app) + self.click(app) + other = make_pdf(self.tmp / "other.pdf", [(300, 300)]) + with mock.patch.object(self.gui.filedialog, "askopenfilename", + return_value=str(other)): + app._pick_template() + self.assertFalse(app._sig_placed(sig)) # not in force on that page size + self.assertIsNotNone(sig.stamp.x) # ... but nothing was destroyed + self.assertEqual(app._element_status(sig.id), self.tr("place.st_unplaced")) + with mock.patch.object(self.gui.filedialog, "askopenfilename", + return_value=str(self.tpl)): + app._pick_template() + self.assertTrue(app._sig_placed(sig)) # back with the original template + + def test_vignette_placement_survives_restart(self): + app = self.app_at_place("beid") + app._turn_page(1) + self.click(app, 0.4, 0.4) + placed = dataclasses.replace(app.profile.settings.vignette) + app.destroy() + app = self.make_app() + self.wizard_with_state(app, self.tpl, self.out) + self.assertEqual(app.profile.settings.vignette, placed) + self.assertTrue(app._vignette_placed()) + app._goto_step(5) + app.update() + self.assertEqual((app.cur_page, app.page_text), (1, "2")) + + def test_cancel_and_finish_keep_library(self): + app = self.app_at_place() + self.add_text(app) + self.click(app) + app._cancel_wizard() + app.update() + self.modal_button(app, self.tr("cancel.confirm")).invoke() + app.update() + self.assertIsNone(app.template_path) # the session is reset ... + self.assertEqual(app._stepper_btns, []) + self.assertEqual([s.label for s in app.profile.signatures], ["Jane"]) # ... not the library + self.assertEqual(app.mode_var.get(), "image") + self.wizard_with_state(app, self.tpl, self.out) + self.assertTrue(app._sig_placed(app.profile.signatures[0])) + app._finish() + app.update() + self.assertIsNone(app.template_path) + self.assertEqual([s.label for s in app.profile.signatures], ["Jane"]) + self.assertEqual(len(self.saved(app)["signatures"]), 1) + + def test_reset_does_not_overwrite_saved_settings(self): + # _reset_state sets the tk variables, whose traces save the settings: + # it must not write the defaults back over the user's choices + import profile_store + app = self.make_app() + app._start_wizard() + app.mode_var.set("azure") + app.pades_level_var.set("b-t") + app.azure_auth_var.set("device-code") + app.destroy() + app = self.make_app() + app._start_wizard() + app._start_wizard() + app.update() + settings = self.saved(app)["settings"] + self.assertEqual( + (settings["mode"], settings["pades_level"], settings["azure_auth"]), + ("azure", "b-t", "device-code")) + self.assertEqual((app.mode_var.get(), app.azure_auth_var.get()), + ("azure", "device-code")) + # The variables are set one after the other: a trace that saved in + # between would mix the file's values with the previous session's. + # (Here the file changed behind the app's back, as a second window + # would do.) + store = profile_store.ProfileStore() # the same temp folders + profile = store.load() + profile.settings.mode = "image" + profile.settings.pades_level = "b-b" + profile.settings.azure_auth = "interactive" + store.save(profile) + on_disk = store.profile_path.read_bytes() + app._start_wizard() + app.update() + self.assertEqual(store.profile_path.read_bytes(), on_disk) + self.assertEqual( + (app.mode_var.get(), app.pades_level_var.get(), app.azure_auth_var.get()), + ("image", "b-b", "interactive")) + + def azure_step(self, app): + """Park `app` on step 5 in azure mode (the vault entry exists).""" + self.wizard_with_state(app, self.tpl, self.out) + app.mode_var.set("azure") + app._goto_step(4) + app.update() + + def test_vault_precedence(self): + # saved user choice > CACHET_AZURE_VAULT_URL > built-in default + with mock.patch.dict(os.environ): + os.environ.pop(core.ENV_AZURE_VAULT_URL, None) + app = self.make_app() + self.assertEqual(app.azure_vault, "https://login.live.com") + app.destroy() + os.environ[core.ENV_AZURE_VAULT_URL] = "https://env.vault.azure.net" + app = self.make_app() + self.assertEqual(app.azure_vault, "https://env.vault.azure.net") + self.azure_step(app) + self.fill(app.azure_vault_entry, "https://mine.vault.azure.net") + app._on_azure_entry_edit() # a real edit is persisted + self.assertEqual(self.saved(app)["settings"]["azure_vault_url"], + "https://mine.vault.azure.net") + app.destroy() + app = self.make_app() # ... and wins over the env + self.assertEqual(app.azure_vault, "https://mine.vault.azure.net") + + def test_focus_out_does_not_persist_env_vault(self): + with mock.patch.dict(os.environ, + {core.ENV_AZURE_VAULT_URL: "https://env.vault.azure.net"}): + app = self.make_app() + self.azure_step(app) + self.assertEqual(app.azure_vault_entry.get(), "https://env.vault.azure.net") + app._on_azure_entry_edit() # what a mere calls + self.assertNotIn("azure_vault_url", self.saved(app)["settings"]) + # the key-name override is never persisted + self.fill(app.azure_key_entry, "sig-override") + app._on_azure_entry_edit() + self.assertEqual(app.azure_key, "sig-override") + on_disk = app.store.profile_path.read_text(encoding="utf-8") + self.assertNotIn("sig-override", on_disk) + self.assertNotIn("env.vault", on_disk) + + def test_output_dir_restored_only_if_it_exists(self): + app = self.make_app() + out = self.tmp / "signed" + out.mkdir() + with mock.patch.object(self.gui.filedialog, "askdirectory", + return_value=str(out)): + app._pick_output() + with mock.patch.object(self.gui.filedialog, "askopenfilename", + return_value=str(self.tpl)): + app._pick_azure_anchors() # (any existing file will do) + app.destroy() + app = self.make_app() + self.assertEqual(app.output_dir, out) + self.assertEqual(app.azure_anchors_path, self.tpl) + app.destroy() + out.rmdir() # the folder is gone since + app = self.make_app() + self.assertIsNone(app.output_dir) + self.assertEqual(self.saved(app)["settings"]["output_dir"], str(out)) # kept + app.destroy() + # A saved path the OS refuses to even probe (here an over-long name; + # likewise an unreachable share or a permission problem — pathlib + # raises for those) is ignored too: the app must still start. + unprobeable = str(self.tmp / ("a" * 300) / "signed") + profile = self.saved(app) + profile["settings"].update(output_dir=unprobeable, azure_trust_anchors=unprobeable) + app.store.profile_path.write_text(json.dumps(profile), encoding="utf-8") + with mock.patch.dict(os.environ): + os.environ.pop(core.ENV_AZURE_TRUST_ANCHORS, None) + app = self.make_app() + self.assertIsNone(app.output_dir) + self.assertIsNone(app.azure_anchors_path) + + def test_language_persisted_and_applied_by_launch_gui(self): + app = self.make_app() + app._on_language_change("Deutsch") + app.update() + self.assertEqual(self.saved(app)["settings"]["language"], "de") + app.destroy() + self.i18n.set_language("en") + stores = [] + + class StubApp: + def __init__(self, args, store=None): + stores.append(store) + + def mainloop(self): + pass + + with mock.patch.object(self.gui, "CachetApp", StubApp), \ + mock.patch.object(self.gui.ctk, "set_appearance_mode"): + self.assertEqual(self.gui.launch_gui(self.types.SimpleNamespace(lib=None)), 0) + self.assertEqual(self.i18n.get_language(), "de") # persisted > system + self.assertTrue(str(stores[0].profile_path).startswith(str(self.tmp))) + + def test_app_init_does_not_change_language(self): + # only launch_gui applies the saved language: tests (and embedders) + # build apps directly and rely on the current one + app = self.make_app() + app._on_language_change("Deutsch") + app.destroy() + self.i18n.set_language("en") + self.make_app() + self.assertEqual(self.i18n.get_language(), "en") + + def test_save_failure_is_non_fatal(self): + app = self.app_at_place() + with mock.patch.object(app.store, "save", side_effect=OSError("read-only")): + sig = self.add_text(app) # must not raise + self.click(app) + self.assertEqual(app._profile_error, "read-only") + self.assertTrue(app._sig_placed(sig)) # the session goes on + self.assertEqual(app.place_warn_lbl.cget("text"), + self.tr("place.save_failed", error="read-only")) + app._save_profile() # the folder is writable again + app._update_place_labels() + self.assertIsNone(app._profile_error) + self.assertEqual(app.place_warn_lbl.cget("text"), "") + self.assertEqual(len(self.saved(app)["signatures"]), 1) + + def test_corrupt_profile_starts_clean(self): + cfg = Path(os.environ["CACHET_CONFIG_DIR"]) + cfg.mkdir(parents=True) + (cfg / "profile.json").write_bytes(b"\x00\xff garbage {") + app = self.make_app() # must start + self.assertEqual(app.profile.signatures, []) + self.assertEqual((cfg / "profile.json.bak").read_bytes(), b"\x00\xff garbage {") + app._start_wizard() + app.update() + self.assertEqual(app.step_index, 0) + + class HeadlessImport(unittest.TestCase): def test_core_imports_without_tkinter(self): code = "import sign_pdfs_beid, sys; print('tkinter' in sys.modules)" @@ -1475,6 +4186,25 @@ def test_core_imports_without_tkinter(self): self.assertEqual(out.returncode, 0, out.stderr) self.assertEqual(out.stdout.strip(), "False") + def test_core_does_not_import_profile_store(self): + # The CLI never reads the persisted GUI profile, not even implicitly. + code = ("import sign_pdfs_beid, sys; " + "print('profile_store' in sys.modules, 'stamps' in sys.modules)") + out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, + cwd=str(Path(__file__).parent)) + self.assertEqual(out.returncode, 0, out.stderr) + self.assertEqual(out.stdout.strip(), "False True") + + def test_new_modules_import_without_tkinter(self): + # stamps.py and profile_store.py serve the CLI binary too (no Tk). + code = ("import stamps, profile_store, sys; " + "print(sorted({'tkinter', 'customtkinter', 'gui', 'i18n'} " + "& set(sys.modules)))") + out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, + cwd=str(Path(__file__).parent)) + self.assertEqual(out.returncode, 0, out.stderr) + self.assertEqual(out.stdout.strip(), "[]") + if __name__ == "__main__": unittest.main() diff --git a/test_stamps.py b/test_stamps.py new file mode 100644 index 0000000..46a6e7a --- /dev/null +++ b/test_stamps.py @@ -0,0 +1,1218 @@ +#!/usr/bin/env python3 +"""Headless tests for stamps.py (visual signatures: text, images, strokes). + +No card, no network, no display. Text sizes are never asserted pixel-exact +(font hinting differs between FreeType builds): ratios, bounding boxes and +colours only. +Run with: ./venv/bin/python -m unittest -v test_stamps +""" + +import dataclasses +import datetime +import gc +import io +import json +import re +import shutil +import struct +import subprocess +import sys +import unittest +import warnings +from pathlib import Path +from unittest import mock + +from PIL import Image, ImageFont + +from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter +from pyhanko.pdf_utils.reader import PdfFileReader +from pyhanko.sign.fields import SigFieldSpec, append_signature_field, enumerate_sig_fields + +import sign_pdfs_beid as core +import stamps +from stamps import Stamp, StampError +from test_sign_pdfs_beid import TmpCase, make_pdf, make_png, pdf_stream, write_pdf_objects + +DAY = datetime.date(2026, 10, 7) +INK = (26, 43, 140) # stamps.DEFAULT_COLOR + + +def strip_page_contents(pdf_path, page_index) -> Path: + """Writes a sibling of `pdf_path` whose page `page_index` (0-based) has no + /Contents entry — the "blank page" some producers emit (/Contents is + optional in PDF). Returns the new path.""" + src = Path(pdf_path) + with src.open("rb") as inf: + writer = IncrementalPdfFileWriter(inf, strict=False) + page_ref, _ = writer.find_page_for_modification(page_index) + del page_ref.get_object()["/Contents"] + writer.mark_update(page_ref) + out = io.BytesIO() + writer.write(out) + dst = src.with_name(f"{src.stem}_nocontents.pdf") + dst.write_bytes(out.getvalue()) + return dst + + +def damaged_font(path) -> Path: + """Writes a copy of a bundled font whose `glyf` table is overwritten with + 0xFF: FreeType (2.13) still OPENS it, then fails on the first glyph it has + to load (every glyph is an invalid composite). Returns the path.""" + data = bytearray(stamps.resolve_font("lato").read_bytes()) + for i in range(struct.unpack(">H", data[4:6])[0]): # table directory + tag, _, offset, length = struct.unpack(">4sIII", data[12 + 16 * i:28 + 16 * i]) + if tag == b"glyf": + data[offset:offset + length] = b"\xff" * length + Path(path).write_bytes(bytes(data)) + return Path(path) + + +def damaged_png(path) -> Path: + """Writes a PNG whose IDAT chunk declares a length of 0: Pillow identifies + it, then raises a SyntaxError (not an OSError) while decoding.""" + data = bytearray(make_png(path).read_bytes()) + pos = data.index(b"IDAT") + data[pos - 4:pos] = struct.pack(">I", 0) + Path(path).write_bytes(bytes(data)) + return Path(path) + + +def pdf_pages(pdf_path) -> list: + """Page dictionaries of a (flat page tree) PDF made by make_pdf.""" + reader = PdfFileReader(io.BytesIO(Path(pdf_path).read_bytes()), strict=False) + return [kid.get_object() for kid in reader.root["/Pages"]["/Kids"]] + + +def page_xobjects(page) -> dict: + """{resource name: object id} of the XObjects of a page dictionary.""" + # [] dereferences indirect objects; dict.get() would hand back the reference + if "/Resources" not in page or "/XObject" not in page["/Resources"]: + return {} + xobjects = page["/Resources"]["/XObject"] + return {name: xobjects.raw_get(name).idnum for name in xobjects} + + +def near(pixel, rgb, tol=40) -> bool: + return all(abs(a - b) <= tol for a, b in zip(pixel[:3], rgb)) + + +class StampCase(TmpCase): + """TmpCase + cleanup + a helper that stamps a PDF and returns the output.""" + + def setUp(self): + super().setUp() + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + + def stamped(self, pdf, stamp_list, **kwargs) -> Path: + pdf = Path(pdf) + with pdf.open("rb") as inf: + writer = IncrementalPdfFileWriter(inf, strict=False) + stamps.apply_stamps(writer, stamp_list, **kwargs) + out = io.BytesIO() + writer.write(out) + dst = pdf.with_name(f"{pdf.stem}_out.pdf") + dst.write_bytes(out.getvalue()) + return dst + + +class FontRegistry(StampCase): + def test_six_bundled_fonts_resolve_and_load(self): + self.assertEqual(len(stamps.BUNDLED_FONTS), 6) + self.assertEqual([fid for fid, _ in stamps.font_choices()], list(stamps.BUNDLED_FONTS)) + self.assertIn(stamps.DEFAULT_FONT, stamps.BUNDLED_FONTS) + for font_id, name in stamps.font_choices(): + with self.subTest(font=font_id): + path = stamps.resolve_font(font_id) + self.assertTrue(path.is_file(), path) + self.assertTrue(name) + ImageFont.truetype(str(path), 40) # raises if not a font + img = stamps.render_text_image("Jane Doe", font_id, 24, "#000000") + self.assertGreater(img.width, img.height) + + def test_ids_are_case_insensitive(self): + self.assertEqual(stamps.resolve_font(" Great-Vibes "), stamps.resolve_font("great-vibes")) + self.assertEqual(stamps.resolve_font("LATO"), stamps.resolve_font("lato")) + + def test_unknown_font_raises(self): + with self.assertRaises(StampError) as cm: + stamps.resolve_font("comic-sans") + msg = str(cm.exception) + self.assertTrue(msg.startswith("unknown font 'comic-sans'"), msg) + for font_id in stamps.BUNDLED_FONTS: + self.assertIn(font_id, msg) + with self.assertRaises(StampError): + stamps.resolve_font(str(self.p("missing.ttf"))) + with self.assertRaises(StampError): + stamps.resolve_font("") + + def test_user_font_path_accepted(self): + mine = self.p("mine.ttf") + shutil.copy(stamps.resolve_font("lato"), mine) + self.assertEqual(stamps.resolve_font(str(mine)), mine) + user = stamps.render_text_image("Jane", str(mine), 24, "#000000") + bundled = stamps.render_text_image("Jane", "lato", 24, "#000000") + self.assertEqual(user.size, bundled.size) + + def test_asset_path_honours_meipass(self): + here = Path(stamps.__file__).resolve().parent + self.assertEqual(stamps.asset_path("fonts"), here / "fonts") + with mock.patch.object(sys, "_MEIPASS", str(self.tmp), create=True): + self.assertEqual(stamps.asset_path("fonts"), self.tmp / "fonts") + # the frozen layout is what resolve_font looks at + with self.assertRaises(StampError) as cm: + stamps.resolve_font("lato") + self.assertTrue(str(cm.exception).startswith("bundled font file missing: ")) + (self.tmp / "fonts").mkdir() + shutil.copy(here / "fonts" / "Lato-Regular.ttf", self.tmp / "fonts") + self.assertEqual(stamps.resolve_font("lato"), + self.tmp / "fonts" / "Lato-Regular.ttf") + + def test_licences_ship_with_fonts(self): + licences = stamps.asset_path("fonts") / "licenses" + self.assertEqual(len(list(licences.glob("OFL-*.txt"))), len(stamps.BUNDLED_FONTS)) + for font_id in stamps.BUNDLED_FONTS: + lic = licences / f"OFL-{font_id.replace('-', '')}.txt" + self.assertTrue(lic.is_file(), lic) + self.assertIn("SIL OPEN FONT LICENSE", lic.read_text(encoding="utf-8").upper()) + + +class TextRendering(StampCase): + def render(self, text="Jane Doe", font="great-vibes", size=24, color="#1A2B8C", **kw): + return stamps.render_text_image(text, font, size, color, **kw) + + def test_rgba_tight_crop_flat_colour(self): + img = self.render() + self.assertEqual(img.mode, "RGBA") + # tight on the four sides: the ink reaches every edge + self.assertEqual(img.getchannel("A").getbbox(), (0, 0) + img.size) + # the RGB plane is the flat colour, transparent pixels included + self.assertEqual(img.convert("RGB").getcolors(), [(img.width * img.height, INK)]) + + def test_alpha_is_antialiased(self): + levels = sum(1 for count in self.render().getchannel("A").histogram() if count) + self.assertGreater(levels, 16) + + def test_size_scales_with_font_size(self): + small, big = self.render(size=24), self.render(size=48) + self.assertAlmostEqual(big.height / small.height, 2.0, delta=0.16) + self.assertAlmostEqual(big.width / small.width, 2.0, delta=0.16) + + def test_multiline_is_taller(self): + one, two = self.render("Jane Doe"), self.render("Jane Doe\nJane Doe") + self.assertGreater(two.height, one.height * 1.6) + self.assertAlmostEqual(two.width, one.width, delta=one.width * 0.05) + + def test_blank_text_raises(self): + for text in ("", " ", "\n"): + with self.subTest(text=text): + with self.assertRaises(StampError) as cm: + self.render(text) + self.assertEqual(str(cm.exception), "text is empty") + + def test_bad_colour_raises(self): + for color in ("blue", "#12345", "#GGGGGG", "1A2B8C", "#1A2B8CFF", "", None): + with self.subTest(color=color): + with self.assertRaises(StampError) as cm: + self.render(color=color) + self.assertEqual(str(cm.exception), f"bad color {color!r} (expected #RRGGBB)") + self.assertEqual(self.render(color="#ff0000").convert("RGB").getpixel((0, 0)), + (255, 0, 0)) + + def test_deterministic(self): + a, b = self.render("Jane Doe\n07/10/2026"), self.render("Jane Doe\n07/10/2026") + self.assertEqual(a.size, b.size) + self.assertEqual(a.tobytes(), b.tobytes()) + + def test_dpi_sets_point_size(self): + stamp = Stamp(kind="text", text="Jane Doe", font_size=24) + img, (w_pt, h_pt) = stamps.stamp_content(stamp) + self.assertEqual((w_pt, h_pt), (img.width * 72 / 300, img.height * 72 / 300)) + # a 24 pt text is a few tens of points high, whatever the raster size + self.assertTrue(12 < h_pt < 60, h_pt) + + def test_oversized_text_raises_stamp_error(self): + real_new = Image.new + for text in ("W" * 2000, "a" * 300): + with self.subTest(chars=len(text)): + with mock.patch.object(stamps.Image, "new", side_effect=real_new) as spy: + with self.assertRaises(StampError) as cm: + stamps.render_text_image(text, "lato", 400, "#000000") + self.assertIn("too large", str(cm.exception)) + # refused BEFORE any allocation: only the 1x1 probe was requested + sizes = [tuple(call.args[1]) for call in spy.call_args_list] + self.assertEqual(sizes, [(1, 1)]) + + def test_budget_boundary_typical_sizes_pass(self): + self.assertGreater(self.render("Jane Doe", size=400).height, 1000) + three = self.render("Jane Doe\nRead and approved\n07/10/2026", size=72) + self.assertGreater(three.height, 600) + + def test_validate_stamp_refuses_oversized_text(self): + for placed in (True, False): + with self.subTest(placed=placed): + stamp = Stamp(kind="text", text="W" * 2000, font="lato", font_size=400, + page=1, x=0.0, y=0.0) + real_new = Image.new + with mock.patch.object(stamps.Image, "new", side_effect=real_new) as spy: + with self.assertRaises(StampError) as cm: + stamps.validate_stamp(stamp, placed=placed) + self.assertIn("too large", str(cm.exception)) + self.assertEqual([tuple(c.args[1]) for c in spy.call_args_list], [(1, 1)]) + + def test_validation_never_rasterises(self): + stamp = Stamp(kind="text", text="Jane Doe", page=1, x=0.0, y=0.0) + real_new = Image.new + with mock.patch.object(stamps.Image, "new", side_effect=real_new) as spy: + stamps.validate_stamp(stamp) + self.assertEqual([tuple(c.args[1]) for c in spy.call_args_list], [(1, 1)]) + + def test_damaged_font_raises_stamp_error(self): + # A font file FreeType opens but cannot use fails while MEASURING, with + # an OSError: it must surface as the "cannot load font" StampError + # (validate_stamp runs on every profile load, which never raises). + font = damaged_font(self.p("damaged.ttf")) + message = f"cannot load font {str(font)!r}: " + stamp = Stamp(kind="text", text="Jane Doe", font=str(font), page=1, x=0.0, y=0.0) + for placed in (True, False): + with self.assertRaises(StampError) as cm: + stamps.validate_stamp(stamp, placed=placed) + self.assertTrue(str(cm.exception).startswith(message), str(cm.exception)) + with self.assertRaises(StampError) as cm: + stamps.stamp_content(stamp) + self.assertTrue(str(cm.exception).startswith(message), str(cm.exception)) + # same contract when FreeType only fails while DRAWING + with mock.patch.object(stamps.ImageDraw.ImageDraw, "multiline_text", + side_effect=OSError("raster overflow")): + with self.assertRaises(StampError) as cm: + self.render(font="lato") + self.assertEqual(str(cm.exception), "cannot load font 'lato': raster overflow") + + +class Placeholders(unittest.TestCase): + def test_date_and_filename_replaced(self): + out = stamps.resolve_text("Signed {date} on {filename}", filename="contract", date=DAY) + self.assertEqual(out, "Signed 07/10/2026 on contract") + today = datetime.date.today().strftime("%d/%m/%Y") + self.assertEqual(stamps.resolve_text("{date}"), today) + + def test_other_braces_untouched(self): + self.assertEqual(stamps.resolve_text("{x} {} {{date}}", date=DAY), "{x} {} {07/10/2026}") + self.assertEqual(stamps.resolve_text("{0} {Date} {file name}", date=DAY), + "{0} {Date} {file name}") + + def test_filename_containing_placeholder_not_reexpanded(self): + out = stamps.resolve_text("{filename} / {date}", filename="{date}_{filename}", date=DAY) + self.assertEqual(out, "{date}_{filename} / 07/10/2026") + + def test_newlines_normalised(self): + self.assertEqual(stamps.resolve_text("a\r\nb\rc\nd", date=DAY), "a\nb\nc\nd") + + +class ImageLoading(StampCase): + def test_palette_transparency_preserved(self): + path = self.p("pal.png") + im = Image.new("P", (4, 4), 0) + im.putpalette([255, 0, 0, 0, 255, 0] + [0] * 762) + im.putpixel((1, 1), 1) + im.save(path, transparency=0) + with Image.open(path) as check: + self.assertEqual(check.mode, "P") + out = stamps.load_image(path) + self.assertEqual(out.mode, "RGBA") + self.assertEqual(out.getpixel((0, 0))[3], 0) + self.assertEqual(out.getpixel((1, 1)), (0, 255, 0, 255)) + + def test_cmyk_and_bilevel_converted(self): + cmyk = self.p("cmyk.jpg") + Image.new("CMYK", (30, 20), (0, 255, 255, 0)).save(cmyk) + bilevel = self.p("bw.png") + Image.new("1", (30, 20), 1).save(bilevel) + for path in (cmyk, bilevel): + with self.subTest(path=path.name): + out = stamps.load_image(path) + self.assertEqual(out.mode, "RGBA") + self.assertEqual(out.size, (30, 20)) + # and it really goes into a PDF (was a bare NotImplementedError) + pdf = make_pdf(self.p(f"{path.stem}.pdf"), [(595, 842)]) + out_pdf = self.stamped(pdf, [Stamp(kind="image", image_path=path, + page=1, x=10.0, y=10.0)]) + self.assertEqual(len(page_xobjects(pdf_pages(out_pdf)[0])), 1) + + def test_rgb_kept(self): + for mode, name in (("RGB", "a.jpg"), ("RGBA", "a.png"), ("L", "l.png"), ("LA", "la.png")): + with self.subTest(mode=mode): + path = self.p(name) + Image.new(mode, (30, 20)).save(path) + self.assertEqual(stamps.load_image(path).mode, mode) + + def test_exif_orientation_applied(self): + path = self.p("rot.jpg") + exif = Image.Exif() + exif[0x0112] = 6 # rotate 90° clockwise to display + Image.new("RGB", (40, 20), (200, 0, 0)).save(path, exif=exif) + with Image.open(path) as raw: + self.assertEqual(raw.size, (40, 20)) + self.assertEqual(stamps.load_image(path).size, (20, 40)) + # the stamp's size follows the NORMALISED image + _, size = stamps.stamp_content(Stamp(kind="image", image_path=path, width_pt=100)) + self.assertEqual(size, (100.0, 200.0)) + + def test_unreadable_raises_stamp_error(self): + text = self.p("not_an_image.png") + text.write_text("hello", encoding="utf-8") + truncated = self.p("truncated.png") + truncated.write_bytes(make_png(self.p("ok.png")).read_bytes()[:40]) + broken = damaged_png(self.p("broken_chunk.png")) # a SyntaxError inside Pillow + for path in (text, truncated, broken, self.p("missing.png"), self.tmp): + with self.subTest(path=path.name): + with self.assertRaises(StampError) as cm: + stamps.load_image(path) + self.assertTrue(str(cm.exception).startswith(f"cannot read image {path}: ")) + self.assertEqual(str(cm.exception).count(str(path)), 1) # not repeated + # Pillow's decoders raise more than OSError/ValueError on damaged files + ok = make_png(self.p("ok.png")) + for exc in (SyntaxError("bad EXIF"), NotImplementedError("pixel format"), MemoryError()): + with self.subTest(exc=type(exc).__name__): + with mock.patch.object(stamps.ImageOps, "exif_transpose", side_effect=exc): + with self.assertRaises(StampError) as cm: + stamps.load_image(ok) + self.assertTrue(str(cm.exception).startswith(f"cannot read image {ok}: ")) + + def test_file_handle_closed(self): + path = make_png(self.p("sig.png")) + with warnings.catch_warnings(): + warnings.simplefilter("error", ResourceWarning) + img = stamps.load_image(path) + gc.collect() + moved = self.p("moved.png") + path.rename(moved) # fails on Windows if still open + moved.unlink() + self.assertEqual(img.getpixel((0, 0)), (255, 0, 0, 128)) # pixels are in memory + + def test_large_image_downsampled_to_600_dpi(self): + path = make_png(self.p("big.png"), size=(3000, 1200)) + img, size = stamps.stamp_content(Stamp(kind="image", image_path=path, width_pt=150)) + self.assertEqual(img.size, (1250, 500)) + self.assertEqual(size, (150.0, 60.0)) + self.assertEqual(img.mode, "RGBA") + + def test_small_image_not_resampled(self): + path = make_png(self.p("small.png"), size=(300, 120)) + img, size = stamps.stamp_content(Stamp(kind="image", image_path=path, width_pt=150)) + self.assertEqual(img.size, (300, 120)) + self.assertEqual(size, (150.0, 60.0)) + + def test_missing_image_path_raises(self): + with self.assertRaises(StampError) as cm: + stamps.stamp_content(Stamp(kind="image")) + self.assertEqual(str(cm.exception), "image_path is required") + + +class StampModel(StampCase): + def test_roundtrip_text(self): + stamp = Stamp(kind="text", text="Jane Doe\nRead and approved, {date}", font="caveat", + color="#AA0000", font_size=18.0, page_anchor="last", x=360.0, y=120.5) + data = json.loads(json.dumps(stamp.to_dict())) + self.assertEqual(data, { + "kind": "text", "text": "Jane Doe\nRead and approved, {date}", "font": "caveat", + "color": "#AA0000", "font_size": 18.0, "page_anchor": "last", "x": 360.0, "y": 120.5}) + self.assertEqual(Stamp.from_dict(data), stamp) + # unplaced: no placement key at all; "every page" is the only flag kept + self.assertEqual(set(Stamp(kind="text", text="x").to_dict()), + {"kind", "text", "font", "color", "font_size"}) + paraphe = Stamp(kind="text", text="JD", all_pages=True) + self.assertIs(paraphe.to_dict()["all_pages"], True) + self.assertEqual(Stamp.from_dict(paraphe.to_dict()), paraphe) + + def test_roundtrip_image_relative_to_base_dir(self): + base = self.p("store") + base.mkdir() + png = make_png(base / "sig.png") + stamp = Stamp(kind="image", image_path=png, width_pt=120.0, page=2, x=60.0, y=700.0) + data = stamp.to_dict(base_dir=base) + self.assertEqual(data, {"kind": "image", "image_path": "sig.png", "width_pt": 120.0, + "page": 2, "x": 60.0, "y": 700.0}) + self.assertEqual(Stamp.from_dict(json.loads(json.dumps(data)), base_dir=base), stamp) + + def test_image_outside_base_dir_is_absolute(self): + base = self.p("store") + (base / "sub").mkdir(parents=True) + outside = make_png(self.p("elsewhere.png")) + nested = make_png(base / "sub" / "deep.png") + for png in (outside, nested): # only a DIRECT child is relative + data = Stamp(kind="image", image_path=png).to_dict(base_dir=base) + self.assertEqual(data["image_path"], str(png)) + self.assertEqual(Stamp(kind="image", image_path=outside).to_dict()["image_path"], + str(outside)) + # an absolute path is taken as it is, whatever the base dir + loaded = Stamp.from_dict({"kind": "image", "image_path": str(outside)}, base_dir=base) + self.assertEqual(loaded.image_path, outside) + + def test_traversal_path_is_not_relative(self): + base = self.p("store") + base.mkdir() + make_png(self.p("x.png")) + data = Stamp(kind="image", image_path=base / ".." / "x.png").to_dict(base_dir=base) + self.assertTrue(Path(data["image_path"]).is_absolute(), data) + self.assertEqual(Path(data["image_path"]), self.p("x.png")) + # and the other way round: a relative entry is joined as written + loaded = Stamp.from_dict({"kind": "image", "image_path": "../x.png"}, base_dir=base) + self.assertEqual(loaded.image_path, base / ".." / "x.png") + + def test_unknown_keys_ignored(self): + stamp = Stamp.from_dict({"kind": "text", "text": "x", "id": "abc", "label": "L", + "placed_on": [595, 842], "enabled": True, "zzz": {"a": 1}}) + self.assertEqual(stamp, Stamp(kind="text", text="x")) + # missing keys take the dataclass defaults + self.assertEqual((stamp.font, stamp.color, stamp.font_size, stamp.width_pt), + ("great-vibes", "#1A2B8C", 24.0, 150.0)) + + def test_numbers_stored_as_float(self): + stamp = Stamp.from_dict({"kind": "text", "text": "x", "font_size": 12, "width_pt": 80, + "page": 3, "x": 10, "y": 20}) + for value in (stamp.font_size, stamp.width_pt, stamp.x, stamp.y): + self.assertIs(type(value), float) + self.assertIs(type(stamp.page), int) + + def test_relative_font_resolved_against_base_dir(self): + base = self.p("cfg") + base.mkdir() + shutil.copy(stamps.resolve_font("lato"), base / "mine.ttf") + stamp = Stamp.from_dict({"kind": "text", "text": "x", "font": "mine.ttf"}, base_dir=base) + self.assertEqual(stamp.font, str(base / "mine.ttf")) + stamps.validate_stamp(stamp, placed=False) + # a bundled id is never turned into a path + bundled = Stamp.from_dict({"kind": "text", "text": "x", "font": "Caveat"}, base_dir=base) + self.assertEqual(bundled.font, "Caveat") + + def test_font_untouched_without_base_dir(self): + for font in ("./mine.ttf", "~/fonts/mine.ttf", "mine.ttf"): + stamp = Stamp.from_dict({"kind": "text", "text": "x", "font": font}) + self.assertEqual(stamp.font, font) + self.assertEqual(Stamp.from_dict(stamp.to_dict()), stamp) + + def test_hostile_paths_raise_stamp_error_only(self): + # pathlib raises RuntimeError for an unknown ~user and OSError for an + # over-long name: neither may escape as itself. + long_name = "x" * 5000 + rows = [ + ({"kind": "image", "image_path": "~no_such_user_zz/sig.png"}, "image not found: "), + ({"kind": "image", "image_path": long_name + ".png"}, "image not found: "), + ({"kind": "image", "image_path": "a\x00b.png"}, "image not found: "), + ({"kind": "image", "image_path": "\ud800.png"}, "image not found: "), + ({"kind": "text", "text": "x", "font": "~no_such_user_zz/f.ttf"}, "unknown font "), + ({"kind": "text", "text": "x", "font": long_name}, "unknown font "), + ({"kind": "text", "text": "x", "font": "a\x00b"}, "unknown font "), + ({"kind": "text", "text": "x", "font": "\ud800.ttf"}, "unknown font "), + ] + for base_dir in (None, self.tmp): + for data, prefix in rows: + with self.subTest(base_dir=base_dir, data=ascii(data)[:60]): + stamp = Stamp.from_dict(data, base_dir=base_dir) + with self.assertRaises(StampError) as cm: + stamps.validate_stamp(stamp, placed=False) + self.assertTrue(str(cm.exception).startswith(prefix), + ascii(str(cm.exception))[:80]) + # and it still serialises (a profile holding it must save): + # a NUL / lone surrogate makes Path.resolve raise ValueError + out = stamp.to_dict(base_dir=self.tmp) + self.assertEqual("image_path" in out, stamp.kind == "image") + with self.assertRaises(StampError): + stamps.load_image(long_name) + + def test_from_dict_type_errors(self): + cases = [([], "expected an object"), ("x", "expected an object"), + (None, "expected an object"), ({}, "missing 'kind'")] + for key in ("kind", "text", "font", "color", "image_path", "page_anchor"): + cases.append(({"kind": "text", key: 5}, f"'{key}' must be a string")) + cases.append(({"kind": "text", key: None}, f"'{key}' must be a string")) + for key in ("font_size", "width_pt", "x", "y"): + cases.append(({"kind": "text", key: "1"}, f"'{key}' must be a number")) + cases.append(({"kind": "text", key: 10 ** 400}, f"'{key}' must be a number")) + for bad in (1.5, "1", None): + cases.append(({"kind": "text", "page": bad}, "'page' must be an integer")) + for bad in (1, "true", None): + cases.append(({"kind": "text", "all_pages": bad}, "'all_pages' must be true or false")) + for data, message in cases: + with self.subTest(data=data): + with self.assertRaises(StampError) as cm: + Stamp.from_dict(data) + self.assertEqual(str(cm.exception), message) + + def test_bool_is_not_a_number(self): + for key in ("font_size", "width_pt", "x", "y"): + with self.subTest(key=key): + with self.assertRaises(StampError) as cm: + Stamp.from_dict({"kind": "text", key: True}) + self.assertEqual(str(cm.exception), f"'{key}' must be a number") + with self.assertRaises(StampError) as cm: + Stamp.from_dict({"kind": "text", "page": True}) + self.assertEqual(str(cm.exception), "'page' must be an integer") + + def test_validate_rules(self): + png = make_png(self.p("sig.png")) + not_a_font = self.p("fake.ttf") + not_a_font.write_text("not a font", encoding="utf-8") + text = dict(kind="text", text="Jane", page=1, x=0.0, y=0.0) + image = dict(kind="image", image_path=png, page=1, x=0.0, y=0.0) + nan, inf = float("nan"), float("inf") + size_msg = "font_size must be greater than 0 and at most 400" + width_msg = "width_pt must be greater than 0 and at most 5000" + target_msg = "give exactly one page target: page, page_anchor or all_pages" + rows = [ + # content rules, in the order they are checked + (dict(text, kind="sticker"), "unknown kind 'sticker' (expected text|image)"), + (dict(text, text=" \n "), "text is empty"), + (dict(text, text="a" * 2001), "text is too long (max 2000 characters)"), + (dict(text, color="blue"), "bad color 'blue' (expected #RRGGBB)"), + (dict(text, font_size=0.0), size_msg), + (dict(text, font_size=-3.0), size_msg), + (dict(text, font_size=400.5), size_msg), + (dict(text, font_size=nan), size_msg), + (dict(text, font_size=inf), size_msg), + (dict(text, font="comic-sans"), "unknown font 'comic-sans'"), + (dict(text, font=str(not_a_font)), f"cannot load font {str(not_a_font)!r}: "), + (dict(text, text="W" * 2000, font="lato", font_size=400.0), + "text is too large to render (reduce the font size or the text length)"), + (dict(image, image_path=None), "image_path is required"), + (dict(image, image_path=self.p("gone.png")), f"image not found: {self.p('gone.png')}"), + (dict(image, width_pt=0.0), width_msg), + (dict(image, width_pt=-1.0), width_msg), + (dict(image, width_pt=5000.5), width_msg), + (dict(image, width_pt=nan), width_msg), + # placement rules + (dict(text, page=None), target_msg), + (dict(text, page_anchor="last"), target_msg), + (dict(text, all_pages=True), target_msg), + (dict(image, page=None, page_anchor="first", all_pages=True), target_msg), + (dict(text, page=0), "page must be an integer >= 1"), + (dict(text, page=None, page_anchor="middle"), + "unknown page_anchor 'middle' (expected first|last)"), + (dict(text, x=None), "x and y are required"), + (dict(image, y=None), "x and y are required"), + (dict(text, x=nan), "x and y must be finite numbers"), + (dict(image, y=inf), "x and y must be finite numbers"), + # content is checked before placement + (dict(text, text="", page=None), "text is empty"), + ] + for kwargs, message in rows: + with self.subTest(message=message, kwargs={k: str(v)[:20] for k, v in kwargs.items()}): + with self.assertRaises(StampError) as cm: + stamps.validate_stamp(Stamp(**kwargs)) + if message.endswith(("'", ": ")): # open-ended messages + self.assertTrue(str(cm.exception).startswith(message), str(cm.exception)) + else: + self.assertEqual(str(cm.exception), message) + valid = [text, image, dict(text, font_size=400.0, text="J"), dict(image, width_pt=5000.0), + dict(text, page=None, page_anchor="first"), dict(image, page=None, all_pages=True), + dict(text, text="a" * 2000, font="lato", font_size=6.0), dict(text, x=-5.0, y=1e6)] + for kwargs in valid: + with self.subTest(valid={k: str(v)[:20] for k, v in kwargs.items()}): + stamps.validate_stamp(Stamp(**kwargs)) + + def test_bundled_font_missing_is_reported(self): + stamp = Stamp(kind="text", text="Jane", font="lato", page=1, x=0.0, y=0.0) + with mock.patch.object(sys, "_MEIPASS", str(self.tmp), create=True): + with self.assertRaises(StampError) as cm: + stamps.validate_stamp(stamp) + self.assertTrue(str(cm.exception).startswith("bundled font file missing: ")) + + def test_validate_unplaced_content_only(self): + unplaced = Stamp(kind="text", text="Jane") + stamps.validate_stamp(unplaced, placed=False) + with self.assertRaises(StampError): + stamps.validate_stamp(unplaced) + half = Stamp(kind="image", image_path=make_png(self.p("s.png")), page=1, x=3.0) + stamps.validate_stamp(half, placed=False) + with self.assertRaises(StampError): + stamps.validate_stamp(half) + with self.assertRaises(StampError) as cm: # content rules still run + stamps.validate_stamp(Stamp(kind="text", text=""), placed=False) + self.assertEqual(str(cm.exception), "text is empty") + + def test_validate_placement_ignores_content(self): + # a saved placement stays judgeable while its image file is missing + gone = Stamp(kind="image", image_path=self.p("gone.png"), page=2, x=1.0, y=2.0) + stamps.validate_placement(gone) + with self.assertRaises(StampError) as cm: + stamps.validate_placement(dataclasses.replace(gone, y=None)) + self.assertEqual(str(cm.exception), "x and y are required") + + def test_page_indexes(self): + self.assertEqual(Stamp(page=1).page_indexes(3), [0]) + self.assertEqual(Stamp(page=3).page_indexes(3), [2]) + self.assertEqual(Stamp(page_anchor="first").page_indexes(3), [0]) + self.assertEqual(Stamp(page_anchor="last").page_indexes(3), [2]) + self.assertEqual(Stamp(page_anchor="last").page_indexes(1), [0]) + self.assertEqual(Stamp(all_pages=True).page_indexes(3), [0, 1, 2]) + for page in (0, 4, -1): + with self.subTest(page=page): + with self.assertRaises(StampError) as cm: + Stamp(page=page).page_indexes(3) + self.assertEqual(str(cm.exception), + f"page {page} out of range (the document has 3 page(s))") + with self.assertRaises(StampError) as cm: + Stamp().page_indexes(3) + self.assertEqual(str(cm.exception), "the signature has no page target") + with self.assertRaises(StampError) as cm: + Stamp(page=1).page_indexes(0) + self.assertEqual(str(cm.exception), "the document has no page") + with self.assertRaises(StampError): + Stamp(page_anchor="middle").page_indexes(3) + + def test_describe_and_labels(self): + text = Stamp(kind="text", text="\n Jane Doe \nRead and approved", page_anchor="last", + x=360.4, y=119.6) + self.assertTrue(text.is_placed) + self.assertEqual(text.target_label(), "last page") + self.assertEqual(text.describe(), "text last page @ (360, 120)") + self.assertEqual(text.content_label(), '"Jane Doe"') + image = Stamp(kind="image", image_path=Path("/x/y/stamp.png"), page=2, x=1.0, y=2.0) + self.assertEqual(image.describe(), "image page 2 @ (1, 2)") + self.assertEqual(image.content_label(), "stamp.png") + self.assertEqual(Stamp(kind="image").content_label(), "?") + self.assertEqual(Stamp(kind="text", text="JD", all_pages=True, x=540.0, y=20.0).describe(), + "text all pages @ (540, 20)") + self.assertEqual(Stamp(kind="text", page_anchor="first").target_label(), "first page") + unplaced = Stamp(kind="text", text="x") + self.assertFalse(unplaced.is_placed) + self.assertEqual(unplaced.target_label(), "unplaced") + self.assertEqual(unplaced.describe(), "text unplaced") + long = Stamp(kind="text", text="A" * 31) + self.assertEqual(long.content_label(), '"' + "A" * 30 + '…"') + self.assertEqual(Stamp(kind="text", text="A" * 30).content_label(), '"' + "A" * 30 + '"') + # placed needs a position AND exactly one target + self.assertFalse(Stamp(page=1, x=1.0).is_placed) + self.assertFalse(Stamp(x=1.0, y=1.0).is_placed) + self.assertFalse(Stamp(page=1, all_pages=True, x=1.0, y=1.0).is_placed) + + def test_content_key(self): + a = Stamp(kind="text", text="{filename} {date}", color="#aa00ff", page=1, x=1.0, y=2.0) + b = Stamp(kind="text", text="{filename} {date}", color="#AA00FF", all_pages=True) + key = stamps.content_key(a, filename="doc", date=DAY) + self.assertEqual(key, ("text", "doc 07/10/2026", "great-vibes", 24.0, "#AA00FF")) + self.assertEqual(key, stamps.content_key(b, filename="doc", date=DAY)) # placement excluded + self.assertNotEqual(key, stamps.content_key(a, filename="other", date=DAY)) + img = Stamp(kind="image", image_path=Path("/x/s.png"), width_pt=80) + self.assertEqual(stamps.content_key(img), ("image", "/x/s.png", 80.0)) + hash(key) + + +class SignaturesFile(StampCase): + def write(self, entries, name="sigs.json", encoding="utf-8") -> Path: + path = self.p(name) + path.write_text(json.dumps(entries), encoding=encoding) + return path + + def assert_rejected(self, path, fragment): + with self.assertRaises(StampError) as cm: + stamps.load_stamps_file(path) + self.assertIn(fragment, str(cm.exception)) + return str(cm.exception) + + def test_load_list(self): + make_png(self.p("stamp.png")) + path = self.write([ + {"kind": "text", "text": "Jane Doe\nRead and approved, {date}", + "font": "great-vibes", "color": "#1A2B8C", "font_size": 24, + "page_anchor": "last", "x": 360, "y": 120}, + {"kind": "text", "text": "JD", "font": "caveat", "font_size": 14, + "all_pages": True, "x": 540, "y": 20}, + {"kind": "image", "image_path": "stamp.png", "width_pt": 120, + "page": 1, "x": 60, "y": 700}, + ]) + loaded = stamps.load_stamps_file(path) + self.assertEqual([s.describe() for s in loaded], [ + "text last page @ (360, 120)", "text all pages @ (540, 20)", + "image page 1 @ (60, 700)"]) + self.assertTrue(all(s.is_placed for s in loaded)) + self.assertEqual(loaded[0].text, "Jane Doe\nRead and approved, {date}") + self.assertEqual(stamps.load_stamps_file(self.write([], name="empty.json")), []) + + def test_relative_image_resolved_against_file_dir(self): + sub = self.p("conf") + sub.mkdir() + png = make_png(sub / "stamp.png") + (sub / "sigs.json").write_text(json.dumps( + [{"kind": "image", "image_path": "stamp.png", "page": 1, "x": 0, "y": 0}]), + encoding="utf-8") + loaded = stamps.load_stamps_file(sub / "sigs.json") # cwd is NOT sub + self.assertEqual(loaded[0].image_path, png) + + def test_disabled_entries_skipped(self): + path = self.write([ + {"kind": "text", "text": "kept", "page": 1, "x": 0, "y": 0, "enabled": True}, + # skipped BEFORE validation: it may even be invalid + {"kind": "text", "text": "", "enabled": False}, + {"kind": "text", "text": "also kept", "page": 1, "x": 0, "y": 0}, + ]) + self.assertEqual([s.text for s in stamps.load_stamps_file(path)], ["kept", "also kept"]) + bad = self.write([{"kind": "text", "text": "x", "page": 1, "x": 0, "y": 0, + "enabled": "no"}], name="bad.json") + msg = self.assert_rejected(bad, "'enabled' must be true or false") + self.assertEqual(msg, "bad.json: signature #1: 'enabled' must be true or false") + + def test_profile_extras_ignored(self): + path = self.write([{"id": "3f9a1c0b77de", "label": "Jane Doe", "enabled": True, + "kind": "text", "text": "Jane Doe", "font": "great-vibes", + "color": "#1A2B8C", "font_size": 24.0, + "page": 2, "x": 360.0, "y": 120.0, "placed_on": [595.0, 842.0]}]) + self.assertEqual(stamps.load_stamps_file(path), + [Stamp(kind="text", text="Jane Doe", page=2, x=360.0, y=120.0)]) + + def test_not_a_list_rejected(self): + for value in ({"kind": "text"}, "x", 3, None): + with self.subTest(value=value): + path = self.write(value) + msg = self.assert_rejected(path, "expected a JSON list of signatures") + self.assertEqual(msg, f"{path}: expected a JSON list of signatures") + + def test_bad_json_rejected(self): + path = self.p("bad.json") + path.write_text("[{", encoding="utf-8") + msg = self.assert_rejected(path, "invalid JSON") + self.assertTrue(msg.startswith(f"invalid JSON in {path}: "), msg) + + def test_missing_file_rejected(self): + path = self.p("nope.json") + msg = self.assert_rejected(path, "cannot read signatures file") + self.assertTrue(msg.startswith(f"cannot read signatures file {path}: "), msg) + + def test_error_names_file_and_entry_number(self): + ok = {"kind": "text", "text": "x", "page": 1, "x": 0, "y": 0} + cases = [ + ([ok, {"kind": "text", "text": "x", "x": 0, "y": 0}], + "sigs.json: signature #2: give exactly one page target: " + "page, page_anchor or all_pages"), + ([ok, ok, {"kind": "image", "page": 1, "x": 0, "y": 0}], + "sigs.json: signature #3: image_path is required"), + ([{"text": "x"}], "sigs.json: signature #1: missing 'kind'"), + ([ok, "nope"], "sigs.json: signature #2: expected an object"), + ([{"kind": "text", "text": "x", "page": "1", "x": 0, "y": 0}], + "sigs.json: signature #1: 'page' must be an integer"), + ] + for entries, message in cases: + with self.subTest(message=message): + self.assertEqual(self.assert_rejected(self.write(entries), "signature #"), message) + + def test_unknown_font_id_reported_as_typed(self): + entry = {"kind": "text", "text": "x", "page": 1, "x": 0, "y": 0} + msg = self.assert_rejected(self.write([dict(entry, font="latoo")]), "unknown font") + # the id as typed (not joined to the folder of the JSON file) + the valid ids + self.assertTrue(msg.startswith("sigs.json: signature #1: unknown font 'latoo' "), msg) + self.assertNotIn(str(self.tmp), msg) + for font_id in stamps.BUNDLED_FONTS: + self.assertIn(font_id, msg) + # a relative PATH is still looked up next to the JSON file, and says where + msg = self.assert_rejected(self.write([dict(entry, font="sub/mine.ttf")]), "unknown font") + self.assertIn(str(self.p("sub") / "mine.ttf"), msg) + shutil.copy(stamps.resolve_font("lato"), self.p("mine")) # a bare-word font file + loaded = stamps.load_stamps_file(self.write([dict(entry, font="mine")])) + self.assertEqual(loaded[0].font, str(self.p("mine"))) + + def test_utf8_bom_accepted(self): + path = self.write([{"kind": "text", "text": "Sébastien", "page": 1, "x": 0, "y": 0}], + encoding="utf-8-sig") + self.assertTrue(path.read_bytes().startswith(b"\xef\xbb\xbf")) + self.assertEqual(stamps.load_stamps_file(path)[0].text, "Sébastien") + + def test_non_ascii_text_roundtrip(self): + path = self.p("sigs.json") + path.write_bytes(json.dumps( + [{"kind": "text", "text": "Sébastien — Ünï", "page": 1, "x": 0, "y": 0}], + ensure_ascii=False).encode("utf-8")) + self.assertIn("Sébastien".encode("utf-8"), path.read_bytes()) + self.assertEqual(stamps.load_stamps_file(path)[0].text, "Sébastien — Ünï") + + def test_not_utf8_rejected(self): + path = self.p("latin1.json") + path.write_bytes(b'[{"text": "S\xe9b"}]') + self.assertEqual(self.assert_rejected(path, "not valid UTF-8"), + f"{path}: not valid UTF-8") + + def test_deeply_nested_json_rejected(self): + path = self.p("deep.json") + path.write_text("[" * 200000, encoding="utf-8") + msg = self.assert_rejected(path, "invalid JSON") # a StampError, no RecursionError + self.assertTrue(msg.startswith("invalid JSON"), msg[:80]) + + def test_nan_coordinates_rejected(self): + path = self.p("nan.json") + path.write_text('[{"kind": "text", "text": "x", "page": 1, "x": NaN, "y": 0}]', + encoding="utf-8") + self.assertEqual(self.assert_rejected(path, "finite"), + "nan.json: signature #1: x and y must be finite numbers") + + +class ApplyStamps(StampCase): + def text(self, **kwargs): + return Stamp(kind="text", text="Jane Doe", **kwargs) + + def counts(self, pdf): + return [len(page_xobjects(page)) for page in pdf_pages(pdf)] + + def test_one_xobject_per_target_page(self): + pdf = make_pdf(self.p("a.pdf"), [(595, 842)] * 3) + png = make_png(self.p("sig.png")) + out = self.stamped(pdf, [ + self.text(page=1, x=50.0, y=50.0), + Stamp(kind="image", image_path=png, page=1, x=300.0, y=50.0), + self.text(page=3, x=50.0, y=50.0), + ]) + self.assertEqual(self.counts(out), [2, 0, 1]) + self.assertEqual(self.counts(pdf), [0, 0, 0]) # the input is untouched + + def test_all_pages_single_image(self): + pdf = make_pdf(self.p("a.pdf"), [(595, 842)] * 5) + out = self.stamped(pdf, [self.text(all_pages=True, x=540.0, y=20.0)]) + xobjects = [page_xobjects(page) for page in pdf_pages(out)] + self.assertEqual([len(x) for x in xobjects], [1] * 5) + # ONE image XObject shared by the five pages (not five copies) + self.assertEqual(len({ref for x in xobjects for ref in x.values()}), 1) + + def test_pages_sharing_one_resources_dictionary(self): + # two pages pointing at the SAME indirect /Resources: each page needs + # its own XObject name (pyHanko refuses a duplicate name) + page = b"<>" + pdf = write_pdf_objects(self.p("shared.pdf"), [ + b"<>", + b"<>", + page % 4, pdf_stream(b"q Q\n"), page % 6, pdf_stream(b"q Q\n"), + b"<>"]) + out = self.stamped(pdf, [self.text(all_pages=True, x=100.0, y=100.0)]) + xobjects = [page_xobjects(p) for p in pdf_pages(out)] + self.assertEqual(xobjects[0], xobjects[1]) # the shared dictionary + self.assertEqual(len(xobjects[0]), 2) # one name per page... + self.assertEqual(len(set(xobjects[0].values())), 1) # ...for one image + + def test_indirect_contents_array_with_direct_resources(self): + # /Contents -> an indirect ARRAY: pyHanko then updates the array only, + # so the /Resources it adds to the page must be written explicitly + for label, resources in (("direct", b"/Resources<>"), ("absent", b"")): + with self.subTest(resources=label): + pdf = write_pdf_objects(self.p(f"{label}.pdf"), [ + b"<>", + b"<>", + b"<>", + b"[5 0 R]", pdf_stream(b"q Q\n")]) + out = self.stamped(pdf, [self.text(page=1, x=100.0, y=100.0)]) + page = pdf_pages(out)[0] + (name,) = page_xobjects(page) # the XObject reached the file + data = b" ".join(part.get_object().data for part in page["/Contents"]) + self.assertIn(name.encode() + b" Do", data) + + def test_pages_sharing_one_contents_array(self): + # two pages pointing at the SAME indirect /Contents array: a stamp + # for page 1 must not reach page 2 (pyHanko appends to it in place) + page = b"<>" + for label, resources in (("own", b"/Resources<>"), + ("shared", b"/Resources 6 0 R")): + with self.subTest(resources=label): + pdf = write_pdf_objects(self.p(f"{label}.pdf"), [ + b"<>", + b"<>", + page % resources, b"[5 0 R]", pdf_stream(b"q Q\n"), + b"<>", page % resources]) + out = self.stamped(pdf, [self.text(page=1, x=100.0, y=100.0), + self.text(page=1, x=100.0, y=300.0)]) + painted = [b" ".join(part.get_object().data for part in page["/Contents"]) + .count(b" Do") for page in pdf_pages(out)] + self.assertEqual(painted, [2, 0]) + + def test_anchor_last_on_shorter_document(self): + last = self.text(page_anchor="last", x=50.0, y=50.0) + first = self.text(page_anchor="first", x=50.0, y=50.0) + one = make_pdf(self.p("one.pdf"), [(595, 842)]) + three = make_pdf(self.p("three.pdf"), [(595, 842)] * 3) + self.assertEqual(self.counts(self.stamped(one, [last])), [1]) + self.assertEqual(self.counts(self.stamped(three, [last])), [0, 0, 1]) + self.assertEqual(self.counts(self.stamped(three, [first])), [1, 0, 0]) + self.assertEqual(self.counts(self.stamped(one, [first, last])), [2]) + + def test_page_out_of_range_before_any_write(self): + pdf = make_pdf(self.p("a.pdf"), [(595, 842)] * 2) + good = self.text(page=1, x=50.0, y=50.0) + bad = self.text(page=5, x=50.0, y=50.0) + with pdf.open("rb") as inf: + writer = IncrementalPdfFileWriter(inf, strict=False) + with self.assertRaises(StampError) as cm: + stamps.apply_stamps(writer, [good, bad]) + self.assertEqual(str(cm.exception), + "page 5 out of range (the document has 2 page(s))") + out = io.BytesIO() + writer.write(out) + # the valid FIRST stamp was not applied either: nothing half-stamped + after = self.p("after.pdf") + after.write_bytes(out.getvalue()) + self.assertEqual(self.counts(after), [0, 0]) + + def test_unreadable_image_before_any_write(self): + pdf = make_pdf(self.p("a.pdf"), [(595, 842)]) + broken = self.p("broken.png") + broken.write_text("nope", encoding="utf-8") + with pdf.open("rb") as inf: + writer = IncrementalPdfFileWriter(inf, strict=False) + with self.assertRaises(StampError): + stamps.apply_stamps(writer, [ + self.text(page=1, x=50.0, y=50.0), + Stamp(kind="image", image_path=broken, page=1, x=0.0, y=0.0)]) + out = io.BytesIO() + writer.write(out) + after = self.p("after.pdf") + after.write_bytes(out.getvalue()) + self.assertEqual(self.counts(after), [0]) + + def test_unplaced_raises(self): + pdf = make_pdf(self.p("a.pdf"), [(595, 842)]) + for stamp in (self.text(), self.text(page=1), self.text(x=1.0, y=1.0), + self.text(page=1, page_anchor="last", x=1.0, y=1.0)): + with self.subTest(stamp=stamp.describe()): + with self.assertRaises(StampError) as cm: + self.stamped(pdf, [stamp]) + self.assertEqual(str(cm.exception), "the signature is not placed") + + def test_empty_list_is_a_noop(self): + pdf = make_pdf(self.p("a.pdf"), [(595, 842)]) + self.assertEqual(self.counts(self.stamped(pdf, [])), [0]) + + def test_page_without_contents_is_stamped(self): + pdf = strip_page_contents(make_pdf(self.p("a.pdf"), [(595, 842)] * 2), 1) + before = pdf_pages(pdf) + self.assertIn("/Contents", before[0]) + self.assertNotIn("/Contents", before[1]) # the fixture really is blank + out = self.stamped(pdf, [self.text(all_pages=True, x=100.0, y=100.0)]) + pages = pdf_pages(out) + self.assertIn("/Contents", pages[1]) + self.assertEqual(self.counts(out), [1, 1]) + img = core.render_page_image(out, 1, 595) # 1 px per point + if img is not None: # pypdfium2 / pdftoppm available + box = img.crop((100, 842 - 160, 300, 842 - 100)) + self.assertTrue(any(near(px, INK) for _, px in box.getcolors(100000))) + + def test_existing_signature_count_zero_for_unsigned(self): + pdf = make_pdf(self.p("a.pdf"), [(595, 842)] * 2) + with pdf.open("rb") as inf: + writer = IncrementalPdfFileWriter(inf, strict=False) + self.assertEqual(stamps.existing_signature_count(writer), 0) + # read-only: stamps applied afterwards still work, count unchanged + stamps.apply_stamps(writer, [self.text(page=1, x=10.0, y=10.0)]) + self.assertEqual(stamps.existing_signature_count(writer), 0) + + def test_empty_signature_field_is_not_a_signature(self): + # a form prepared for signing: the field exists but nothing is signed + pdf = make_pdf(self.p("a.pdf"), [(595, 842)]) + with pdf.open("rb") as inf: + writer = IncrementalPdfFileWriter(inf, strict=False) + append_signature_field( + writer, SigFieldSpec("ToBeSigned", on_page=0, box=(10, 10, 110, 60))) + out = io.BytesIO() + writer.write(out) + prepared = self.p("prepared.pdf") + prepared.write_bytes(out.getvalue()) + with prepared.open("rb") as inf: + writer = IncrementalPdfFileWriter(inf, strict=False) + self.assertEqual(len(list(enumerate_sig_fields(writer.prev))), 1) # the fixture + self.assertEqual(stamps.existing_signature_count(writer), 0) + + def test_page_count_and_mediabox(self): + pdf = make_pdf(self.p("a.pdf"), [(200, 300), (400, 500), (612, 792)]) + with pdf.open("rb") as inf: + writer = IncrementalPdfFileWriter(inf, strict=False) + self.assertEqual(stamps.page_count(writer), 3) + self.assertEqual(stamps.page_mediabox(writer, 0), [0.0, 0.0, 200.0, 300.0]) + self.assertEqual(stamps.page_mediabox(writer, -1), [0.0, 0.0, 612.0, 792.0]) + + def test_static_content_cached_once(self): + cache: dict = {} + stamp = Stamp(kind="text", text="Jane Doe, {date}", page=1, x=50.0, y=50.0) + with mock.patch.object(stamps, "render_text_image", + wraps=stamps.render_text_image) as spy: + for name in ("a", "b", "c"): + pdf = make_pdf(self.p(f"{name}.pdf"), [(595, 842)]) + out = self.stamped(pdf, [stamp], filename=name, date=DAY, cache=cache) + self.assertEqual(self.counts(out), [1]) + self.assertEqual(spy.call_count, 1) + self.assertEqual(list(cache), [stamps.content_key(stamp, date=DAY)]) + + def test_image_content_cached_once(self): + cache: dict = {} + stamp = Stamp(kind="image", image_path=make_png(self.p("s.png")), all_pages=True, + x=5.0, y=5.0) + with mock.patch.object(stamps, "load_image", wraps=stamps.load_image) as spy: + for name in ("a", "b"): + self.stamped(make_pdf(self.p(f"{name}.pdf"), [(595, 842)] * 2), [stamp], + filename=name, cache=cache) + self.assertEqual(spy.call_count, 1) + + def test_filename_text_not_cached(self): + cache: dict = {} + stamp = Stamp(kind="text", text="Ref {filename}", page=1, x=50.0, y=50.0) + with mock.patch.object(stamps, "render_text_image", + wraps=stamps.render_text_image) as spy: + for name in ("a", "b", "c"): + pdf = make_pdf(self.p(f"{name}.pdf"), [(595, 842)]) + self.stamped(pdf, [stamp], filename=name, date=DAY, cache=cache) + self.assertEqual(spy.call_count, 3) + self.assertEqual([call.args[0] for call in spy.call_args_list], + ["Ref a", "Ref b", "Ref c"]) + self.assertEqual(cache, {}) # no growth over the batch + + def paint_matrix(self, pdf, page_index=0) -> list[float]: + """[w, h, x, y] of the only stamp painted on a page: the operands of + its ``w 0 0 h x y cm`` matrix.""" + contents = pdf_pages(pdf)[page_index]["/Contents"] + data = b" ".join(part.get_object().data for part in contents) + (found,) = re.findall(rb"q (\S+) 0 0 (\S+) (\S+) (\S+) cm /Stamp\w+ Do Q", data) + return [float(v) for v in found] + + def test_float_coordinates_accepted(self): + pdf = make_pdf(self.p("a.pdf"), [(595, 842)]) + out = self.stamped(pdf, [self.text(page=1, x=100.5, y=200.25)]) + self.assertEqual(self.paint_matrix(out)[2:], [100.5, 200.25]) # not rounded to integers + + def test_stamped_size_is_the_content_size(self): + # the size is NOT truncated to whole points (a pyHanko stamp box would + # be): what is previewed is what gets stamped, down to under 1 pt + thin = self.p("rule.png") + Image.new("RGB", (2000, 1), (0, 0, 0)).save(thin) + for stamp in ( + Stamp(kind="text", text="JD", font="lato", font_size=8, page=1, x=100.0, y=400.0), + Stamp(kind="text", text="__________", font="lato", font_size=12, + page=1, x=100.0, y=400.0), + Stamp(kind="image", image_path=thin, width_pt=150.0, page=1, x=100.0, y=400.0)): + with self.subTest(stamp=stamp.content_label()): + _, (w, h) = stamps.stamp_content(stamp) + self.assertNotEqual((w, h), (int(w), int(h))) # a fractional size + pdf = make_pdf(self.p("a.pdf"), [(595, 842)]) + matrix = self.paint_matrix(self.stamped(pdf, [stamp])) + self.assertAlmostEqual(matrix[0], w, places=3) + self.assertAlmostEqual(matrix[1], h, places=3) + self.assertEqual(matrix[2:], [100.0, 400.0]) + self.assertLess(h, 1.0) # the rule: under 1 pt high + + def test_position_is_relative_to_the_mediabox_origin(self): + pdf = make_pdf(self.p("a.pdf"), [(595, 842)], origin=(100, 200)) + with pdf.open("rb") as inf: + writer = IncrementalPdfFileWriter(inf, strict=False) + self.assertEqual(stamps.page_mediabox(writer, 0), [100.0, 200.0, 695.0, 1042.0]) + out = self.stamped(pdf, [self.text(page=1, x=50.0, y=60.0)]) + self.assertEqual(self.paint_matrix(out)[2:], [150.0, 260.0]) + + def test_mediabox_inherited_from_the_page_tree(self): + # the page has no /MediaBox of its own: the one of /Pages applies + pdf = write_pdf_objects(self.p("inherited.pdf"), [ + b"<>", + b"<>", + b"<>>>", + pdf_stream(b"q Q\n")]) + with pdf.open("rb") as inf: + writer = IncrementalPdfFileWriter(inf, strict=False) + self.assertEqual(stamps.page_mediabox(writer, 0), [10.0, 20.0, 410.0, 620.0]) + out = self.stamped(pdf, [self.text(page=1, x=50.0, y=60.0)]) + self.assertEqual(self.paint_matrix(out)[2:], [60.0, 80.0]) + + def test_rendered_pixels(self): + pdf = make_pdf(self.p("a.pdf"), [(595, 842)]) + # 200x100 px: left half opaque red, right half fully transparent + png = self.p("half.png") + im = Image.new("RGBA", (200, 100), (0, 0, 0, 0)) + im.paste((255, 0, 0, 255), (0, 0, 100, 100)) + im.save(png) + stamp_text = self.text(page=1, x=300.0, y=400.0) + _, (tw, th) = stamps.stamp_content(stamp_text) + # 30x15 px, fewer pixels than its 200x100 pt box: it must be ENLARGED + small = self.p("small.png") + Image.new("RGB", (30, 15), (0, 0, 255)).save(small) + out = self.stamped(pdf, [ + Stamp(kind="image", image_path=png, width_pt=100.0, page=1, x=100.0, y=100.0), + stamp_text, + Stamp(kind="image", image_path=small, width_pt=200.0, page=1, x=100.0, y=600.0)]) + img = core.render_page_image(out, 0, 595) # 1 px per point, y downwards + if img is None: + self.skipTest("no PDF renderer available (pypdfium2 / pdftoppm)") + white = (255, 255, 255) + # image: 100x50 pt box at (100, 100) -> rows 692..742 + self.assertTrue(near(img.getpixel((125, 842 - 125)), (255, 0, 0))) # opaque half + self.assertTrue(near(img.getpixel((175, 842 - 125)), white, 6)) # page shows through + self.assertTrue(near(img.getpixel((125, 842 - 170)), white, 6)) # above the box + self.assertTrue(near(img.getpixel((80, 842 - 125)), white, 6)) # left of the box + # text: ink inside its own box, in the chosen colour; none just outside + box = img.crop((300, round(842 - 400 - th), round(300 + tw), 842 - 400)) + self.assertTrue(any(near(px, INK) for _, px in box.getcolors(100000))) + margin = img.crop((300, round(842 - 400 - th) - 40, round(300 + tw), + round(842 - 400 - th) - 4)) + self.assertTrue(all(near(px, white, 6) for _, px in margin.getcolors(100000))) + # the small image fills its whole box (both corners), not its natural 30x15 pt + self.assertTrue(near(img.getpixel((105, 842 - 605)), (0, 0, 255))) + self.assertTrue(near(img.getpixel((295, 842 - 695)), (0, 0, 255))) + # no frame around either element + self.assertTrue(near(img.getpixel((199, 842 - 125)), white, 6)) # right edge + self.assertTrue(near(img.getpixel((175, 842 - 149)), white, 6)) # top edge + self.assertTrue(near(img.getpixel((101, 842 - 125)), (255, 0, 0))) # left edge: the image + + +class StrokeRendering(unittest.TestCase): + def test_strokes_give_transparent_rgba(self): + strokes = [[(10, 10), (60, 40), (110, 10)], [(10, 50), (110, 50)]] + img = stamps.render_strokes_image(strokes, "#0000FF", width=3.0, scale=4) + self.assertEqual(img.mode, "RGBA") + self.assertEqual(img.convert("RGB").getcolors(), [(img.width * img.height, (0, 0, 255))]) + alpha = img.getchannel("A") + self.assertEqual(alpha.getbbox(), (0, 0) + img.size) # tightly cropped + self.assertEqual(alpha.getpixel((img.width // 2, 2)), 0) # between the strokes + self.assertEqual(alpha.getpixel((img.width // 2, img.height - 6)), 255) # on the line + # scale x (bounding box of the points + the pen width) + self.assertAlmostEqual(img.width, 4 * (100 + 3), delta=8) + self.assertAlmostEqual(img.height, 4 * (40 + 3), delta=8) + double = stamps.render_strokes_image(strokes, "#0000FF", width=3.0, scale=8) + self.assertAlmostEqual(double.width / img.width, 2.0, delta=0.1) + + def test_single_point_stroke_draws_a_dot(self): + img = stamps.render_strokes_image([[(50, 50)]], "#000000", width=3.0, scale=4) + self.assertAlmostEqual(img.width, 12, delta=3) + self.assertAlmostEqual(img.height, 12, delta=3) + self.assertEqual(img.getchannel("A").getpixel((img.width // 2, img.height // 2)), 255) + # points outside the canvas (negative coordinates) are kept + off = stamps.render_strokes_image([[(-20, -5), (30, 10)]], "#000000") + self.assertAlmostEqual(off.width, 4 * (50 + 3), delta=8) + + def test_empty_raises(self): + for strokes in ([], [[]], [[], []]): + with self.subTest(strokes=strokes): + with self.assertRaises(StampError) as cm: + stamps.render_strokes_image(strokes, "#000000") + self.assertEqual(str(cm.exception), "nothing was drawn") + with self.assertRaises(StampError) as cm: + stamps.render_strokes_image([[(1, 1)]], "black") + self.assertEqual(str(cm.exception), "bad color 'black' (expected #RRGGBB)") + + def test_oversized_drawing_raises_stamp_error(self): + # two points a few screens apart: the mask would be gigapixels + real_new = Image.new + for strokes in ([[(0, 0), (30000, 30000)]], [[(0, 0)], [(1e9, 5)]], + [[(0, 0), (float("inf"), 5)]]): + with self.subTest(strokes=strokes): + with mock.patch.object(stamps.Image, "new", side_effect=real_new) as spy: + with self.assertRaises(StampError) as cm: + stamps.render_strokes_image(strokes, "#000000") + self.assertEqual(str(cm.exception), "drawing is too large") + spy.assert_not_called() # refused BEFORE any allocation + # a large canvas crossed corner to corner still renders + img = stamps.render_strokes_image([[(0, 0), (1000, 600)]], "#000000", scale=4) + self.assertGreater(img.width, 3900) + + +class HeadlessImportNewModules(unittest.TestCase): + def test_import_without_tkinter_or_pdfium(self): + code = ("import stamps, profile_store, sys; " + "print([m for m in ('tkinter', 'customtkinter', 'pypdfium2', " + "'sign_pdfs_beid', 'gui', 'i18n') if m in sys.modules])") + out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, + cwd=str(Path(__file__).parent)) + self.assertEqual(out.returncode, 0, out.stderr) + self.assertEqual(out.stdout.strip(), "[]") + + +if __name__ == "__main__": + unittest.main()