diff --git a/.gitignore b/.gitignore index 4fd2cbd..550bd39 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ wine_out/ # --- Misc --- *.log .DS_Store +.serena/ diff --git a/CLAUDE.md b/CLAUDE.md index 14b59e8..7ff197a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,17 +22,27 @@ wraps the same logic as the headless CLI. 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, no git repo. Tests use stdlib `unittest`. -Code comments and CLI/GUI text are in English. +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. ## 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. 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. +- `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 full `CATALOG` + (key → per-language strings). Import-safe **without tkinter**; + `test_i18n.py` enforces that every key exists in every language and that + placeholders match the English reference — add new GUI strings there in + ALL six languages. - `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, @@ -47,10 +57,11 @@ Code comments and CLI/GUI text are in English. 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` — headless - `unittest` suites (no card, no tkinter, no network). +- `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 3.14 virtualenv. +- `venv/` — Python virtualenv (see "tkinter in this environment"). ## Commands @@ -79,7 +90,7 @@ Code comments and CLI/GUI text are in English. ./venv/bin/python -m unittest -v # Syntax check everything: -./venv/bin/python -m py_compile sign_pdfs_beid.py gui.py trust.py azure_signer.py test_sign_pdfs_beid.py test_trust.py test_azure.py +./venv/bin/python -m py_compile sign_pdfs_beid.py gui.py gui_main.py trust.py azure_signer.py i18n.py test_sign_pdfs_beid.py test_trust.py test_azure.py test_i18n.py ``` ### CLI flags @@ -159,7 +170,7 @@ 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 step-4 +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. @@ -198,44 +209,76 @@ reused by the GUI canvas. ## GUI workflow (`gui.py`) -`CachetApp` (a `ctk.CTk`) lays the required steps top-to-bottom in a scrollable -frame: (1) template, (2) files, (3) output, (4) **Validate** → pass/fail table -— plus a **first/last-page selector** (`anchor_row`, default "last page") that -appears only when some files' page count differs from the template; it maps to +`CachetApp` (a `ctk.CTk`) opens on a **landing page** (overview text, language +selector, Start bottom-right — no stepper there); Start builds the **wizard**: +a stepper bar on top, a split body (form on the left inside a +`CTkScrollableFrame`, per-step contextual help `i18n` text on the right), 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 is chosen on the landing +page (`system_language()` default) and fixed for the wizard's lifetime. + +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, -(5) mode (beid/image + **PAdES level selector**, default `b-lta`, + RRN privacy -note), (6) **page + position** — shown for **all** modes -(image mode also reveals the image picker); while the step-4 selector applies, -the preview is **locked** onto the template's first/last page (Prev/Next -no-op, label says "locked") and a position clicked on another page is dropped, -(7) **Launch** (runs `process_batch` -on a worker thread), (8) summary table. +(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; "Full documentation" popup stays English), (6) placement — 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) + Start + progress bar, (8) results report +table. + +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 disabled until the current step is complete; ALL navigation +(stepper, Prev/Next, Cancel) 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 page; a click sets -the position, drawing a to-scale placeholder — the image (image mode) or a **3:1 -box of width page_w/5** (beid). 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. +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)`. Calling +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. A guarded end-to-end test (`GuiImageEndToEnd`, -skipped without a display) covers these paths. +window was closed mid-batch. Guarded end-to-end tests (`GuiImageEndToEnd`, +`GuiWizardChrome`, …, skipped without a display) cover these paths. -The placement section (step 6) is `pack(before=self._after_image_anchor)`-ed so -it sits **between** steps 5 and 7 — `pack()` appends to the end otherwise, which -would misorder the workflow. It shows for **both** modes; the image-picker row -(`self.image_row`) is `pack_forget`-ten in beid mode. +**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 @@ -280,12 +323,15 @@ national register number is embedded in every signature — mind PDF distributio ### tkinter in this environment -The system `python3.14` base lacks the `_tkinter` C extension, so `tkinter` +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.14-tk`. In this checkout the venv has been -**provisioned** with `tkinter/` + `_tkinter*.so` (extracted from the -`python3.14-tk` .deb) into `venv/lib/python3.14/site-packages/`, so `--gui` -works here; recreating the venv requires redoing that (or the apt install). +`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). ## Validating changes without hardware @@ -308,10 +354,15 @@ works here; recreating the venv requires redoing that (or the apt install). 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)`, drive its methods, call - `app.update()`, and screenshot the window by id with ImageMagick +- **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. + CustomTkinter API errors that `py_compile` cannot. 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 and + placeholder parity across the six languages. - **vignette appearance** (beid): render `build_stamp_style(identity)` via `pyhanko.stamp.TextStamp.apply()` onto a copy with an explicit `BoxConstraints(width=_STAMP_W, height=_STAMP_H)`, then rasterize with @@ -327,6 +378,10 @@ PyInstaller builds **two onefile binaries** from one shared spec - **`cachet-cli`** — console (`console=True`), entry `sign_pdfs_beid.py`; headless, **excludes** tkinter/customtkinter 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. + 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`; diff --git a/README.md b/README.md index 7ec591d..4236640 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ before signing, outputs never overwrite existing files, and the whole thing ships as two standalone PyInstaller binaries (windowed GUI + headless CLI) for Linux and Windows. +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; the system language is +picked by default). + > ⚖️ The eID mode uses the card's **non-repudiation** certificate, legally > equivalent to a handwritten signature. The **national register number** is > embedded in every signature produced — mind the distribution of the signed @@ -222,15 +228,22 @@ python3 -m venv venv ## Usage — graphical interface -A vertical wizard walks through the flow: **1.** template → **2.** files → -**3.** output folder → **4.** 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**) → **5.** mode -(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) → -**6.** page + position (actual page preview, click to -place; locked onto the first/last page while the step-4 selector applies) → -**7.** launch → **8.** per-document summary. +The app opens on a **welcome screen** (overview + language selector — +English, French, Dutch, German, Spanish, Portuguese); **Start** launches a +wizard with a stepper on top (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. + +The 8 steps: **1.** 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) → **6.** placement (actual +page preview, click to place, plus a manual **target page** field; preview +locked onto the first/last page while the step-3 selector applies) → +**7.** signing (progress) → **8.** per-document report. --- @@ -267,9 +280,10 @@ Headless `unittest` suite (no card, no tkinter): | `sign_pdfs_beid.py` | core + CLI entry point (business logic, importable without tkinter). | | `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 (façade over the core). | +| `gui.py` | CustomTkinter interface: landing page + 8-step wizard (façade over the core). | +| `i18n.py` | GUI localization catalog (EN/FR/NL/DE/ES/PT). | | `gui_main.py` | entry point of the windowed binary (opens the GUI). | -| `test_sign_pdfs_beid.py`, `test_trust.py`, `test_azure.py` | `unittest` test suites. | +| `test_sign_pdfs_beid.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. | diff --git a/gui.py b/gui.py index 39130d8..2221dc6 100644 --- a/gui.py +++ b/gui.py @@ -1,22 +1,32 @@ #!/usr/bin/env python3 -"""CustomTkinter graphical interface for Cachet. +"""CustomTkinter graphical interface for Cachet — an 8-step wizard. This module is imported ONLY when running `sign_pdfs_beid.py --gui`: it -depends on tkinter/customtkinter, which are absent in CLI/headless mode. All the -business logic (validation, image insertion, eID signing, placement math) lives -in `sign_pdfs_beid.py` and is tested without tkinter; the GUI is merely a -façade that follows the requested workflow: - - 1. Choose the template PDF. 5. Choose the mode (eID | image). - 2. Choose the files. 6. Page + position (both modes; - 3. Choose the output folder. image: also the image choice). - 4. Validate the files. 7. Run. 8. Per-document summary. - -The selection frame (step 6) shows the actual rendering of the template's -current page and resizes with the window; a click there places the signature -(scaled placeholder: image in image mode, 3:1 box of width page/5 in beid -mode). -""" +depends on tkinter/customtkinter, which are absent in CLI/headless mode. All +the business logic (validation, image insertion, eID/azure signing, placement +math) lives in `sign_pdfs_beid.py` and is tested without tkinter; the GUI is +merely a façade. + +Structure: + +* **Landing page** — app overview, language selector (EN/FR/NL/DE/ES/PT via + `i18n.py`), and a Start button. No stepper here. +* **Wizard** — a stepper bar on top (current step highlighted; completed + steps get a light-green border, steps with problems a light-red one, + not-yet-reachable steps are disabled), a split body (form on the left, + contextual help on the right), and a navigation footer whose Previous/Next + labels name the target step. Cancel asks for confirmation, then resets + everything and returns to the landing page. + +The 8 steps: template → documents → validation → output folder → signature +type → placement (page preview + click, plus an explicit target-page field) +→ signing (progress) → results report. + +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 +`queue.Queue` drained by the main thread via a periodic `after()`. The worker +catches `SystemExit` too: `open_eid_session()` raises it (no reader/card) and +it is *not* an `Exception`.""" from __future__ import annotations @@ -29,75 +39,36 @@ import customtkinter as ctk from PIL import Image, ImageTk +import i18n import sign_pdfs_beid as core +from i18n import tr -_FRAME_MAX_W = 360 # max size of the page preview frame (px) +_FRAME_MAX_W = 360 # minimum size of the page preview frame (px) _FRAME_MAX_H = 460 +_HELP_PANEL_W = 380 # right column (contextual help) width, px +_HINT_WRAP = 520 # px; CTk labels do not auto-wrap -# -------------------------------------------------------------------------- -# Step-5 explanation texts (left column: one short hint per input element; -# right column: the mode/level/AES-vs-QES documentation; popup: full glossary) -# -------------------------------------------------------------------------- +# Wizard steps, in order. Each key maps to the i18n entries +# step..short / step..title / step..help and to a +# _build_step_ method. +_STEP_KEYS = ( + "template", "files", "validate", "output", + "mode", "place", "run", "results", +) -_HINT_WRAP = 430 # px; CTk labels do not auto-wrap +# Stepper chip palette (light, dark). +_COL_ACCENT = ("#3B8ED0", "#1F6AA5") # current step +_COL_DONE = ("#6fbf73", "#4e8f52") # completed, no errors: light green +_COL_ERROR = ("#e08a8a", "#a85454") # step with problems: light red +_COL_TODO = ("gray55", "gray45") # pending, reachable +_COL_LOCKED = ("gray80", "gray25") # not reachable yet -_HINT_BEID = ( - "Signs with the citizen's eID card: a QUALIFIED signature (QES), legally " - "equal to a handwritten one. Requires a card reader, the inserted card and " - "one PIN entry per document. ⚠ Embeds your national register number (RRN) " - "in every signature — mind how signed PDFs are distributed." -) -_HINT_AZURE = ( - "Signs with your personal certificate held in Azure Key Vault: an ADVANCED " - "signature (AES). One Microsoft sign-in for the whole batch — no card, no " - "per-document PIN. Needs network access; only the document fingerprint " - "(digest) ever leaves this machine." -) -_HINT_IMAGE = ( - "Pastes a picture onto the page. NOT a cryptographic signature: no legal " - "value, nothing to configure, works fully offline. Use it only when a " - "document merely needs to look signed." -) -_HINT_LEVEL = ( - "Durability of the signature (eID and Azure modes). Keep the default " - "b-lta: the file stays verifiable for decades. Levels above b-b need " - "internet access (timestamp authority, trust lists, OCSP/CRL); choose " - "b-b only to sign offline. See the documentation panel for details." -) -_HINT_VAULT = ( - "Required. The address of your organisation's Azure Key Vault, where your " - "personal signing key and certificate are stored (ask your administrator). " - "Note: the pre-filled https://login.live.com is the Microsoft sign-in " - "page, NOT a vault — a real Key Vault address looks like " - "https://.vault.azure.net; replace it with your organisation's " - "vault before launching." -) -_HINT_KEY = ( - "Optional. Normally the key is derived from YOUR login (sig-), " - "so you can only sign in your own name. Fill this only to use another " - "key — the override is clearly flagged in the run output." -) -_HINT_ANCHORS = ( - "PEM/DER file with your organisation's internal CA chain (root + " - "intermediates). Required for levels b-lt/b-lta and for the post-signing " - "verification: it anchors the trust of your certificate. The EU trusted " - "list is NOT used in Azure mode." -) -_HINT_AUTH = ( - "How you sign in to Microsoft. 'interactive' opens your browser " - "(recommended); 'device-code' shows a code to type on another device " - "(for terminals); 'default' is for automation/testing only — it can pick " - "a service account and break the personal-signature model. Signing in " - "now is optional: otherwise the login simply happens at launch." -) -_DOC_INTRO = ( - "Cachet produces three very different kinds of \"signature\". The summary " - "below explains the modes, the PAdES durability levels and the AES/QES " - "legal tiers — open \"Read more\" for the full glossary of every term " - "and technology used by the app." -) +# -------------------------------------------------------------------------- +# Built-in documentation (English only — the localized per-step help lives in +# i18n.py; this is the in-depth reference opened via "Full documentation"). +# -------------------------------------------------------------------------- _DOC_PANEL = """\ THE THREE MODES @@ -294,13 +265,13 @@ otherwise invisible cryptographic signature. TEMPLATE VALIDATION -An optional safety check: before signing, every input PDF is compared to a model +A safety check: before signing, every input PDF is compared to a model ("template") and must have the same page count and identical page sizes. This guarantees the signature lands in the right spot on every file in the batch. When some files have a DIFFERENT page count (e.g. scanned annexes were added), -a selector appears in step 4: every file is then signed on its own first or -last page — that page must still have exactly the template's page size, so the -position you pick in step 6 is guaranteed to fit. +a selector appears at the validation step: every file is then signed on its +own first or last page — that page must still have exactly the template's page +size, so the position you pick at the placement step is guaranteed to fit. B-LTA RENEWAL B-LTA (the default level) adds an archival timestamp chain so the evidence stays @@ -320,302 +291,783 @@ """ +def _alive(widget) -> bool: + """True if a widget still exists (guards updates after a rebuild).""" + try: + return bool(widget and widget.winfo_exists()) + except Exception: # noqa: BLE001 - interpreter shutting down, etc. + return False + + class CachetApp(ctk.CTk): - """Main window: the whole workflow in a scrollable view.""" + """Main window: landing page + 8-step wizard.""" def __init__(self, args): super().__init__() - self.title(f"Cachet {core.__version__} — PDF signing") - self.geometry("1180x950") # two-column step 5 needs the width + self._apply_title() + self.geometry("1180x950") _style = ttk.Style() _style.configure("Treeview", rowheight=30, font=("", 11)) # tall rows, full text _style.configure("Treeview.Heading", font=("", 11, "bold")) - # --- state --- + # --- persistent widgets/state containers ------------------------- self.default_lib = getattr(args, "lib", None) + # 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.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._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 + + self._reset_state() + + # Config edits invalidate a previous run + refresh the stepper. + self.mode_var.trace_add("write", lambda *_: self._on_config_edit()) + self.pades_level_var.trace_add("write", lambda *_: self._on_config_edit()) + + self._screen = ctk.CTkFrame(self, fg_color="transparent") + self._screen.pack(fill="both", expand=True) + self._show_landing() + self.bind("", self._on_resize) # the canvas grows with the window + + # ------------------------------------------------------------- state + 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).""" 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.mode_var = ctk.StringVar(value="beid") - self.pades_level_var = ctk.StringVar(value="b-lta") # PAdES level - # Page anchor (step 4): when validation finds files whose page count - # differs from the template, this selector decides whether EVERY file - # is signed on its own first or last page (default: last, matching - # the historical vignette position). Hidden while all counts match. - self.anchor_choice_var = ctk.StringVar(value="last page") + 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 + # 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 - # azure mode state (CACHET_AZURE_* env vars pre-fill the panel). - self.azure_vault_var = ctk.StringVar( - value=os.environ.get(core.ENV_AZURE_VAULT_URL, "https://login.live.com")) - self.azure_key_var = ctk.StringVar( - value=os.environ.get(core.ENV_AZURE_KEY_NAME, "")) - self.azure_auth_var = ctk.StringVar(value="interactive") # GUI default + 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_login_q: queue.Queue | None = None - self.image_path: Path | None = None - self.cur_page = 0 # 0-based, for the preview - self.place_page: int | None = None # 1-based, chosen position - self.place_x: float | None = None - self.place_y: float | None = None - self._canvas_img = None # PhotoImage ref of the placeholder - self._bg_img = None # PhotoImage ref of the page background - self._page_img_cache: dict = {} # (template, page) -> full-resolution PIL - self._last_win_size = 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") + + def _invalidate_run(self) -> None: + self.run_rows = [] + self.run_results = None + self.run_error = None + + def _on_config_edit(self) -> None: + if self._running: + return + self._invalidate_run() + self._refresh_chrome() + + # ============================================================ LANDING + def _clear_screen(self) -> None: + for w in self._screen.winfo_children(): + w.destroy() + self._stepper_btns = [] + self.canvas = None + + def _show_landing(self) -> None: + self._clear_screen() + page = ctk.CTkFrame(self._screen, fg_color="transparent") + page.pack(fill="both", expand=True, padx=28, pady=20) + + # Top bar: language selector on the right (no stepper on this page). + top = ctk.CTkFrame(page, fg_color="transparent") + top.pack(fill="x") + self._lang_menu = ctk.CTkOptionMenu( + top, width=150, + values=[i18n.LANGUAGE_NAMES[c] for c in i18n.LANGUAGES], + command=self._on_language_change, + ) + self._lang_menu.set(i18n.LANGUAGE_NAMES[i18n.get_language()]) + self._lang_menu.pack(side="right", padx=8) + ctk.CTkLabel(top, text=tr("landing.language") + ":").pack(side="right", padx=(0, 2)) + + # Center: overview of what the app does. + body = ctk.CTkFrame(page, fg_color="transparent") + body.pack(fill="both", expand=True) + ctk.CTkLabel(body, text=tr("landing.heading"), + font=ctk.CTkFont(size=28, weight="bold")).pack(pady=(60, 4)) + ctk.CTkLabel(body, text=f"Cachet {core.__version__}", + text_color=("gray40", "gray60"), + font=ctk.CTkFont(size=12)).pack(pady=(0, 24)) + ctk.CTkLabel(body, text=tr("landing.intro"), justify="left", + wraplength=760, font=ctk.CTkFont(size=14)).pack(padx=40) + + # Bottom bar: Start in the bottom-right corner. + bottom = ctk.CTkFrame(page, fg_color="transparent") + bottom.pack(fill="x", side="bottom") + ctk.CTkButton(bottom, text=tr("landing.start") + " ▸", width=170, height=40, + font=ctk.CTkFont(size=15, weight="bold"), + command=self._start_wizard).pack(side="right", pady=8) + + def _on_language_change(self, display_name: str) -> None: + for code, name in i18n.LANGUAGE_NAMES.items(): + if name == display_name: + i18n.set_language(code) + break + self._apply_title() + self._show_landing() # re-render the landing texts + + def _start_wizard(self) -> None: + self._reset_state() + self._build_wizard() + self._goto_step(0) + + # ============================================================= WIZARD + def _build_wizard(self) -> None: + self._clear_screen() + outer = ctk.CTkFrame(self._screen, fg_color="transparent") + outer.pack(fill="both", expand=True, padx=12, pady=(10, 8)) + + # --- stepper bar (top) ------------------------------------------- + stepper = ctk.CTkFrame(outer) + stepper.pack(fill="x") + self._stepper_btns = [] + for i, key in enumerate(_STEP_KEYS): + btn = ctk.CTkButton( + stepper, text=f"{i + 1}. {tr(f'step.{key}.short')}", + height=34, corner_radius=8, border_width=2, + font=ctk.CTkFont(size=12), + command=lambda i=i: self._goto_step(i), + ) + btn.pack(side="left", fill="x", expand=True, padx=3, pady=6) + self._stepper_btns.append(btn) + + # --- navigation footer (bottom) ---------------------------------- + footer = ctk.CTkFrame(outer) + footer.pack(fill="x", side="bottom", pady=(8, 0)) + self._btn_cancel = ctk.CTkButton( + footer, text=tr("nav.cancel"), width=120, + fg_color="transparent", border_width=1, + text_color=("gray20", "gray80"), + command=self._cancel_wizard) + self._btn_cancel.pack(side="left", padx=8, pady=8) + self._btn_next = ctk.CTkButton(footer, width=250, command=self._nav_next) + self._btn_next.pack(side="right", padx=8, pady=8) + self._btn_prev = ctk.CTkButton( + footer, width=250, fg_color="transparent", border_width=1, + text_color=("gray20", "gray80"), command=self._nav_prev) + self._btn_prev.pack(side="right", padx=8, pady=8) + + # --- split body: form left, contextual help right ---------------- + body = ctk.CTkFrame(outer, fg_color="transparent") + body.pack(fill="both", expand=True, pady=(8, 0)) + right = ctk.CTkFrame(body, width=_HELP_PANEL_W) + right.pack(side="right", fill="y", padx=(8, 0)) + right.pack_propagate(False) + ctk.CTkLabel(right, text=tr("help.heading"), + font=ctk.CTkFont(size=14, weight="bold") + ).pack(anchor="w", padx=12, pady=(10, 2)) + self._help_box = ctk.CTkTextbox(right, wrap="word", font=ctk.CTkFont(size=12)) + self._help_box.pack(fill="both", expand=True, padx=10, pady=(0, 10)) + self._help_box.configure(state="disabled") + + left = ctk.CTkFrame(body) + left.pack(side="left", fill="both", expand=True) + self._step_header = ctk.CTkLabel(left, text="", + font=ctk.CTkFont(size=17, weight="bold")) + self._step_header.pack(anchor="w", padx=14, pady=(10, 0)) + self._content_left = ctk.CTkScrollableFrame(left, fg_color="transparent") + self._content_left.pack(fill="both", expand=True, padx=6, pady=6) + + # ------------------------------------------------------ step routing + def _first_incomplete(self) -> int: + for i in range(len(_STEP_KEYS)): + if not self._step_complete(i): + return i + return len(_STEP_KEYS) + + def _goto_step(self, idx: int) -> None: + if self._running or not self._stepper_btns: + return + idx = max(0, min(len(_STEP_KEYS) - 1, idx)) + if idx > self._first_incomplete(): # inaccessible (locked) + return + self.step_index = idx + key = _STEP_KEYS[idx] + for w in self._content_left.winfo_children(): + w.destroy() + self.canvas = None # rebuilt by the place step + self._step_header.configure(text=tr( + "step.header", n=idx + 1, total=len(_STEP_KEYS), + title=tr(f"step.{key}.title"))) + self._help_box.configure(state="normal") + self._help_box.delete("1.0", "end") + self._help_box.insert("1.0", tr(f"step.{key}.help")) + self._help_box.configure(state="disabled") + getattr(self, f"_build_step_{key}")(self._content_left) + if key == "validate" and self.validation_results is None: + self._validate() # auto-run on first entry + self._refresh_chrome() + + def _nav_next(self) -> None: + if self.step_index == len(_STEP_KEYS) - 1: + self._finish() + else: + self._goto_step(self.step_index + 1) - root = ctk.CTkScrollableFrame(self) - root.pack(fill="both", expand=True, padx=12, pady=12) - self._build_steps(root) - self._refresh_placement_section() - self.bind("", self._on_resize) # the canvas grows with the window + def _nav_prev(self) -> None: + self._goto_step(self.step_index - 1) - # ------------------------------------------------------------------ UI - def _build_steps(self, root) -> None: - def header(txt): - lbl = ctk.CTkLabel(root, text=txt, font=ctk.CTkFont(size=15, weight="bold")) - lbl.pack(anchor="w", pady=(12, 2)) - return lbl + def _finish(self) -> None: + self._reset_state() + self._show_landing() - # 1. template - header("1. Template PDF") - row = ctk.CTkFrame(root, fg_color="transparent"); row.pack(fill="x") - ctk.CTkButton(row, text="Choose template…", command=self._pick_template).pack(side="left") - self.template_lbl = ctk.CTkLabel(row, text="(none)"); self.template_lbl.pack(side="left", padx=10) - - # 2. files - header("2. Files to sign") - row = ctk.CTkFrame(root, fg_color="transparent"); row.pack(fill="x") - ctk.CTkButton(row, text="Choose files…", command=self._pick_inputs).pack(side="left") - self.inputs_lbl = ctk.CTkLabel(row, text="(none)"); self.inputs_lbl.pack(side="left", padx=10) - - # 3. output - header("3. Output folder") - row = ctk.CTkFrame(root, fg_color="transparent"); row.pack(fill="x") - ctk.CTkButton(row, text="Choose folder…", command=self._pick_output).pack(side="left") - self.output_lbl = ctk.CTkLabel(row, text="(none)"); self.output_lbl.pack(side="left", padx=10) - - # 4. validation - header("4. Validation (page count + exact dimensions)") - ctk.CTkButton(root, text="Validate files", command=self._validate).pack(anchor="w") - self.valid_table = self._make_table(root, ("File", "Result", "Detail"), height=5) - # Shown by _validate() only when some files' page count differs from - # the template: the whole batch is then signed on each file's - # first/last page (packed before the step-5 header to keep the order). - self.anchor_row = ctk.CTkFrame(root, fg_color="transparent") - ctk.CTkLabel( - self.anchor_row, - text="Some files have a different page count than the template — " - "sign every file on its:", - ).pack(side="left") - ctk.CTkOptionMenu(self.anchor_row, variable=self.anchor_choice_var, - values=["last page", "first page"], width=130, - command=lambda _choice: self._validate() - ).pack(side="left", padx=8) - ctk.CTkLabel(self.anchor_row, text="(pick the position on that page in step 6)", - text_color=("gray25", "gray70")).pack(side="left") - - # 5. mode — split vertically: controls + per-input explanations on the - # LEFT, the "which signature / which level / AES vs QES" docs on the - # RIGHT (with a Read-more popup for the full glossary). - self._step5_header = header("5. Signing mode") - split = ctk.CTkFrame(root, fg_color="transparent") - split.pack(fill="x", pady=(0, 4)) - split.grid_columnconfigure(0, weight=1, uniform="step5") - split.grid_columnconfigure(1, weight=1, uniform="step5") - - left = ctk.CTkFrame(split) - left.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) - - def hint(parent, txt, **pack_kw): - # Small gray explanation under each control: what it is, why we - # need it, whether it is required. - lbl = ctk.CTkLabel(parent, text=txt, justify="left", anchor="w", + def _cancel_wizard(self) -> None: + if self._running: + return + win = ctk.CTkToplevel(self) + win.title(tr("cancel.title")) + win.geometry("480x190") + win.transient(self) + win.resizable(False, False) + ctk.CTkLabel(win, text=tr("cancel.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", + 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(): + win.destroy() + self._reset_state() + self._show_landing() + + ctk.CTkButton(row, text=tr("cancel.confirm"), 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, + 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()) + + # ------------------------------------------- completion / error state + def _mode_error(self) -> str | None: + if self.mode_var.get() == "azure": + if not self.azure_vault.strip(): + return tr("azure.vault_missing") + lvl = self.pades_level_var.get() + needs_anchors = core.level_needs_ltv(lvl) or core.level_needs_timestamp(lvl) + if needs_anchors and not self.azure_anchors_path: + return tr("azure.anchors_missing", level=lvl) + return None + + def _page_entry_error(self) -> str | None: + if 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): + return tr("place.page_invalid") + return None + + def _place_error(self) -> str | None: + 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") + return None + + def _step_complete(self, idx: int) -> bool: + key = _STEP_KEYS[idx] + if key == "template": + return bool(self.template_path and self.template_dims) + if key == "files": + return bool(self.input_paths) + if key == "validate": + return self.validation_results is not None and bool(self.valid_paths) + if key == "output": + return self.output_dir is not None + if key == "mode": + return self._mode_error() is None + if key == "place": + return self._place_error() is None + # run + results: reachable/complete once a batch has finished. + return self.run_results is not None and not self._running + + def _step_error(self, idx: int) -> str | None: + key = _STEP_KEYS[idx] + if key == "template": + return (tr("tpl.unreadable", error=self.template_error) + if self.template_error else None) + if key == "validate": + if self.validation_results is not None and any( + not r.ok for r in self.validation_results): + ok = len(self.valid_paths) + return tr("val.summary", ok=ok, total=len(self.validation_results)) + return None + if key == "mode": + return self._mode_error() + if key == "place": + return self._place_error() + if key in ("run", "results"): + if self.run_error: + return tr("run.error", error=self.run_error) + if self.run_results is not None and any(not r.ok for r in self.run_results): + ok = sum(1 for r in self.run_results if r.ok) + return tr("res.partial", ok=ok, total=len(self.run_results), + fail=len(self.run_results) - ok) + return None + + def _refresh_chrome(self) -> None: + """Stepper colors + footer buttons, from the current state.""" + if not self._stepper_btns or not _alive(self._stepper_btns[0]): + return + first_inc = self._first_incomplete() + for i, btn in enumerate(self._stepper_btns): + accessible = (i == self.step_index) if self._running else (i <= first_inc) + error = self._step_error(i) is not None + # "Completed" (green) only applies to steps the user has passed: + # a step beyond the first incomplete one is merely unreached, + # even if its defaults would already satisfy the conditions. + complete = self._step_complete(i) and i < max(first_inc, self.step_index + 1) + current = i == self.step_index + if not accessible: + border = _COL_LOCKED + elif error: + border = _COL_ERROR + elif complete: + border = _COL_DONE + else: + border = _COL_TODO + btn.configure( + state="normal" if accessible else "disabled", + border_color=border, + fg_color=_COL_ACCENT if current else "transparent", + text_color=("white", "white") if current else ("gray10", "gray90"), + ) + + i = self.step_index + last = len(_STEP_KEYS) - 1 + if i == 0: + self._btn_prev.configure(text=tr("nav.previous_plain"), state="disabled") + else: + self._btn_prev.configure( + text=tr("nav.previous", step=tr(f"step.{_STEP_KEYS[i - 1]}.title")), + state="disabled" if self._running else "normal") + if i == last: + self._btn_next.configure( + text=tr("nav.finish"), + state="disabled" if self._running else "normal") + else: + can_next = self._step_complete(i) and not self._running + self._btn_next.configure( + text=tr("nav.next", step=tr(f"step.{_STEP_KEYS[i + 1]}.title")), + state="normal" if can_next else "disabled") + self._btn_cancel.configure(state="disabled" if self._running else "normal") + + # ------------------------------------------------------------ tables + def _make_table(self, parent, columns: list[tuple[str, int]], height: int): + """ttk table; ``columns`` = [(localized heading, width px), …].""" + 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.pack(fill="x", pady=4, padx=8) + return table + + @staticmethod + def _clear(table) -> None: + for item in table.get_children(): + table.delete(item) + + # ===================================================== step 1: template + def _build_step_template(self, parent) -> None: + row = ctk.CTkFrame(parent, fg_color="transparent") + row.pack(fill="x", pady=(12, 4), padx=8) + ctk.CTkButton(row, text=tr("tpl.choose"), width=200, + command=self._pick_template).pack(side="left") + self.template_lbl = ctk.CTkLabel(row, text="", justify="left", + anchor="w", wraplength=440) + self.template_lbl.pack(side="left", padx=12) + self._update_template_label() + + def _update_template_label(self) -> None: + if not _alive(getattr(self, "template_lbl", None)): + return + if self.template_error: + text = tr("tpl.unreadable", error=self.template_error) + elif self.template_path: + text = tr("tpl.selected", name=self.template_path.name, + pages=len(self.template_dims)) + else: + text = tr("common.none") + self.template_lbl.configure(text=text) + + def _pick_template(self) -> None: + path = filedialog.askopenfilename(title=tr("step.template.title"), + filetypes=[("PDF", "*.pdf")]) + if not path: + return + self.template_path = Path(path) + self.template_error = None + self._page_img_cache.clear() # new template -> new renders + try: + self.template_dims = core.page_dimensions(self.template_path) + except Exception as exc: # noqa: BLE001 + self.template_dims = [] + self.template_error = str(exc) + # A new template invalidates validation, placement and results. + self.validation_results = None + self.valid_paths = [] + self.cur_page = 0 + self._reset_position() + self._invalidate_run() + self._update_template_label() + self._refresh_chrome() + + # ======================================================= step 2: files + def _build_step_files(self, parent) -> None: + row = ctk.CTkFrame(parent, fg_color="transparent") + row.pack(fill="x", pady=(12, 4), padx=8) + ctk.CTkButton(row, text=tr("files.choose"), width=200, + command=self._pick_inputs).pack(side="left") + self.inputs_lbl = ctk.CTkLabel(row, text="") + self.inputs_lbl.pack(side="left", padx=12) + self.files_box = ctk.CTkTextbox(parent, height=300, font=ctk.CTkFont(size=12)) + self.files_box.pack(fill="both", expand=True, padx=8, pady=6) + self._update_files_widgets() + + def _update_files_widgets(self) -> None: + if _alive(getattr(self, "inputs_lbl", None)): + self.inputs_lbl.configure( + text=tr("files.count", count=len(self.input_paths)) + if self.input_paths else tr("common.none")) + if _alive(getattr(self, "files_box", None)): + self.files_box.configure(state="normal") + self.files_box.delete("1.0", "end") + self.files_box.insert("1.0", "\n".join(p.name for p in self.input_paths)) + self.files_box.configure(state="disabled") + + def _pick_inputs(self) -> None: + paths = filedialog.askopenfilenames(title=tr("step.files.title"), + filetypes=[("PDF", "*.pdf")]) + if not paths: + return + self.input_paths = [Path(p) for p in paths] + self.validation_results = None # new files -> revalidate + self.valid_paths = [] + self._invalidate_run() + self._update_files_widgets() + self._refresh_chrome() + + # ================================================== step 3: validation + def _build_step_validate(self, parent) -> None: + row = ctk.CTkFrame(parent, fg_color="transparent") + row.pack(fill="x", pady=(12, 4), padx=8) + ctk.CTkButton(row, text=tr("val.revalidate"), width=200, + command=self._validate).pack(side="left") + self.val_status_lbl = ctk.CTkLabel(row, text="", justify="left", + anchor="w", wraplength=420) + self.val_status_lbl.pack(side="left", padx=12) + self.valid_table = self._make_table( + parent, + [(tr("val.col_file"), 240), (tr("val.col_result"), 110), + (tr("val.col_detail"), 360)], + height=10, + ) + # Shown by _update_validation_widgets() only when some files' page + # count differs from the template: the whole batch is then signed on + # each file's own first/last page (core: RunConfig.page_anchor). + self.anchor_row = ctk.CTkFrame(parent, fg_color="transparent") + inner = ctk.CTkFrame(self.anchor_row, fg_color="transparent") + inner.pack(fill="x") + ctk.CTkLabel(inner, text=tr("val.anchor_label"), + justify="left", anchor="w", wraplength=440 + ).pack(side="left") + self.anchor_menu = ctk.CTkOptionMenu( + inner, width=180, + values=[tr("anchor.opt_last"), tr("anchor.opt_first")], + command=self._on_anchor_menu) + self.anchor_menu.pack(side="left", padx=8) + ctk.CTkLabel(self.anchor_row, text=tr("val.anchor_hint"), + justify="left", anchor="w", wraplength=620, + text_color=("gray25", "gray70"), + font=ctk.CTkFont(size=11)).pack(fill="x", pady=(2, 0)) + self._update_validation_widgets() + + def _page_anchor(self) -> str | None: + """"first"/"last" while the validation-step selector applies, else + None (all page counts match the template).""" + return self.anchor_choice if self.count_mismatch else None + + def _on_anchor_menu(self, display: str) -> None: + code = "first" if display == tr("anchor.opt_first") else "last" + self._set_page_anchor_choice(code) + + def _set_page_anchor_choice(self, code: str) -> None: + """Switch the first/last anchor and re-validate (the acceptance of + page-count mismatches depends on the anchor page).""" + self.anchor_choice = code if code in core.PAGE_ANCHORS else "last" + if self.validation_results is not None: + self._validate() + else: + self._sync_anchor_page() + self._refresh_chrome() + + def _validate(self) -> None: + """Compare every chosen document with the template (page count + + exact per-page dimensions); rejected files are excluded from the + batch. Runs automatically when the step is first shown. Files whose + page COUNT differs are accepted iff their first/last (anchor) page + matches the template's — the selector below the table picks which.""" + if not self.template_path or not self.input_paths or not self.template_dims: + return + + def count_differs(p) -> bool: + try: + return len(core.page_dimensions(p)) != len(self.template_dims) + except Exception: # noqa: BLE001 - unreadable: reported below + return False + + self.count_mismatch = any(count_differs(p) for p in self.input_paths) + anchor = self._page_anchor() + self.validation_results = [ + core.validate_against_template(self.template_dims, p, + page_anchor=anchor) + for p in self.input_paths + ] + self.valid_paths = [r.path for r in self.validation_results if r.ok] + self._invalidate_run() + self._update_validation_widgets() + self._sync_anchor_page() + self._refresh_chrome() + + 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.""" + 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._set_page_entry("") + 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 "—")) + if _alive(getattr(self, "val_status_lbl", None)): + if self.validation_results is None: + self.val_status_lbl.configure(text="") + else: + text = tr("val.summary", ok=len(self.valid_paths), + total=len(self.validation_results)) + if not self.valid_paths: + text += " " + tr("val.none_valid") + self.val_status_lbl.configure(text=text) + if _alive(getattr(self, "anchor_row", None)): + self.anchor_menu.set(tr(f"anchor.opt_{self.anchor_choice}")) + if self.count_mismatch: + self.anchor_row.pack(fill="x", pady=(2, 4), padx=8) + else: + self.anchor_row.pack_forget() + + # ====================================================== step 4: output + def _build_step_output(self, parent) -> None: + row = ctk.CTkFrame(parent, fg_color="transparent") + row.pack(fill="x", pady=(12, 4), padx=8) + ctk.CTkButton(row, text=tr("out.choose"), width=200, + command=self._pick_output).pack(side="left") + self.output_lbl = ctk.CTkLabel( + row, text=str(self.output_dir) if self.output_dir else tr("common.none"), + justify="left", anchor="w", wraplength=440) + self.output_lbl.pack(side="left", padx=12) + + def _pick_output(self) -> None: + path = filedialog.askdirectory(title=tr("step.output.title")) + if not path: + return + self.output_dir = Path(path) + self._invalidate_run() + if _alive(getattr(self, "output_lbl", None)): + self.output_lbl.configure(text=str(self.output_dir)) + self._refresh_chrome() + + # ======================================================== step 5: mode + def _build_step_mode(self, parent) -> None: + def hint(container, key, **pack_kw): + lbl = ctk.CTkLabel(container, text=tr(key), justify="left", anchor="w", wraplength=_HINT_WRAP, text_color=("gray25", "gray70"), font=ctk.CTkFont(size=11)) - lbl.pack(fill="x", padx=(28, 8), **pack_kw) + lbl.pack(fill="x", padx=(30, 8), **pack_kw) return lbl - # Radios stacked VERTICALLY, one explanation each. - ctk.CTkRadioButton(left, text="eID (card + vignette) — qualified, QES", - variable=self.mode_var, value="beid", - command=self._refresh_placement_section - ).pack(anchor="w", padx=8, pady=(10, 0)) - hint(left, _HINT_BEID, pady=(2, 8)) - ctk.CTkRadioButton(left, text="Azure (Microsoft login) — advanced, AES", - variable=self.mode_var, value="azure", - command=self._refresh_placement_section - ).pack(anchor="w", padx=8) - hint(left, _HINT_AZURE, pady=(2, 8)) - ctk.CTkRadioButton(left, text="Image insertion — visual stamp only", - variable=self.mode_var, value="image", - command=self._refresh_placement_section - ).pack(anchor="w", padx=8) - hint(left, _HINT_IMAGE, pady=(2, 8)) - - # PAdES level (used by the eID and Azure modes). - lrow = ctk.CTkFrame(left, fg_color="transparent") - lrow.pack(fill="x", padx=8) - ctk.CTkLabel(lrow, text="PAdES level:").pack(side="left") + box = ctk.CTkFrame(parent, fg_color="transparent") + box.pack(fill="x", pady=(10, 0)) + for value, label_key, hint_key in ( + ("beid", "mode.beid", "mode.beid_hint"), + ("azure", "mode.azure", "mode.azure_hint"), + ("image", "mode.image", "mode.image_hint")): + ctk.CTkRadioButton(box, text=tr(label_key), variable=self.mode_var, + value=value, command=self._on_mode_change + ).pack(anchor="w", padx=8, pady=(8, 0)) + hint(box, hint_key, pady=(2, 4)) + + lrow = ctk.CTkFrame(box, fg_color="transparent") + lrow.pack(fill="x", padx=8, pady=(8, 0)) + ctk.CTkLabel(lrow, text=tr("mode.level")).pack(side="left") ctk.CTkOptionMenu(lrow, variable=self.pades_level_var, values=list(core.PADES_LEVELS), width=110 ).pack(side="left", padx=8) - hint(left, _HINT_LEVEL, pady=(2, 10)) + hint(box, "mode.level_hint", pady=(2, 8)) - # Azure panel (shown only in azure mode; see _refresh_placement_section). - self.azure_section = ctk.CTkFrame(left, fg_color="transparent") + # Azure panel (visible only in azure mode). + self.azure_section = ctk.CTkFrame(box, fg_color="transparent") az = self.azure_section - ctk.CTkLabel(az, text="Azure settings", + ctk.CTkLabel(az, text=tr("azure.settings"), font=ctk.CTkFont(size=13, weight="bold") - ).pack(anchor="w", padx=8) + ).pack(anchor="w", padx=8, pady=(6, 0)) arow1 = ctk.CTkFrame(az, fg_color="transparent") arow1.pack(fill="x", padx=8, pady=(4, 0)) - ctk.CTkLabel(arow1, text="Vault URL:", width=150, anchor="w").pack(side="left") - ctk.CTkEntry(arow1, textvariable=self.azure_vault_var).pack( - side="left", fill="x", expand=True, padx=(4, 0)) - hint(az, _HINT_VAULT, pady=(2, 6)) + ctk.CTkLabel(arow1, text=tr("azure.vault"), width=170, anchor="w").pack(side="left") + self.azure_vault_entry = ctk.CTkEntry(arow1) + self.azure_vault_entry.insert(0, self.azure_vault) + self.azure_vault_entry.pack(side="left", fill="x", expand=True, padx=(4, 8)) + hint(az, "azure.vault_hint", pady=(2, 6)) arow2 = ctk.CTkFrame(az, fg_color="transparent") arow2.pack(fill="x", padx=8) - ctk.CTkLabel(arow2, text="Key name (override):", width=150, anchor="w" - ).pack(side="left") - ctk.CTkEntry(arow2, textvariable=self.azure_key_var).pack( - side="left", fill="x", expand=True, padx=(4, 0)) - hint(az, _HINT_KEY, pady=(2, 6)) + ctk.CTkLabel(arow2, text=tr("azure.key"), width=170, anchor="w").pack(side="left") + self.azure_key_entry = ctk.CTkEntry(arow2) + self.azure_key_entry.insert(0, self.azure_key) + self.azure_key_entry.pack(side="left", fill="x", expand=True, padx=(4, 8)) + hint(az, "azure.key_hint", pady=(2, 6)) + for entry in (self.azure_vault_entry, self.azure_key_entry): + for seq in ("", "", ""): + entry.bind(seq, lambda _e: self._on_azure_entry_edit()) arow3 = ctk.CTkFrame(az, fg_color="transparent") arow3.pack(fill="x", padx=8) - ctk.CTkButton(arow3, text="Internal CA chain (PEM)…", width=180, + ctk.CTkButton(arow3, text=tr("azure.anchors"), width=210, command=self._pick_azure_anchors).pack(side="left") self.azure_anchors_lbl = ctk.CTkLabel( - arow3, text=str(self.azure_anchors_path or "(none chosen)")) + arow3, text=str(self.azure_anchors_path or tr("azure.anchors_none")), + justify="left", anchor="w", wraplength=340) self.azure_anchors_lbl.pack(side="left", padx=8) - hint(az, _HINT_ANCHORS, pady=(2, 6)) + hint(az, "azure.anchors_hint", pady=(2, 6)) arow4 = ctk.CTkFrame(az, fg_color="transparent") arow4.pack(fill="x", padx=8) - ctk.CTkLabel(arow4, text="Auth method:", width=150, anchor="w" - ).pack(side="left") + ctk.CTkLabel(arow4, text=tr("azure.auth"), width=170, anchor="w").pack(side="left") ctk.CTkOptionMenu(arow4, variable=self.azure_auth_var, values=list(core.AZURE_AUTH_METHODS), width=140 ).pack(side="left", padx=(4, 12)) self.azure_login_btn = ctk.CTkButton( - arow4, text="Sign in with Microsoft", command=self._azure_sign_in) + arow4, text=tr("azure.signin"), command=self._azure_sign_in) self.azure_login_btn.pack(side="left") - self.azure_login_lbl = ctk.CTkLabel(arow4, text="(not signed in)") + self.azure_login_lbl = ctk.CTkLabel( + arow4, + text=tr("azure.signed_in", upn=self.azure_user_upn) + if self.azure_user_upn else tr("azure.not_signed_in")) self.azure_login_lbl.pack(side="left", padx=8) - hint(az, _HINT_AUTH, pady=(2, 8)) - - # RIGHT column: documentation panel + Read-more popup. - right = ctk.CTkFrame(split) - right.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) - ctk.CTkLabel(right, text="Which signature should I use?", - font=ctk.CTkFont(size=14, weight="bold") - ).pack(anchor="w", padx=10, pady=(10, 2)) - ctk.CTkLabel(right, text=_DOC_INTRO, justify="left", anchor="w", - wraplength=_HINT_WRAP, - font=ctk.CTkFont(size=11)).pack(fill="x", padx=10) - self.doc_box = ctk.CTkTextbox(right, wrap="word", height=330, - font=ctk.CTkFont(size=12)) - self.doc_box.pack(fill="both", expand=True, padx=10, pady=(6, 2)) - self.doc_box.insert("1.0", _DOC_PANEL) - self.doc_box.configure(state="disabled") - ctk.CTkButton(right, text="Read more — full documentation…", - command=self._show_docs_popup - ).pack(anchor="e", padx=10, pady=(2, 10)) - - # 6. page + position (BOTH modes; the image choice appears only in image mode) - self.image_section = ctk.CTkFrame(root) - ctk.CTkLabel(self.image_section, text="6. Page + position", - font=ctk.CTkFont(size=15, weight="bold")).pack(anchor="w", pady=(4, 2)) - self.image_row = ctk.CTkFrame(self.image_section, fg_color="transparent") - self.image_row.pack(fill="x") - ctk.CTkButton(self.image_row, text="Choose image…", command=self._pick_image).pack(side="left") - self.image_lbl = ctk.CTkLabel(self.image_row, text="(none)"); self.image_lbl.pack(side="left", padx=10) - - self._nav_row = ctk.CTkFrame(self.image_section, fg_color="transparent") - self._nav_row.pack(fill="x", pady=4) - ctk.CTkButton(self._nav_row, text="◀ Previous", width=110, - command=lambda: self._turn_page(-1)).pack(side="left") - self.page_lbl = ctk.CTkLabel(self._nav_row, text="page —/—"); self.page_lbl.pack(side="left", padx=10) - ctk.CTkButton(self._nav_row, text="Next ▶", width=110, - command=lambda: self._turn_page(1)).pack(side="left") - self.pos_lbl = ctk.CTkLabel(self._nav_row, text="position: (click in the frame)") - self.pos_lbl.pack(side="left", padx=16) - - # tkinter Canvas: background = rendered page, click = position. Grows with the window. - import tkinter as tk - self.canvas = tk.Canvas(self.image_section, width=_FRAME_MAX_W, height=_FRAME_MAX_H, - bg="#d9d9d9", highlightthickness=1, highlightbackground="#888") - self.canvas.pack(pady=6, fill="both", expand=True) - self.canvas.bind("", self._on_canvas_click) - - # 7. run (anchor: the image section is placed JUST before this header) - self._after_image_anchor = header("7. Run") - self.launch_btn = ctk.CTkButton(root, text="Run", - command=self._launch, fg_color="#2a7", hover_color="#196") - self.launch_btn.pack(anchor="w") - self.status_lbl = ctk.CTkLabel(root, text=""); self.status_lbl.pack(anchor="w", pady=2) - - # 8. summary - header("8. Summary") - self.summary_table = self._make_table(root, ("Document", "Signed", "Detail"), height=8) - - def _make_table(self, parent, columns, height): - table = ttk.Treeview(parent, columns=columns, show="headings", height=height) - widths = {"File": 220, "Document": 220, "Detail": 380} - for c in columns: - table.heading(c, text=c) - table.column(c, width=widths.get(c, 90), anchor="w") - table.pack(fill="x", pady=4) - return table - - @staticmethod - def _clear(table): - for item in table.get_children(): - table.delete(item) - - def _refresh_placement_section(self) -> None: - # Placement section visible in ALL modes (vignette OR image). Placed - # BEFORE "7. Run" to respect the workflow order. - self.image_section.pack(fill="both", expand=True, pady=6, before=self._after_image_anchor) - if self.mode_var.get() == "image": - self.image_row.pack(fill="x", before=self._nav_row) # image choice - else: - self.image_row.pack_forget() # beid/azure: no image + hint(az, "azure.auth_hint", pady=(2, 8)) + + ctk.CTkButton(box, text=tr("docs.more"), width=260, + fg_color="transparent", border_width=1, + text_color=("gray20", "gray80"), + command=self._show_docs_popup).pack(anchor="w", padx=8, pady=(6, 8)) + self._refresh_azure_visibility() + + def _on_mode_change(self) -> None: + # (the mode_var trace already invalidates the run + refreshes chrome) + self._refresh_azure_visibility() + + def _on_azure_entry_edit(self) -> None: + if _alive(getattr(self, "azure_vault_entry", None)): + self.azure_vault = self.azure_vault_entry.get() + if _alive(getattr(self, "azure_key_entry", None)): + self.azure_key = self.azure_key_entry.get() + self._on_config_edit() + + def _refresh_azure_visibility(self) -> None: + if not _alive(getattr(self, "azure_section", None)): + return if self.mode_var.get() == "azure": - # lives at the end of the step-5 LEFT column -> plain pack works - self.azure_section.pack(fill="x", pady=(0, 8)) + self.azure_section.pack(fill="x", pady=(0, 4)) else: self.azure_section.pack_forget() - self._draw_page() - # ---------------------------------------------------------- documentation def _show_docs_popup(self) -> None: - """'Read more' window: the full glossary of every term/technology.""" + """'Full documentation' window (English): modes, levels, glossary.""" win = getattr(self, "_docs_win", None) if win is not None and win.winfo_exists(): win.lift() win.focus() return win = ctk.CTkToplevel(self) - win.title("Cachet — Documentation") + win.title(tr("docs.title")) win.geometry("840x780") box = ctk.CTkTextbox(win, wrap="word", font=ctk.CTkFont(size=13)) box.pack(fill="both", expand=True, padx=12, pady=12) - box.insert("1.0", _FULL_DOC) + box.insert("1.0", _DOC_PANEL + "\n\n" + _FULL_DOC) box.configure(state="disabled") self._docs_win = win # ------------------------------------------------------------ azure auth def _pick_azure_anchors(self) -> None: path = filedialog.askopenfilename( - title="Internal CA chain (root + intermediates)", + title=tr("azure.anchors"), filetypes=[("Certificates", "*.pem *.crt *.cer *.der"), ("All files", "*.*")]) - if path: - self.azure_anchors_path = Path(path) + if not path: + return + self.azure_anchors_path = Path(path) + if _alive(getattr(self, "azure_anchors_lbl", None)): self.azure_anchors_lbl.configure(text=path) + self._invalidate_run() + self._refresh_chrome() def _azure_sign_in(self) -> None: """Interactive Microsoft login on a WORKER thread (system browser / @@ -624,7 +1076,7 @@ def _azure_sign_in(self) -> None: process-wide (azure_signer.get_cached_credential), so the batch reuses this login instead of prompting again.""" self.azure_login_btn.configure(state="disabled") - self.azure_login_lbl.configure(text="signing in…") + self.azure_login_lbl.configure(text=tr("azure.signing_in")) self._azure_login_q = queue.Queue() method = self.azure_auth_var.get() @@ -644,10 +1096,7 @@ def worker(q: queue.Queue) -> None: def _poll_azure_login(self) -> None: if self._azure_login_q is None: return - try: - if not self.winfo_exists(): # window closed mid-login - return - except Exception: # noqa: BLE001 + if not _alive(self): # window closed mid-login return try: kind, payload = self._azure_login_q.get_nowait() @@ -655,123 +1104,173 @@ def _poll_azure_login(self) -> None: self.after(100, self._poll_azure_login) return self._azure_login_q = None - self.azure_login_btn.configure(state="normal") if kind == "ok": - self.azure_login_lbl.configure(text=f"signed in as {payload}") - else: - self.azure_login_lbl.configure(text="(not signed in)") - self.status_lbl.configure(text=f"Microsoft sign-in failed: {payload}") - - # -------------------------------------------------------------- actions - def _pick_template(self) -> None: - path = filedialog.askopenfilename(title="Template PDF", filetypes=[("PDF", "*.pdf")]) - if not path: - return - self.template_path = Path(path) - self._page_img_cache.clear() # new template -> new renders - try: - self.template_dims = core.page_dimensions(self.template_path) - except Exception as exc: # noqa: BLE001 - self.template_dims = [] - self.template_lbl.configure(text=f"unreadable: {exc}") - return - self.cur_page = 0 - self.template_lbl.configure( - text=f"{self.template_path.name} ({len(self.template_dims)} pages)" - ) + self.azure_user_upn = payload + # The user may have navigated away meanwhile: only touch the step-5 + # widgets if they still exist (the state above survives rebuilds). + if _alive(getattr(self, "azure_login_btn", None)): + self.azure_login_btn.configure(state="normal") + if _alive(getattr(self, "azure_login_lbl", None)): + if kind == "ok": + self.azure_login_lbl.configure( + text=tr("azure.signed_in", upn=payload)) + else: + self.azure_login_lbl.configure( + text=tr("azure.signin_failed", error=payload)) + + # =================================================== step 6: placement + def _build_step_place(self, parent) -> None: + 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) + + 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") + self.page_entry = ctk.CTkEntry(nav, width=64, justify="center") + 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") + self.page_lbl = ctk.CTkLabel(nav, text="") + self.page_lbl.pack(side="left", padx=10) + self.page_next_btn = ctk.CTkButton(nav, text=tr("place.next"), width=140, + command=lambda: self._turn_page(1)) + self.page_next_btn.pack(side="left") + if self._page_anchor(): + # the anchor fixes the signature page per document: manual page + # and preview navigation are locked (see _sync_anchor_page); the + # useless page buttons are hidden so the row stays uncluttered + self.page_entry.configure(state="disabled") + self.page_prev_btn.configure(state="disabled") + self.page_next_btn.configure(state="disabled") + 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, + text_color=("#b3261e", "#e08a8a")) + self.place_warn_lbl.pack(side="left", padx=16) + + # tkinter Canvas: background = rendered template page, click = position. + 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.bind("", self._on_canvas_click) + self._update_place_labels() self._draw_page() - def _pick_inputs(self) -> None: - paths = filedialog.askopenfilenames(title="Files to sign", filetypes=[("PDF", "*.pdf")]) - if not paths: - return - self.input_paths = [Path(p) for p in paths] - self.inputs_lbl.configure(text=f"{len(self.input_paths)} file(s)") - - def _pick_output(self) -> None: - path = filedialog.askdirectory(title="Output folder") - if not path: - return - self.output_dir = Path(path) - self.output_lbl.configure(text=str(self.output_dir)) - def _pick_image(self) -> None: path = filedialog.askopenfilename( - title="Signature image", filetypes=[("Images", "*.png *.jpg *.jpeg *.gif *.bmp")] - ) + title=tr("place.image_choose"), + filetypes=[("Images", "*.png *.jpg *.jpeg *.gif *.bmp")]) if not path: return self.image_path = Path(path) - self.image_lbl.configure(text=self.image_path.name) + if _alive(getattr(self, "image_lbl", None)): + self.image_lbl.configure(text=self.image_path.name) + self._invalidate_run() + self._update_place_labels() self._draw_page() - - def _page_anchor(self) -> str | None: - """"first"/"last" while the step-4 selector applies, else None.""" - if not self.count_mismatch: - return None - return "first" if self.anchor_choice_var.get().startswith("first") else "last" - - def _validate(self) -> None: - self._clear(self.valid_table) - self.valid_paths = [] - if not self.template_path or not self.input_paths: - self.status_lbl.configure(text="Choose a template and files first.") - return - try: - template_dims = core.page_dimensions(self.template_path) - except Exception as exc: # noqa: BLE001 - self.status_lbl.configure(text=f"Template unreadable: {exc}") + self._refresh_chrome() + + def _set_page_entry(self, text: str) -> None: + """Programmatic update of the target-page field (click, reset).""" + self.page_text = text + if _alive(getattr(self, "page_entry", 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: return - - # Page-count mismatches decide whether the first/last selector applies - # (unreadable files are reported by validate_against_template below). - def count_differs(p) -> bool: - try: - return len(core.page_dimensions(p)) != len(template_dims) - except Exception: # noqa: BLE001 - return False - - self.count_mismatch = any(count_differs(p) for p in self.input_paths) - if self.count_mismatch: - self.anchor_row.pack(fill="x", pady=(2, 0), before=self._step5_header) - else: - self.anchor_row.pack_forget() - + 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: + 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() + self._invalidate_run() + self._update_place_labels() + self._refresh_chrome() + + def _update_place_labels(self) -> None: anchor = self._page_anchor() - for p in self.input_paths: - r = core.validate_against_template(template_dims, p, page_anchor=anchor) - self.valid_table.insert("", "end", - values=(r.path.name, "✓ OK" if r.ok else "✗ rejected", r.reason or "—")) - if r.ok: - self.valid_paths.append(r.path) - self.status_lbl.configure( - text=f"{len(self.valid_paths)}/{len(self.input_paths)} valid file(s)." - ) - self._sync_anchor_page() - - def _sync_anchor_page(self) -> None: - """Lock the step-6 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.""" - anchor = self._page_anchor() - if anchor and self.template_dims: - 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.pos_lbl.configure(text="position: (click in the frame)") - self._draw_page() + 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", + 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) + 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") + self.place_warn_lbl.configure(text=warn) def _turn_page(self, delta: int) -> None: if not self.template_dims or self._page_anchor(): - return # anchored: the page is locked by the step-4 selector + return # anchored: the preview is locked on the anchor page self.cur_page = max(0, min(len(self.template_dims) - 1, self.cur_page + delta)) self._draw_page() 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() - 130) - h = max(260, int(self.winfo_height() * 0.55)) + w = max(320, self.winfo_width() - _HELP_PANEL_W - 160) + h = max(260, int(self.winfo_height() * 0.45)) return w, h def _get_page_image(self, page_index): @@ -786,7 +1285,7 @@ def _get_page_image(self, page_index): return self._page_img_cache[key] def _draw_page(self) -> None: - if not hasattr(self, "canvas") or not self.template_dims: + if not _alive(self.canvas) or not self.template_dims: return anchor = self._page_anchor() if anchor: # preview locked onto the page that carries the signature @@ -797,7 +1296,7 @@ def _draw_page(self) -> None: 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 - pil = self._get_page_image(self.cur_page) # background = actual page rendering + pil = self._get_page_image(self.cur_page) # background = actual page render if pil is not None: self._bg_img = ImageTk.PhotoImage( pil.resize((max(1, int(fw)), max(1, int(fh)))) @@ -805,20 +1304,21 @@ def _draw_page(self) -> None: self.canvas.create_image(ox, oy, anchor="nw", image=self._bg_img) self.canvas.create_rectangle(ox, oy, ox + fw, oy + fh, outline="#333") else: # render unavailable -> blank frame - self.canvas.create_rectangle(ox, oy, ox + fw, oy + fh, fill="white", outline="#333") - self.page_lbl.configure( - text=f"page {self.cur_page + 1}/{len(self.template_dims)}" - + (f" — locked: {anchor} page" if anchor else "") - ) + self.canvas.create_rectangle(ox, oy, ox + fw, oy + fh, + fill="white", outline="#333") + if _alive(getattr(self, "page_lbl", None)): + label = tr("place.preview_page", cur=self.cur_page + 1, + total=len(self.template_dims)) + if anchor: + label += tr("place.locked_suffix", + 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) def _on_canvas_click(self, event) -> None: - # the vignette (beid AND azure) or the image is placed by clicking - if self.mode_var.get() not in ("image", "beid", "azure") or not self.template_dims: - return - if not hasattr(self, "_frame_geom"): + 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 @@ -827,14 +1327,16 @@ def _on_canvas_click(self, event) -> None: 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 - anchor = self._page_anchor() - where = f"{anchor} page" if anchor else f"page {self.place_page}" - self.pos_lbl.configure(text=f"position: {where} @ ({x:.0f}, {y:.0f}) pt") + 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 mode).""" + 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) @@ -854,7 +1356,8 @@ def _draw_placeholder(self, x, y) -> None: 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) + self.canvas.create_rectangle(left, top, left + w, top + h, + outline="#c00", width=2) def _on_resize(self, event) -> None: # the canvas follows the window size, keeping the page proportions @@ -864,28 +1367,81 @@ def _on_resize(self, event) -> None: if size == self._last_win_size: return self._last_win_size = size - if self.template_dims: + if self.template_dims and _alive(self.canvas): self._draw_page() - # --------------------------------------------------------------- launch + # ========================================================= step 7: run + def _build_step_run(self, parent) -> None: + box = ctk.CTkFrame(parent, fg_color="transparent") + box.pack(fill="x", pady=(12, 4), padx=8) + mode = self.mode_var.get() + lines = [ + tr("run.summary_docs", count=len(self.valid_paths)), + tr("run.summary_mode", mode=tr(f"mode.{mode}")), + ] + 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: + 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}") + 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)) + if anchor: + lines.append(tr(f"run.summary_anchor_{anchor}")) + lines.append(tr("run.summary_output", output=self.output_dir)) + ctk.CTkLabel(box, text="\n".join(lines), justify="left", anchor="w", + wraplength=640, + font=ctk.CTkFont(size=13)).pack(anchor="w") + note_key = {"beid": "run.pin_note", "azure": "run.azure_note"}.get(mode) + if note_key: + ctk.CTkLabel(box, text=tr(note_key), justify="left", + wraplength=620, + text_color=("gray25", "gray70"), + font=ctk.CTkFont(size=11)).pack(anchor="w", pady=(6, 0)) + + self.launch_btn = ctk.CTkButton(parent, text=tr("run.start"), + width=240, height=40, + font=ctk.CTkFont(size=14, weight="bold"), + fg_color="#2a7", hover_color="#196", + command=self._launch) + self.launch_btn.pack(anchor="w", padx=8, pady=(14, 4)) + self.progress = ctk.CTkProgressBar(parent, width=520) + self.progress.set(0) + self.progress.pack(anchor="w", padx=8, pady=(10, 2)) + self.run_status_lbl = ctk.CTkLabel(parent, text="", justify="left", + anchor="w", wraplength=640) + self.run_status_lbl.pack(anchor="w", padx=8, pady=2) + if self.run_results is not None: # returning after a batch + ok = sum(1 for r in self.run_results if r.ok) + self.progress.set(1) + self.run_status_lbl.configure( + text=tr("run.done", ok=ok, total=len(self.run_results))) + elif self.run_error: + self.run_status_lbl.configure( + text=tr("run.error", error=self.run_error)) + def _launch(self) -> None: - files = self.valid_paths or self.input_paths - if not files or not self.output_dir: - self.status_lbl.configure(text="Validated files and an output folder are required.") + if self._running or not self.valid_paths or not self.output_dir: return - if self.mode_var.get() == "image" and (self.image_path is None or self.place_x is None): - self.status_lbl.configure( - text="Image mode: choose an image, then click in the frame to set the position." - ) - return - # beid: a click places the vignette; without a click, default vignette - # (bottom-right, last page). So we pass place_* as-is (None if no - # click) — process_batch derives the placement from it. With the - # step-4 anchor active, the page is resolved PER DOCUMENT (first/last) - # instead of the fixed clicked page. + # 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. anchor = self._page_anchor() cfg = core.RunConfig( - inputs=files, + inputs=list(self.valid_paths), output=self.output_dir, mode=self.mode_var.get(), template=self.template_path, @@ -899,19 +1455,22 @@ def _launch(self) -> 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. - azure_vault_url=self.azure_vault_var.get().strip() or None, - azure_key_name=self.azure_key_var.get().strip() or None, + azure_vault_url=self.azure_vault.strip() or None, + azure_key_name=self.azure_key.strip() or None, azure_auth=self.azure_auth_var.get(), azure_trust_anchors=self.azure_anchors_path, ) try: core.validate_config(cfg) except ValueError as exc: - self.status_lbl.configure(text=str(exc)) + self.run_status_lbl.configure(text=tr("run.error", error=exc)) return - self._clear(self.summary_table) - self.status_lbl.configure(text="Processing…") + self._invalidate_run() + self._running = True self.launch_btn.configure(state="disabled") # avoids concurrent batches + self.progress.set(0) + self.run_status_lbl.configure(text=tr("run.working")) + self._refresh_chrome() # locks all navigation # Tkinter is not thread-safe: the worker writes ONLY to a queue, # and the main thread drains it via a periodic after(). self._result_q = queue.Queue() @@ -933,35 +1492,82 @@ def _run_batch(self, cfg) -> None: def _poll_results(self) -> None: # main thread: drain the queue and update the widgets. - if not self.winfo_exists(): # window closed during processing + if not _alive(self): # window closed during processing return + total = max(1, len(self.valid_paths)) try: while True: kind, payload = self._result_q.get_nowait() if kind == "row": - self.summary_table.insert( - "", "end", - values=(payload.path.name, "✓" if payload.ok else "✗", payload.detail), - ) + self.run_rows.append(payload) + if _alive(getattr(self, "progress", None)): + self.progress.set(len(self.run_rows) / total) + if _alive(getattr(self, "run_status_lbl", None)): + self.run_status_lbl.configure(text=tr( + "run.progress", done=len(self.run_rows), + total=total, name=payload.path.name)) elif kind == "done": - ok = sum(1 for r in payload if r.ok) - self.status_lbl.configure( - text=f"Done: {ok}/{len(payload)} document(s) processed." - ) - self.launch_btn.configure(state="normal") + self.run_results = payload + self._running = False + if _alive(getattr(self, "launch_btn", None)): + self.launch_btn.configure(state="normal") + self._goto_step(_STEP_KEYS.index("results")) return elif kind == "error": - self.status_lbl.configure(text=f"Error: {payload}") - self.launch_btn.configure(state="normal") + self.run_error = payload + self._running = False + if _alive(getattr(self, "launch_btn", None)): + self.launch_btn.configure(state="normal") + if _alive(getattr(self, "run_status_lbl", None)): + self.run_status_lbl.configure( + text=tr("run.error", error=payload)) + self._refresh_chrome() return except queue.Empty: pass self.after(100, self._poll_results) + # ===================================================== step 8: results + def _build_step_results(self, parent) -> None: + results = self.run_results or [] + ok = sum(1 for r in results if r.ok) + if self.run_error: + headline = tr("run.error", error=self.run_error) + elif ok == len(results): + headline = tr("res.all_ok", total=len(results)) + else: + headline = tr("res.partial", ok=ok, total=len(results), + fail=len(results) - ok) + box = ctk.CTkFrame(parent, fg_color="transparent") + box.pack(fill="x", pady=(12, 4), padx=8) + ctk.CTkLabel(box, text=headline, + font=ctk.CTkFont(size=13, weight="bold"), + justify="left", anchor="w", wraplength=640).pack(anchor="w") + ctk.CTkLabel(box, text=tr("run.summary_output", output=self.output_dir), + justify="left", anchor="w", wraplength=640 + ).pack(anchor="w", pady=(2, 0)) + if self.mode_var.get() == "beid": + ctk.CTkLabel(box, text=tr("res.rrn_note"), justify="left", + wraplength=620, + text_color=("gray25", "gray70"), + font=ctk.CTkFont(size=11)).pack(anchor="w", pady=(6, 0)) + self.summary_table = self._make_table( + parent, + [(tr("res.col_doc"), 240), (tr("res.col_status"), 130), + (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)) + 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) app.mainloop() return 0 diff --git a/i18n.py b/i18n.py new file mode 100644 index 0000000..01b8479 --- /dev/null +++ b/i18n.py @@ -0,0 +1,1708 @@ +#!/usr/bin/env python3 +"""Localization catalog for the Cachet GUI. + +Import-safe WITHOUT tkinter (plain dicts + helpers), so it is unit-testable +headlessly like the core. The GUI is the only consumer; the CLI stays in +English (its output is technical/log-like and documented that way). + +Usage: + from i18n import tr, set_language, system_language + set_language(system_language()) + label = tr("landing.start") + text = tr("val.summary", ok=3, total=4) + +Every key carries all six languages (EN default/fallback, FR, NL, DE, ES, +PT); ``test_i18n.py`` enforces catalog completeness and that the ``{...}`` +placeholders of every translation match the English reference. Texts only +use braces for placeholders — ``tr`` formats when kwargs are passed. +""" + +from __future__ import annotations + +import os + +#: Supported language codes, in menu order. +LANGUAGES = ("en", "fr", "nl", "de", "es", "pt") + +#: Native display name of each language (for the landing-page selector). +LANGUAGE_NAMES = { + "en": "English", + "fr": "Français", + "nl": "Nederlands", + "de": "Deutsch", + "es": "Español", + "pt": "Português", +} + +DEFAULT_LANGUAGE = "en" + +_current = DEFAULT_LANGUAGE + + +def get_language() -> str: + """Code of the language currently in use.""" + return _current + + +def set_language(code: str) -> None: + """Switch the active language; raises ValueError on unsupported codes.""" + global _current + if code not in LANGUAGES: + raise ValueError(f"Unsupported language: {code!r} (expected {'|'.join(LANGUAGES)})") + _current = code + + +def detect_language(raw: str | None) -> str: + """Map a locale-ish string ("fr_BE.UTF-8", "nl", "de_DE@euro") to a + supported language code, defaulting to English. Pure (unit-tested).""" + if not raw: + return DEFAULT_LANGUAGE + code = raw.strip().lower()[:2] + return code if code in LANGUAGES else DEFAULT_LANGUAGE + + +def system_language() -> str: + """Best-effort system language from the usual environment variables + (LC_ALL > LC_MESSAGES > LANG), falling back to locale.getlocale().""" + raw = ( + os.environ.get("LC_ALL") + or os.environ.get("LC_MESSAGES") + or os.environ.get("LANG") + ) + if not raw: + try: + import locale + + raw = locale.getlocale()[0] + except Exception: # noqa: BLE001 - never fail app start over locale + raw = None + return detect_language(raw) + + +def tr(key: str, **fmt) -> str: + """Translation of ``key`` in the active language (English fallback); + unknown keys return the key itself so a miss is visible, never fatal.""" + entry = CATALOG.get(key) + if entry is None: + return key + text = entry.get(_current) or entry[DEFAULT_LANGUAGE] + return text.format(**fmt) if fmt else text + + +# ========================================================================= +# Catalog — key -> {language -> text}. Languages always in the order +# en, fr, nl, de, es, pt. Braces are reserved for placeholders. +# ========================================================================= + +CATALOG: dict[str, dict[str, str]] = { + # ------------------------------------------------------------- window + "app.title": { + "en": "Cachet {version} — PDF signing", + "fr": "Cachet {version} — Signature de PDF", + "nl": "Cachet {version} — PDF's ondertekenen", + "de": "Cachet {version} — PDF-Signatur", + "es": "Cachet {version} — Firma de PDF", + "pt": "Cachet {version} — Assinatura de PDF", + }, + # ------------------------------------------------------------ landing + "landing.heading": { + "en": "Welcome to Cachet", + "fr": "Bienvenue dans Cachet", + "nl": "Welkom bij Cachet", + "de": "Willkommen bei Cachet", + "es": "Le damos la bienvenida a Cachet", + "pt": "Damos-lhe as boas-vindas ao Cachet", + }, + "landing.intro": { + "en": ( + "Cachet signs a whole batch of PDF documents in one run. Sign with " + "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." + ), + "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." + ), + "nl": ( + "Cachet ondertekent een hele reeks PDF-documenten in één keer. " + "Onderteken met uw Belgische eID-kaart (een gekwalificeerde " + "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." + ), + "de": ( + "Cachet signiert einen ganzen Stapel PDF-Dokumente in einem " + "Durchgang. Signieren Sie mit Ihrer belgischen eID-Karte (eine " + "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." + ), + "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 " + "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." + ), + "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." + ), + }, + "landing.language": { + "en": "Language", + "fr": "Langue", + "nl": "Taal", + "de": "Sprache", + "es": "Idioma", + "pt": "Idioma", + }, + "landing.start": { + "en": "Start", + "fr": "Commencer", + "nl": "Starten", + "de": "Starten", + "es": "Comenzar", + "pt": "Começar", + }, + # --------------------------------------------------------- navigation + "nav.next": { + "en": "Next: {step} ▸", + "fr": "Suivant : {step} ▸", + "nl": "Volgende: {step} ▸", + "de": "Weiter: {step} ▸", + "es": "Siguiente: {step} ▸", + "pt": "Seguinte: {step} ▸", + }, + "nav.previous": { + "en": "◂ Previous: {step}", + "fr": "◂ Précédent : {step}", + "nl": "◂ Vorige: {step}", + "de": "◂ Zurück: {step}", + "es": "◂ Anterior: {step}", + "pt": "◂ Anterior: {step}", + }, + "nav.previous_plain": { + "en": "◂ Previous", + "fr": "◂ Précédent", + "nl": "◂ Vorige", + "de": "◂ Zurück", + "es": "◂ Anterior", + "pt": "◂ Anterior", + }, + "nav.finish": { + "en": "Finish", + "fr": "Terminer", + "nl": "Voltooien", + "de": "Fertigstellen", + "es": "Finalizar", + "pt": "Concluir", + }, + "nav.cancel": { + "en": "Cancel", + "fr": "Annuler", + "nl": "Annuleren", + "de": "Abbrechen", + "es": "Cancelar", + "pt": "Cancelar", + }, + "step.header": { + "en": "Step {n} of {total} — {title}", + "fr": "Étape {n} sur {total} — {title}", + "nl": "Stap {n} van {total} — {title}", + "de": "Schritt {n} von {total} — {title}", + "es": "Paso {n} de {total} — {title}", + "pt": "Passo {n} de {total} — {title}", + }, + "help.heading": { + "en": "Help", + "fr": "Aide", + "nl": "Hulp", + "de": "Hilfe", + "es": "Ayuda", + "pt": "Ajuda", + }, + # ------------------------------------------------------- cancel modal + "cancel.title": { + "en": "Cancel and start over?", + "fr": "Annuler et recommencer ?", + "nl": "Annuleren en opnieuw beginnen?", + "de": "Abbrechen und neu beginnen?", + "es": "¿Cancelar y empezar de nuevo?", + "pt": "Cancelar e recomeçar?", + }, + "cancel.body": { + "en": ( + "This discards everything entered so far — selected files, " + "settings and results — and returns to the welcome screen." + ), + "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." + ), + "nl": ( + "Hiermee wordt alles gewist wat u tot nu toe hebt ingevoerd — " + "geselecteerde bestanden, instellingen en resultaten — en keert u " + "terug naar het welkomstscherm." + ), + "de": ( + "Dadurch werden alle bisherigen Eingaben verworfen — ausgewählte " + "Dateien, Einstellungen und Ergebnisse — und Sie kehren zum " + "Startbildschirm zurück." + ), + "es": ( + "Esto descarta todo lo introducido hasta ahora — archivos " + "seleccionados, ajustes y resultados — y vuelve a la pantalla de " + "bienvenida." + ), + "pt": ( + "Isto elimina tudo o que foi introduzido até agora — ficheiros " + "selecionados, definições e resultados — e regressa ao ecrã " + "inicial." + ), + }, + "cancel.confirm": { + "en": "Yes, discard", + "fr": "Oui, tout effacer", + "nl": "Ja, wissen", + "de": "Ja, verwerfen", + "es": "Sí, descartar", + "pt": "Sim, eliminar", + }, + "cancel.keep": { + "en": "No, continue", + "fr": "Non, continuer", + "nl": "Nee, doorgaan", + "de": "Nein, fortfahren", + "es": "No, continuar", + "pt": "Não, continuar", + }, + # ------------------------------------------------- step names + help + "step.template.short": { + "en": "Template", "fr": "Modèle", "nl": "Sjabloon", + "de": "Vorlage", "es": "Plantilla", "pt": "Modelo", + }, + "step.template.title": { + "en": "Select Template", + "fr": "Choisir le modèle", + "nl": "Sjabloon kiezen", + "de": "Vorlage wählen", + "es": "Seleccionar plantilla", + "pt": "Escolher o modelo", + }, + "step.template.help": { + "en": ( + "The template is the reference document: every file you sign is " + "compared against it. Choose the blank PDF your documents are " + "based on. Its pages also serve as the preview when you place the " + "signature later. Only files whose page count and page sizes " + "match the template exactly will be signed." + ), + "fr": ( + "Le modèle est le document de référence : chaque fichier à signer " + "lui est comparé. Choisissez le PDF vierge dont vos documents sont " + "issus. Ses pages servent aussi d'aperçu au moment de positionner " + "la signature. Seuls les fichiers dont le nombre de pages et les " + "dimensions correspondent exactement au modèle seront signés." + ), + "nl": ( + "Het sjabloon is het referentiedocument: elk te ondertekenen " + "bestand wordt ermee vergeleken. Kies de lege PDF waarop uw " + "documenten zijn gebaseerd. De pagina's dienen ook als voorbeeld " + "bij het plaatsen van de handtekening. Alleen bestanden waarvan " + "het aantal pagina's en de paginagrootte exact met het sjabloon " + "overeenkomen, worden ondertekend." + ), + "de": ( + "Die Vorlage ist das Referenzdokument: Jede zu signierende Datei " + "wird mit ihr verglichen. Wählen Sie das leere PDF, auf dem Ihre " + "Dokumente basieren. Seine Seiten dienen später auch als Vorschau " + "beim Platzieren der Signatur. Nur Dateien, deren Seitenzahl und " + "Seitengrößen exakt mit der Vorlage übereinstimmen, werden " + "signiert." + ), + "es": ( + "La plantilla es el documento de referencia: cada archivo a " + "firmar se compara con ella. Elija el PDF en blanco del que " + "parten sus documentos. Sus páginas sirven además de vista previa " + "al colocar la firma. Solo se firmarán los archivos cuyo número " + "de páginas y dimensiones coincidan exactamente con la plantilla." + ), + "pt": ( + "O modelo é o documento de referência: cada ficheiro a assinar é " + "comparado com ele. Escolha o PDF em branco na origem dos seus " + "documentos. As suas páginas servem também de pré-visualização ao " + "posicionar a assinatura. Só serão assinados os ficheiros cujo " + "número de páginas e dimensões correspondam exatamente ao modelo." + ), + }, + "step.files.short": { + "en": "Documents", "fr": "Documents", "nl": "Documenten", + "de": "Dokumente", "es": "Documentos", "pt": "Documentos", + }, + "step.files.title": { + "en": "Select Documents", + "fr": "Choisir les documents", + "nl": "Documenten kiezen", + "de": "Dokumente wählen", + "es": "Seleccionar documentos", + "pt": "Escolher os documentos", + }, + "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." + ), + "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." + ), + "nl": ( + "Kies alle PDF-documenten die u in deze reeks wilt ondertekenen. " + "Dezelfde handtekening, positie 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 " + "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." + ), + "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 " + "documentos que não correspondam ao modelo serão excluídos no " + "passo seguinte." + ), + }, + "step.validate.short": { + "en": "Validation", "fr": "Validation", "nl": "Validatie", + "de": "Prüfung", "es": "Validación", "pt": "Validação", + }, + "step.validate.title": { + "en": "Validate & Summary", + "fr": "Validation et récapitulatif", + "nl": "Valideren en overzicht", + "de": "Prüfung und Übersicht", + "es": "Validación y resumen", + "pt": "Validação e resumo", + }, + "step.validate.help": { + "en": ( + "Each document is compared with the template: it must have the " + "same number of pages and exactly the same page sizes. Documents " + "that differ are rejected and will not be signed. Go back to " + "adjust the template or the document list if needed — at least " + "one valid document is required to continue. If some documents " + "have a different page count, a selector lets you sign each file " + "on its own first or last page — that page must still match the " + "template exactly." + ), + "fr": ( + "Chaque document est comparé au modèle : il doit avoir le même " + "nombre de pages et exactement les mêmes dimensions de pages. Les " + "documents différents sont rejetés et ne seront pas signés. " + "Revenez en arrière pour ajuster le modèle ou la liste des " + "documents si nécessaire — au moins un document valide est requis " + "pour continuer. Si certains documents n'ont pas le même nombre " + "de pages, un sélecteur permet de signer chaque fichier sur sa " + "première ou sa dernière page — cette page doit toujours " + "correspondre exactement au modèle." + ), + "nl": ( + "Elk document wordt met het sjabloon vergeleken: het moet " + "hetzelfde aantal pagina's en exact dezelfde paginagrootten " + "hebben. Afwijkende documenten worden geweigerd en niet " + "ondertekend. Ga zo nodig terug om het sjabloon of de " + "documentlijst aan te passen — er is minstens één geldig document " + "nodig om verder te gaan. Als sommige documenten een ander aantal " + "pagina's hebben, kunt u met een keuzelijst elk bestand op zijn " + "eerste of laatste pagina ondertekenen — die pagina moet nog " + "steeds exact met het sjabloon overeenkomen." + ), + "de": ( + "Jedes Dokument wird mit der Vorlage verglichen: Es muss dieselbe " + "Seitenzahl und exakt dieselben Seitengrößen haben. Abweichende " + "Dokumente werden abgelehnt und nicht signiert. Gehen Sie bei " + "Bedarf zurück, um die Vorlage oder die Dokumentliste anzupassen " + "— mindestens ein gültiges Dokument ist erforderlich, um " + "fortzufahren. Haben einige Dokumente eine andere Seitenzahl, " + "lässt ein Auswahlfeld jede Datei auf ihrer ersten oder letzten " + "Seite signieren — diese Seite muss weiterhin exakt der Vorlage " + "entsprechen." + ), + "es": ( + "Cada documento se compara con la plantilla: debe tener el mismo " + "número de páginas y exactamente las mismas dimensiones. Los " + "documentos diferentes se rechazan y no se firmarán. Vuelva atrás " + "para ajustar la plantilla o la lista de documentos si es " + "necesario — se necesita al menos un documento válido para " + "continuar. Si algunos documentos tienen un número de páginas " + "distinto, un selector permite firmar cada archivo en su primera " + "o última página — esa página debe seguir coincidiendo " + "exactamente con la plantilla." + ), + "pt": ( + "Cada documento é comparado com o modelo: deve ter o mesmo número " + "de páginas e exatamente as mesmas dimensões. Os documentos " + "diferentes são rejeitados e não serão assinados. Volte atrás " + "para ajustar o modelo ou a lista de documentos, se necessário — " + "é preciso pelo menos um documento válido para continuar. Se " + "alguns documentos tiverem um número de páginas diferente, um " + "seletor permite assinar cada ficheiro na sua primeira ou última " + "página — essa página deve continuar a corresponder exatamente " + "ao modelo." + ), + }, + "step.output.short": { + "en": "Output", "fr": "Sortie", "nl": "Uitvoer", + "de": "Ausgabe", "es": "Salida", "pt": "Saída", + }, + "step.output.title": { + "en": "Select Output Folder", + "fr": "Choisir le dossier de sortie", + "nl": "Uitvoermap kiezen", + "de": "Ausgabeordner wählen", + "es": "Seleccionar carpeta de salida", + "pt": "Escolher a pasta de saída", + }, + "step.output.help": { + "en": ( + "Choose the folder where the signed documents will be written. " + "Each result keeps the original name with the suffix “_signe”. " + "Existing files are never overwritten: if a name is already " + "taken, a numbered variant is created instead." + ), + "fr": ( + "Choisissez le dossier où les documents signés seront écrits. " + "Chaque résultat garde le nom d'origine suivi du suffixe " + "« _signe ». Les fichiers existants ne sont jamais écrasés : si " + "un nom est déjà pris, une variante numérotée est créée." + ), + "nl": ( + "Kies de map waarin de ondertekende documenten worden " + "weggeschreven. Elk resultaat behoudt de oorspronkelijke naam met " + "het achtervoegsel “_signe”. Bestaande bestanden worden nooit " + "overschreven: is een naam al in gebruik, dan wordt een " + "genummerde variant aangemaakt." + ), + "de": ( + "Wählen Sie den Ordner, in den die signierten Dokumente " + "geschrieben werden. Jedes Ergebnis behält den ursprünglichen " + "Namen mit dem Suffix „_signe“. Bestehende Dateien werden nie " + "überschrieben: Ist ein Name bereits vergeben, wird eine " + "nummerierte Variante erstellt." + ), + "es": ( + "Elija la carpeta donde se escribirán los documentos firmados. " + "Cada resultado conserva el nombre original con el sufijo " + "«_signe». Los archivos existentes nunca se sobrescriben: si un " + "nombre ya está ocupado, se crea una variante numerada." + ), + "pt": ( + "Escolha a pasta onde os documentos assinados serão gravados. " + "Cada resultado mantém o nome original com o sufixo «_signe». Os " + "ficheiros existentes nunca são substituídos: se um nome já " + "estiver ocupado, é criada uma variante numerada." + ), + }, + "step.mode.short": { + "en": "Signature", "fr": "Signature", "nl": "Handtekening", + "de": "Signatur", "es": "Firma", "pt": "Assinatura", + }, + "step.mode.title": { + "en": "Select Signature Type", + "fr": "Choisir le type de signature", + "nl": "Handtekeningtype kiezen", + "de": "Signaturtyp wählen", + "es": "Seleccionar tipo de firma", + "pt": "Escolher o tipo de assinatura", + }, + "step.mode.help": { + "en": ( + "eID (QES) — a qualified signature with your Belgian identity " + "card, legally equivalent to a handwritten one. Needs a card " + "reader and one PIN entry per document; your national register " + "number is embedded in every signature.\n\n" + "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" + "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 glossary is available in English under “Full " + "documentation”." + ), + "fr": ( + "eID (QES) — signature qualifiée avec votre carte d'identité " + "belge, juridiquement équivalente à une signature manuscrite. " + "Nécessite un lecteur de carte et un code PIN par document ; " + "votre numéro de registre national est intégré dans chaque " + "signature.\n\n" + "Azure (AES) — signature avancée avec votre certificat personnel " + "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 " + "cryptographique, sans valeur juridique ; fonctionne entièrement " + "hors ligne.\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. Le glossaire " + "complet est disponible en anglais sous « Documentation " + "complète »." + ), + "nl": ( + "eID (QES) — een gekwalificeerde handtekening met uw Belgische " + "identiteitskaart, juridisch gelijkwaardig aan een handgeschreven " + "handtekening. Vereist een kaartlezer en één pincode per " + "document; uw rijksregisternummer wordt in elke handtekening " + "opgenomen.\n\n" + "Azure (AES) — een geavanceerde handtekening met uw persoonlijke " + "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 " + "handtekening, zonder juridische waarde; werkt volledig " + "offline.\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 woordenlijst is in het Engels beschikbaar onder " + "“Volledige documentatie”." + ), + "de": ( + "eID (QES) — eine qualifizierte Signatur mit Ihrer belgischen " + "Identitätskarte, rechtlich der handschriftlichen Unterschrift " + "gleichgestellt. Benötigt einen Kartenleser und eine PIN-Eingabe " + "pro Dokument; Ihre nationale Registernummer wird in jede " + "Signatur eingebettet.\n\n" + "Azure (AES) — eine fortgeschrittene Signatur mit Ihrem " + "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 " + "Rechtswert; funktioniert vollständig offline.\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. Das " + "vollständige Glossar ist auf Englisch unter „Vollständige " + "Dokumentation“ verfügbar." + ), + "es": ( + "eID (QES) — firma cualificada con su tarjeta de identidad " + "belga, legalmente equivalente a la manuscrita. Requiere un " + "lector de tarjetas y un PIN por documento; su número de registro " + "nacional se incrusta en cada firma.\n\n" + "Azure (AES) — firma avanzada con su certificado personal en " + "Azure 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" + "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. El glosario completo está " + "disponible en inglés en «Documentación completa»." + ), + "pt": ( + "eID (QES) — assinatura qualificada com o seu cartão de " + "identidade belga, juridicamente equivalente à manuscrita. Requer " + "um leitor de cartões e um PIN por documento; o seu número de " + "registo nacional é incorporado em cada assinatura.\n\n" + "Azure (AES) — assinatura avançada com o seu certificado pessoal " + "no 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 valor legal; funciona totalmente offline.\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. O glossário completo " + "está disponível em inglês em «Documentação completa»." + ), + }, + "step.place.short": { + "en": "Placement", "fr": "Position", "nl": "Plaatsing", + "de": "Platzierung", "es": "Posición", "pt": "Posição", + }, + "step.place.title": { + "en": "Configure Signature Placement", + "fr": "Positionner la signature", + "nl": "Plaatsing van de handtekening", + "de": "Signatur platzieren", + "es": "Configurar la posición de la firma", + "pt": "Configurar a posição da assinatura", + }, + "step.place.help": { + "en": ( + "Click on the page preview to set the lower-left corner of the " + "signature. In eID and Azure modes you may skip this: the " + "vignette then goes bottom-right on the last page. The target " + "page field selects the page that actually gets signed — 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. While the first/last-page choice from the " + "validation step applies, the preview is locked on that page and " + "the target-page field is disabled." + ), + "fr": ( + "Cliquez sur l'aperçu de la page pour définir le coin inférieur " + "gauche de la signature. En modes eID et Azure, vous pouvez " + "ignorer cette étape : la vignette ira alors en bas à droite de " + "la dernière page. Le champ « page cible » désigne la page " + "réellement signée — ajustez-le 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. Tant que le " + "choix première/dernière page de l'étape de validation " + "s'applique, l'aperçu est verrouillé sur cette page et le champ " + "« page cible » est désactivé." + ), + "nl": ( + "Klik op het paginavoorbeeld om de linkerbenedenhoek van de " + "handtekening te bepalen. In de modi eID en Azure mag u dit " + "overslaan: het vignet komt dan rechtsonder op de laatste pagina. " + "Het veld 'doelpagina' bepaalt welke pagina daadwerkelijk wordt " + "ondertekend — 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. Zolang de keuze eerste/laatste pagina uit de " + "validatiestap geldt, is het voorbeeld op die pagina vergrendeld " + "en is het veld 'doelpagina' uitgeschakeld." + ), + "de": ( + "Klicken Sie auf die Seitenvorschau, um die linke untere Ecke der " + "Signatur festzulegen. In den Modi eID und Azure können Sie dies " + "überspringen: Die Vignette kommt dann unten rechts auf die " + "letzte Seite. Das Feld „Zielseite“ bestimmt die tatsächlich " + "signierte Seite — passen Sie es 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. Solange die Wahl erste/letzte Seite aus dem " + "Prüfschritt gilt, ist die Vorschau auf dieser Seite gesperrt und " + "das Feld „Zielseite“ deaktiviert." + ), + "es": ( + "Haga clic en la vista previa para fijar la esquina inferior " + "izquierda de la firma. En los modos eID y Azure puede omitirlo: " + "la viñeta irá entonces abajo a la derecha en la última página. " + "El campo «página de destino» selecciona la página que realmente " + "se firma — ajústelo si un documento debe firmarse en una página " + "distinta de la mostrada. Los documentos sin esa página fallarán " + "con un mensaje claro. Mientras se aplique la elección de " + "primera/última página del paso de validación, la vista previa " + "queda bloqueada en esa página y el campo «página de destino» " + "está desactivado." + ), + "pt": ( + "Clique na pré-visualização para definir o canto inferior " + "esquerdo da assinatura. Nos modos eID e Azure pode saltar este " + "passo: a vinheta ficará em baixo à direita na última página. O " + "campo «página de destino» seleciona a página realmente assinada " + "— ajuste-o se um documento tiver de ser assinado numa página " + "diferente da mostrada. Os documentos sem essa página falharão " + "com uma mensagem clara. Enquanto a escolha primeira/última " + "página do passo de validação se aplicar, a pré-visualização fica " + "bloqueada nessa página e o campo «página de destino» fica " + "desativado." + ), + }, + "step.run.short": { + "en": "Signing", "fr": "Signature", "nl": "Ondertekenen", + "de": "Signieren", "es": "Firmar", "pt": "Assinar", + }, + "step.run.title": { + "en": "Apply Signatures", + "fr": "Signer les documents", + "nl": "Handtekeningen toepassen", + "de": "Signaturen anwenden", + "es": "Aplicar las firmas", + "pt": "Aplicar as assinaturas", + }, + "step.run.help": { + "en": ( + "Check the summary, then start the batch. eID mode asks for your " + "PIN once per document; Azure mode may open a Microsoft sign-in " + "window 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." + ), + "fr": ( + "Vérifiez le récapitulatif, puis lancez le lot. Le mode eID " + "demande votre PIN une fois par document ; le mode Azure peut " + "ouvrir une fenêtre de connexion Microsoft 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." + ), + "nl": ( + "Controleer het overzicht en start de reeks. De eID-modus vraagt " + "uw pincode één keer per document; de Azure-modus kan een " + "Microsoft-aanmeldvenster openen 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." + ), + "de": ( + "Prüfen Sie die Übersicht und starten Sie den Stapel. Der " + "eID-Modus fragt Ihre PIN einmal pro Dokument ab; der Azure-Modus " + "kann ein Microsoft-Anmeldefenster ö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." + ), + "es": ( + "Compruebe el resumen y lance el lote. El modo eID pide su PIN " + "una vez por documento; el modo Azure puede abrir 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." + ), + "pt": ( + "Verifique o resumo e inicie o lote. O modo eID pede o PIN uma " + "vez por documento; o modo Azure pode abrir uma 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." + ), + }, + "step.results.short": { + "en": "Report", "fr": "Rapport", "nl": "Rapport", + "de": "Bericht", "es": "Informe", "pt": "Relatório", + }, + "step.results.title": { + "en": "Results Report", + "fr": "Rapport des résultats", + "nl": "Resultatenrapport", + "de": "Ergebnisbericht", + "es": "Informe de resultados", + "pt": "Relatório de resultados", + }, + "step.results.help": { + "en": ( + "Every document of the batch is listed with its outcome. " + "Successful lines show the achieved signature level and, where " + "applicable, the long-term-validation status. Failed lines " + "explain the reason; those documents were not signed. Finish " + "returns to the welcome screen." + ), + "fr": ( + "Chaque document du lot est listé avec son résultat. Les lignes " + "réussies indiquent le niveau de signature atteint et, le cas " + "échéant, l'état de la validation à long terme. Les lignes en " + "échec expliquent la raison ; ces documents n'ont pas été signés. " + "« Terminer » revient à l'écran d'accueil." + ), + "nl": ( + "Elk document van de reeks staat vermeld met zijn resultaat. " + "Geslaagde regels tonen het bereikte handtekeningniveau en, " + "indien van toepassing, de status van de langetermijnvalidatie. " + "Mislukte regels geven de reden; die documenten zijn niet " + "ondertekend. 'Voltooien' keert terug naar het welkomstscherm." + ), + "de": ( + "Jedes Dokument des Stapels wird mit seinem Ergebnis aufgeführt. " + "Erfolgreiche Zeilen zeigen das erreichte Signaturniveau und " + "gegebenenfalls den Status der Langzeitvalidierung. " + "Fehlgeschlagene Zeilen nennen den Grund; diese Dokumente wurden " + "nicht signiert. „Fertigstellen“ kehrt zum Startbildschirm " + "zurück." + ), + "es": ( + "Cada documento del lote aparece con su resultado. Las líneas " + "correctas muestran el nivel de firma alcanzado y, en su caso, el " + "estado de la validación a largo plazo. Las líneas con error " + "explican el motivo; esos documentos no se firmaron. «Finalizar» " + "vuelve a la pantalla de bienvenida." + ), + "pt": ( + "Cada documento do lote é listado com o seu resultado. As linhas " + "com êxito mostram o nível de assinatura alcançado e, quando " + "aplicável, o estado da validação de longo prazo. As linhas " + "falhadas explicam o motivo; esses documentos não foram " + "assinados. «Concluir» regressa ao ecrã inicial." + ), + }, + # ------------------------------------------------------------- step 1 + "tpl.choose": { + "en": "Choose template…", + "fr": "Choisir le modèle…", + "nl": "Sjabloon kiezen…", + "de": "Vorlage wählen…", + "es": "Elegir plantilla…", + "pt": "Escolher modelo…", + }, + "tpl.selected": { + "en": "{name} ({pages} pages)", + "fr": "{name} ({pages} pages)", + "nl": "{name} ({pages} pagina's)", + "de": "{name} ({pages} Seiten)", + "es": "{name} ({pages} páginas)", + "pt": "{name} ({pages} páginas)", + }, + "tpl.unreadable": { + "en": "Cannot read this PDF: {error}", + "fr": "Impossible de lire ce PDF : {error}", + "nl": "Kan deze PDF niet lezen: {error}", + "de": "Diese PDF kann nicht gelesen werden: {error}", + "es": "No se puede leer este PDF: {error}", + "pt": "Não é possível ler este PDF: {error}", + }, + "common.none": { + "en": "(none selected)", + "fr": "(aucune sélection)", + "nl": "(niets geselecteerd)", + "de": "(nichts ausgewählt)", + "es": "(nada seleccionado)", + "pt": "(nada selecionado)", + }, + # ------------------------------------------------------------- step 2 + "files.choose": { + "en": "Choose PDF files…", + "fr": "Choisir les fichiers PDF…", + "nl": "PDF-bestanden kiezen…", + "de": "PDF-Dateien wählen…", + "es": "Elegir archivos PDF…", + "pt": "Escolher ficheiros PDF…", + }, + "files.count": { + "en": "{count} file(s) selected", + "fr": "{count} fichier(s) sélectionné(s)", + "nl": "{count} bestand(en) geselecteerd", + "de": "{count} Datei(en) ausgewählt", + "es": "{count} archivo(s) seleccionado(s)", + "pt": "{count} ficheiro(s) selecionado(s)", + }, + # ------------------------------------------------------------- step 3 + "val.revalidate": { + "en": "Validate again", + "fr": "Revalider", + "nl": "Opnieuw valideren", + "de": "Erneut prüfen", + "es": "Validar de nuevo", + "pt": "Validar novamente", + }, + "val.col_file": { + "en": "File", "fr": "Fichier", "nl": "Bestand", + "de": "Datei", "es": "Archivo", "pt": "Ficheiro", + }, + "val.col_result": { + "en": "Result", "fr": "Résultat", "nl": "Resultaat", + "de": "Ergebnis", "es": "Resultado", "pt": "Resultado", + }, + "val.col_detail": { + "en": "Detail", "fr": "Détail", "nl": "Detail", + "de": "Detail", "es": "Detalle", "pt": "Detalhe", + }, + "val.ok": { + "en": "✓ OK", "fr": "✓ OK", "nl": "✓ OK", + "de": "✓ OK", "es": "✓ OK", "pt": "✓ OK", + }, + "val.rejected": { + "en": "✗ rejected", "fr": "✗ rejeté", "nl": "✗ geweigerd", + "de": "✗ abgelehnt", "es": "✗ rechazado", "pt": "✗ rejeitado", + }, + "val.summary": { + "en": "{ok}/{total} valid file(s).", + "fr": "{ok}/{total} fichier(s) valide(s).", + "nl": "{ok}/{total} geldig(e) bestand(en).", + "de": "{ok}/{total} gültige Datei(en).", + "es": "{ok}/{total} archivo(s) válido(s).", + "pt": "{ok}/{total} ficheiro(s) válido(s).", + }, + "val.anchor_label": { + "en": "Some files have a different page count than the template — sign every file on:", + "fr": "Certains fichiers n'ont pas le même nombre de pages que le modèle — signer chaque fichier sur :", + "nl": "Sommige bestanden hebben een ander aantal pagina's dan het sjabloon — onderteken elk bestand op:", + "de": "Einige Dateien haben eine andere Seitenzahl als die Vorlage — jede Datei signieren auf:", + "es": "Algunos archivos tienen un número de páginas distinto de la plantilla — firmar cada archivo en:", + "pt": "Alguns ficheiros têm um número de páginas diferente do modelo — assinar cada ficheiro:", + }, + "anchor.opt_last": { + "en": "its last page", + "fr": "sa dernière page", + "nl": "zijn laatste pagina", + "de": "ihrer letzten Seite", + "es": "su última página", + "pt": "na última página", + }, + "anchor.opt_first": { + "en": "its first page", + "fr": "sa première page", + "nl": "zijn eerste pagina", + "de": "ihrer ersten Seite", + "es": "su primera página", + "pt": "na primeira página", + }, + "anchor.last_page": { + "en": "last page", + "fr": "dernière page", + "nl": "laatste pagina", + "de": "letzte Seite", + "es": "última página", + "pt": "última página", + }, + "anchor.first_page": { + "en": "first page", + "fr": "première page", + "nl": "eerste pagina", + "de": "erste Seite", + "es": "primera página", + "pt": "primeira página", + }, + "val.anchor_hint": { + "en": "(the position is picked on that page at the placement step)", + "fr": "(la position se choisit sur cette page à l'étape de positionnement)", + "nl": "(de positie kiest u op die pagina in de plaatsingsstap)", + "de": "(die Position wählen Sie auf dieser Seite im Platzierungsschritt)", + "es": "(la posición se elige en esa página en el paso de colocación)", + "pt": "(a posição escolhe-se nessa página no passo de posicionamento)", + }, + "val.none_valid": { + "en": "No document matches the template. Go back and adjust the selection.", + "fr": "Aucun document ne correspond au modèle. Revenez en arrière pour ajuster la sélection.", + "nl": "Geen enkel document komt met het sjabloon overeen. Ga terug en pas de selectie aan.", + "de": "Kein Dokument entspricht der Vorlage. Gehen Sie zurück und passen Sie die Auswahl an.", + "es": "Ningún documento coincide con la plantilla. Vuelva atrás y ajuste la selección.", + "pt": "Nenhum documento corresponde ao modelo. Volte atrás e ajuste a seleção.", + }, + # ------------------------------------------------------------- step 4 + "out.choose": { + "en": "Choose output folder…", + "fr": "Choisir le dossier de sortie…", + "nl": "Uitvoermap kiezen…", + "de": "Ausgabeordner wählen…", + "es": "Elegir carpeta de salida…", + "pt": "Escolher pasta de saída…", + }, + # ------------------------------------------------------------- step 5 + "mode.beid": { + "en": "eID card — qualified signature (QES)", + "fr": "Carte eID — signature qualifiée (QES)", + "nl": "eID-kaart — gekwalificeerde handtekening (QES)", + "de": "eID-Karte — qualifizierte Signatur (QES)", + "es": "Tarjeta eID — firma cualificada (QES)", + "pt": "Cartão eID — assinatura qualificada (QES)", + }, + "mode.azure": { + "en": "Azure Key Vault — advanced signature (AES)", + "fr": "Azure Key Vault — signature avancée (AES)", + "nl": "Azure Key Vault — geavanceerde handtekening (AES)", + "de": "Azure Key Vault — fortgeschrittene Signatur (AES)", + "es": "Azure Key Vault — firma avanzada (AES)", + "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", + }, + "mode.beid_hint": { + "en": "Card reader + one PIN per document. Embeds your national register number (RRN) in every signature.", + "fr": "Lecteur de carte + un PIN par document. Intègre votre numéro de registre national (RRN) dans chaque signature.", + "nl": "Kaartlezer + één pincode per document. Neemt uw rijksregisternummer (RRN) in elke handtekening op.", + "de": "Kartenleser + eine PIN pro Dokument. Bettet Ihre nationale Registernummer (RRN) in jede Signatur ein.", + "es": "Lector de tarjetas + un PIN por documento. Incrusta su número de registro nacional (RRN) en cada firma.", + "pt": "Leitor de cartões + um PIN por documento. Incorpora o seu número de registo nacional (RRN) em cada assinatura.", + }, + "mode.azure_hint": { + "en": "One Microsoft sign-in for the whole batch; needs network. Only the document digest leaves this machine.", + "fr": "Une seule connexion Microsoft pour tout le lot ; réseau requis. Seule l'empreinte du document quitte cette machine.", + "nl": "Eén Microsoft-aanmelding voor de hele reeks; netwerk vereist. Alleen de digest van het document verlaat deze computer.", + "de": "Eine Microsoft-Anmeldung für den ganzen Stapel; Netzwerk erforderlich. Nur der Hashwert des Dokuments verlässt diesen Rechner.", + "es": "Un solo inicio de sesión de Microsoft para todo el lote; requiere red. Solo la huella del documento sale de este equipo.", + "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.", + }, + "mode.level": { + "en": "PAdES level:", + "fr": "Niveau PAdES :", + "nl": "PAdES-niveau:", + "de": "PAdES-Niveau:", + "es": "Nivel PAdES:", + "pt": "Nível PAdES:", + }, + "mode.level_hint": { + "en": "Durability of the signature (eID and Azure). Keep b-lta — verifiable for decades; levels above b-b need internet.", + "fr": "Durabilité de la signature (eID et Azure). Gardez b-lta — vérifiable pendant des décennies ; au-delà de b-b, Internet est requis.", + "nl": "Duurzaamheid van de handtekening (eID en Azure). Behoud b-lta — tientallen jaren verifieerbaar; boven b-b is internet vereist.", + "de": "Haltbarkeit der Signatur (eID und Azure). Behalten Sie b-lta — über Jahrzehnte prüfbar; oberhalb von b-b ist Internet erforderlich.", + "es": "Durabilidad de la firma (eID y Azure). Mantenga b-lta — verificable durante décadas; por encima de b-b se necesita Internet.", + "pt": "Durabilidade da assinatura (eID e Azure). Mantenha b-lta — verificável durante décadas; acima de b-b é necessária Internet.", + }, + "azure.settings": { + "en": "Azure settings", + "fr": "Paramètres Azure", + "nl": "Azure-instellingen", + "de": "Azure-Einstellungen", + "es": "Ajustes de Azure", + "pt": "Definições do Azure", + }, + "azure.vault": { + "en": "Vault URL:", + "fr": "URL du coffre :", + "nl": "Vault-URL:", + "de": "Vault-URL:", + "es": "URL del almacén:", + "pt": "URL do cofre:", + }, + "azure.vault_hint": { + "en": ( + "Required. Your organisation's Key Vault address (ask your " + "administrator), e.g. https://name.vault.azure.net. The " + "pre-filled https://login.live.com is the Microsoft sign-in " + "page, NOT a vault — replace it." + ), + "fr": ( + "Obligatoire. L'adresse du Key Vault de votre organisation " + "(demandez à votre administrateur), p. ex. " + "https://nom.vault.azure.net. Le https://login.live.com " + "prérempli est la page de connexion Microsoft, PAS un coffre — " + "remplacez-le." + ), + "nl": ( + "Verplicht. Het Key Vault-adres van uw organisatie (vraag uw " + "beheerder), bv. https://naam.vault.azure.net. Het vooraf " + "ingevulde https://login.live.com is de Microsoft-aanmeldpagina, " + "GEEN kluis — vervang het." + ), + "de": ( + "Erforderlich. Die Key-Vault-Adresse Ihrer Organisation (fragen " + "Sie Ihre Administration), z. B. https://name.vault.azure.net. " + "Das vorausgefüllte https://login.live.com ist die " + "Microsoft-Anmeldeseite, KEIN Vault — ersetzen Sie es." + ), + "es": ( + "Obligatorio. La dirección del Key Vault de su organización " + "(consulte a su administrador), p. ej. " + "https://nombre.vault.azure.net. El https://login.live.com " + "precargado es la página de inicio de sesión de Microsoft, NO un " + "almacén — sustitúyalo." + ), + "pt": ( + "Obrigatório. O endereço do Key Vault da sua organização " + "(pergunte ao administrador), p. ex. " + "https://nome.vault.azure.net. O https://login.live.com " + "pré-preenchido é a página de início de sessão Microsoft, NÃO um " + "cofre — substitua-o." + ), + }, + "azure.key": { + "en": "Key name (override):", + "fr": "Nom de la clé (dérogation) :", + "nl": "Sleutelnaam (overschrijven):", + "de": "Schlüsselname (Überschreibung):", + "es": "Nombre de la clave (anulación):", + "pt": "Nome da chave (substituição):", + }, + "azure.key_hint": { + "en": ( + "Optional. Normally the key is derived from YOUR login " + "(sig-), so you can only sign in your own name. Fill this " + "only to use another key — the override is flagged in the run " + "output." + ), + "fr": ( + "Facultatif. Normalement la clé est dérivée de VOTRE identifiant " + "(sig-) : vous ne signez qu'en votre nom. Ne remplissez ceci " + "que pour utiliser une autre clé — la dérogation est signalée " + "dans le rapport." + ), + "nl": ( + "Optioneel. Normaal wordt de sleutel afgeleid van UW aanmelding " + "(sig-), zodat u alleen in eigen naam kunt ondertekenen. Vul " + "dit alleen in om een andere sleutel te gebruiken — de afwijking " + "wordt in de uitvoer gemeld." + ), + "de": ( + "Optional. Normalerweise wird der Schlüssel aus IHRER Anmeldung " + "abgeleitet (sig-), sodass Sie nur im eigenen Namen " + "signieren. Nur ausfüllen, um einen anderen Schlüssel zu " + "verwenden — die Überschreibung wird im Bericht vermerkt." + ), + "es": ( + "Opcional. Normalmente la clave se deriva de SU inicio de sesión " + "(sig-), de modo que solo firma en su propio nombre. " + "Rellénelo solo para usar otra clave — la anulación queda " + "señalada en el informe." + ), + "pt": ( + "Opcional. Normalmente a chave deriva do SEU início de sessão " + "(sig-), pelo que só assina em seu nome. Preencha apenas " + "para usar outra chave — a substituição é assinalada no " + "relatório." + ), + }, + "azure.anchors": { + "en": "Internal CA chain (PEM)…", + "fr": "Chaîne CA interne (PEM)…", + "nl": "Interne CA-keten (PEM)…", + "de": "Interne CA-Kette (PEM)…", + "es": "Cadena de CA interna (PEM)…", + "pt": "Cadeia da CA interna (PEM)…", + }, + "azure.anchors_none": { + "en": "(none chosen)", + "fr": "(aucune choisie)", + "nl": "(geen gekozen)", + "de": "(keine gewählt)", + "es": "(ninguna elegida)", + "pt": "(nenhuma escolhida)", + }, + "azure.anchors_hint": { + "en": ( + "PEM/DER file with your organisation's internal CA chain (root + " + "intermediates). Required for levels b-lt/b-lta and for " + "post-signing verification. The EU trusted list is NOT used in " + "Azure mode." + ), + "fr": ( + "Fichier PEM/DER contenant la chaîne CA interne de votre " + "organisation (racine + intermédiaires). Requis pour les niveaux " + "b-lt/b-lta et pour la vérification après signature. La liste de " + "confiance de l'UE n'est PAS utilisée en mode Azure." + ), + "nl": ( + "PEM/DER-bestand met de interne CA-keten van uw organisatie " + "(root + tussenliggende). Vereist voor de niveaus b-lt/b-lta en " + "voor de verificatie na ondertekening. De vertrouwenslijst van de " + "EU wordt in Azure-modus NIET gebruikt." + ), + "de": ( + "PEM/DER-Datei mit der internen CA-Kette Ihrer Organisation " + "(Root + Zwischenzertifikate). Erforderlich für die Niveaus " + "b-lt/b-lta und für die Prüfung nach dem Signieren. Die " + "EU-Vertrauensliste wird im Azure-Modus NICHT verwendet." + ), + "es": ( + "Archivo PEM/DER con la cadena de CA interna de su organización " + "(raíz + intermedias). Necesario para los niveles b-lt/b-lta y " + "para la verificación posterior a la firma. La lista de confianza " + "de la UE NO se usa en modo Azure." + ), + "pt": ( + "Ficheiro PEM/DER com a cadeia da CA interna da sua organização " + "(raiz + intermédias). Necessário para os níveis b-lt/b-lta e " + "para a verificação após a assinatura. A lista de confiança da UE " + "NÃO é usada no modo Azure." + ), + }, + "azure.auth": { + "en": "Sign-in method:", + "fr": "Méthode de connexion :", + "nl": "Aanmeldmethode:", + "de": "Anmeldemethode:", + "es": "Método de inicio de sesión:", + "pt": "Método de início de sessão:", + }, + "azure.auth_hint": { + "en": ( + "'interactive' opens your browser (recommended); 'device-code' " + "shows a code to type on another device; 'default' is for " + "automation only. Signing in now is optional — otherwise it " + "happens at launch." + ), + "fr": ( + "« interactive » ouvre votre navigateur (recommandé) ; " + "« device-code » affiche un code à saisir sur un autre appareil ; " + "« default » est réservé à l'automatisation. Se connecter " + "maintenant est facultatif — sinon la connexion a lieu au " + "lancement." + ), + "nl": ( + "'interactive' opent uw browser (aanbevolen); 'device-code' " + "toont een code om op een ander apparaat in te voeren; 'default' " + "is alleen voor automatisering. Nu aanmelden is optioneel — " + "anders gebeurt het bij de start." + ), + "de": ( + "'interactive' öffnet Ihren Browser (empfohlen); 'device-code' " + "zeigt einen Code für ein anderes Gerät; 'default' ist nur für " + "Automatisierung. Die Anmeldung jetzt ist optional — sonst " + "erfolgt sie beim Start." + ), + "es": ( + "'interactive' abre su navegador (recomendado); 'device-code' " + "muestra un código para escribir en otro dispositivo; 'default' " + "es solo para automatización. Iniciar sesión ahora es opcional — " + "si no, ocurre al lanzar." + ), + "pt": ( + "'interactive' abre o navegador (recomendado); 'device-code' " + "mostra um código para introduzir noutro dispositivo; 'default' é " + "apenas para automatização. Iniciar sessão agora é opcional — " + "caso contrário, acontece no arranque." + ), + }, + "azure.signin": { + "en": "Sign in with Microsoft", + "fr": "Se connecter avec Microsoft", + "nl": "Aanmelden bij Microsoft", + "de": "Mit Microsoft anmelden", + "es": "Iniciar sesión con Microsoft", + "pt": "Iniciar sessão com a Microsoft", + }, + "azure.signing_in": { + "en": "signing in…", + "fr": "connexion…", + "nl": "aanmelden…", + "de": "Anmeldung läuft…", + "es": "iniciando sesión…", + "pt": "a iniciar sessão…", + }, + "azure.signed_in": { + "en": "signed in as {upn}", + "fr": "connecté : {upn}", + "nl": "aangemeld als {upn}", + "de": "angemeldet als {upn}", + "es": "sesión iniciada como {upn}", + "pt": "sessão iniciada como {upn}", + }, + "azure.not_signed_in": { + "en": "(not signed in)", + "fr": "(non connecté)", + "nl": "(niet aangemeld)", + "de": "(nicht angemeldet)", + "es": "(sin sesión iniciada)", + "pt": "(sem sessão iniciada)", + }, + "azure.signin_failed": { + "en": "Microsoft sign-in failed: {error}", + "fr": "Échec de la connexion Microsoft : {error}", + "nl": "Microsoft-aanmelding mislukt: {error}", + "de": "Microsoft-Anmeldung fehlgeschlagen: {error}", + "es": "Error al iniciar sesión con Microsoft: {error}", + "pt": "Falha no início de sessão Microsoft: {error}", + }, + "azure.vault_missing": { + "en": "Azure mode needs the Key Vault URL.", + "fr": "Le mode Azure requiert l'URL du Key Vault.", + "nl": "De Azure-modus vereist de Key Vault-URL.", + "de": "Der Azure-Modus benötigt die Key-Vault-URL.", + "es": "El modo Azure necesita la URL del Key Vault.", + "pt": "O modo Azure precisa do URL do Key Vault.", + }, + "azure.anchors_missing": { + "en": "Level {level} needs the internal CA chain (trust anchors).", + "fr": "Le niveau {level} requiert la chaîne CA interne (ancres de confiance).", + "nl": "Niveau {level} vereist de interne CA-keten (vertrouwensankers).", + "de": "Niveau {level} benötigt die interne CA-Kette (Vertrauensanker).", + "es": "El nivel {level} necesita la cadena de CA interna (anclas de confianza).", + "pt": "O nível {level} precisa da cadeia da CA interna (âncoras de confiança).", + }, + "docs.more": { + "en": "Full documentation (English)…", + "fr": "Documentation complète (en anglais)…", + "nl": "Volledige documentatie (Engels)…", + "de": "Vollständige Dokumentation (Englisch)…", + "es": "Documentación completa (en inglés)…", + "pt": "Documentação completa (em inglês)…", + }, + "docs.title": { + "en": "Cachet — Documentation", + "fr": "Cachet — Documentation", + "nl": "Cachet — Documentatie", + "de": "Cachet — Dokumentation", + "es": "Cachet — Documentación", + "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 :", + "nl": "Doelpagina:", + "de": "Zielseite:", + "es": "Página de destino:", + "pt": "Página de destino:", + }, + "place.preview_page": { + "en": "Preview: page {cur}/{total}", + "fr": "Aperçu : page {cur}/{total}", + "nl": "Voorbeeld: pagina {cur}/{total}", + "de": "Vorschau: Seite {cur}/{total}", + "es": "Vista previa: página {cur}/{total}", + "pt": "Pré-visualização: página {cur}/{total}", + }, + "place.prev": { + "en": "◀ Previous page", + "fr": "◀ Page précédente", + "nl": "◀ Vorige pagina", + "de": "◀ Vorherige Seite", + "es": "◀ Página anterior", + "pt": "◀ Página anterior", + }, + "place.next": { + "en": "Next page ▶", + "fr": "Page suivante ▶", + "nl": "Volgende pagina ▶", + "de": "Nächste Seite ▶", + "es": "Página siguiente ▶", + "pt": "Página seguinte ▶", + }, + "place.pos_none": { + "en": "No position set — click on the page preview.", + "fr": "Aucune position définie — cliquez sur l'aperçu de la page.", + "nl": "Geen positie ingesteld — klik op het paginavoorbeeld.", + "de": "Keine Position festgelegt — klicken Sie auf die Seitenvorschau.", + "es": "Sin posición definida — haga clic en la vista previa.", + "pt": "Nenhuma posição definida — clique na pré-visualização.", + }, + "place.pos_default": { + "en": "No position set — the vignette goes bottom-right on the last page.", + "fr": "Aucune position définie — la vignette ira en bas à droite de la dernière page.", + "nl": "Geen positie ingesteld — het vignet komt rechtsonder op de laatste pagina.", + "de": "Keine Position festgelegt — die Vignette kommt unten rechts auf die letzte Seite.", + "es": "Sin posición definida — la viñeta irá abajo a la derecha en la última página.", + "pt": "Nenhuma posição definida — a vinheta ficará em baixo à direita na última página.", + }, + "place.pos": { + "en": "Position: page {page}, ({x}, {y}) pt from the bottom-left corner.", + "fr": "Position : page {page}, ({x}, {y}) pt depuis le coin inférieur gauche.", + "nl": "Positie: pagina {page}, ({x}, {y}) pt vanaf de linkerbenedenhoek.", + "de": "Position: Seite {page}, ({x}, {y}) pt von der linken unteren Ecke.", + "es": "Posición: página {page}, ({x}, {y}) pt desde la esquina inferior izquierda.", + "pt": "Posição: página {page}, ({x}, {y}) pt a partir do canto inferior esquerdo.", + }, + "place.reset": { + "en": "Reset position", + "fr": "Réinitialiser la position", + "nl": "Positie wissen", + "de": "Position zurücksetzen", + "es": "Restablecer posición", + "pt": "Repor posição", + }, + "place.page_beyond": { + "en": "⚠ Page {page} is beyond the template ({total} pages): documents without this page will fail.", + "fr": "⚠ La page {page} dépasse le modèle ({total} pages) : les documents sans cette page échoueront.", + "nl": "⚠ Pagina {page} valt buiten het sjabloon ({total} pagina's): documenten zonder deze pagina zullen mislukken.", + "de": "⚠ Seite {page} liegt außerhalb der Vorlage ({total} Seiten): Dokumente ohne diese Seite schlagen fehl.", + "es": "⚠ La página {page} supera la plantilla ({total} páginas): los documentos sin esa página fallarán.", + "pt": "⚠ A página {page} excede o modelo ({total} páginas): os documentos sem essa página falharão.", + }, + "place.page_invalid": { + "en": "⚠ The target page must be a whole number ≥ 1.", + "fr": "⚠ La page cible doit être un nombre entier ≥ 1.", + "nl": "⚠ De doelpagina moet een geheel getal ≥ 1 zijn.", + "de": "⚠ Die Zielseite muss eine ganze Zahl ≥ 1 sein.", + "es": "⚠ La página de destino debe ser un número entero ≥ 1.", + "pt": "⚠ A página de destino deve ser um número inteiro ≥ 1.", + }, + "place.locked_suffix": { + "en": " — locked: {anchor}", + "fr": " — verrouillé : {anchor}", + "nl": " — vergrendeld: {anchor}", + "de": " — gesperrt: {anchor}", + "es": " — bloqueado: {anchor}", + "pt": " — bloqueado: {anchor}", + }, + "place.pos_anchor": { + "en": "Position: {anchor} of each document, ({x}, {y}) pt from the bottom-left corner.", + "fr": "Position : {anchor} de chaque document, ({x}, {y}) pt depuis le coin inférieur gauche.", + "nl": "Positie: {anchor} van elk document, ({x}, {y}) pt vanaf de linkerbenedenhoek.", + "de": "Position: {anchor} jedes Dokuments, ({x}, {y}) pt von der linken unteren Ecke.", + "es": "Posición: {anchor} de cada documento, ({x}, {y}) pt desde la esquina inferior izquierda.", + "pt": "Posição: {anchor} de cada documento, ({x}, {y}) pt a partir do canto inferior esquerdo.", + }, + "place.pos_default_anchor": { + "en": "No position set — the vignette goes bottom-right on the {anchor} of each document.", + "fr": "Aucune position définie — la vignette ira en bas à droite de la {anchor} de chaque document.", + "nl": "Geen positie ingesteld — het vignet komt rechtsonder op de {anchor} van elk document.", + "de": "Keine Position festgelegt — die Vignette kommt unten rechts auf die {anchor} jedes Dokuments.", + "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.", + }, + # ------------------------------------------------------------- step 7 + "run.summary_docs": { + "en": "Documents to sign: {count}", + "fr": "Documents à signer : {count}", + "nl": "Te ondertekenen documenten: {count}", + "de": "Zu signierende Dokumente: {count}", + "es": "Documentos a firmar: {count}", + "pt": "Documentos a assinar: {count}", + }, + "run.summary_mode": { + "en": "Signature type: {mode}", + "fr": "Type de signature : {mode}", + "nl": "Handtekeningtype: {mode}", + "de": "Signaturtyp: {mode}", + "es": "Tipo de firma: {mode}", + "pt": "Tipo de assinatura: {mode}", + }, + "run.summary_level": { + "en": "PAdES level: {level}", + "fr": "Niveau PAdES : {level}", + "nl": "PAdES-niveau: {level}", + "de": "PAdES-Niveau: {level}", + "es": "Nivel PAdES: {level}", + "pt": "Nível PAdES: {level}", + }, + "run.summary_output": { + "en": "Output folder: {output}", + "fr": "Dossier de sortie : {output}", + "nl": "Uitvoermap: {output}", + "de": "Ausgabeordner: {output}", + "es": "Carpeta de salida: {output}", + "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}", + }, + "run.place_custom": { + "en": "page {page} @ ({x}, {y}) pt", + "fr": "page {page} @ ({x}, {y}) pt", + "nl": "pagina {page} @ ({x}, {y}) pt", + "de": "Seite {page} @ ({x}, {y}) pt", + "es": "página {page} @ ({x}, {y}) pt", + "pt": "página {page} @ ({x}, {y}) pt", + }, + "run.place_default": { + "en": "bottom-right, last page", + "fr": "en bas à droite, dernière page", + "nl": "rechtsonder, laatste pagina", + "de": "unten rechts, letzte Seite", + "es": "abajo a la derecha, última página", + "pt": "em baixo à direita, última página", + }, + "run.place_custom_anchor": { + "en": "{anchor} of each document @ ({x}, {y}) pt", + "fr": "{anchor} de chaque document @ ({x}, {y}) pt", + "nl": "{anchor} van elk document @ ({x}, {y}) pt", + "de": "{anchor} jedes Dokuments @ ({x}, {y}) pt", + "es": "{anchor} de cada documento @ ({x}, {y}) pt", + "pt": "{anchor} de cada documento @ ({x}, {y}) pt", + }, + "run.place_default_anchor": { + "en": "bottom-right, {anchor} of each document", + "fr": "en bas à droite, {anchor} de chaque document", + "nl": "rechtsonder, {anchor} van elk document", + "de": "unten rechts, {anchor} jedes Dokuments", + "es": "abajo a la derecha, {anchor} de cada documento", + "pt": "em baixo à direita, {anchor} de cada documento", + }, + "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.", + "nl": "Bestanden met een afwijkend aantal pagina's worden op hun laatste pagina ondertekend.", + "de": "Dateien mit abweichender Seitenzahl werden auf ihrer letzten Seite signiert.", + "es": "Los archivos con un número de páginas distinto se firman en su última página.", + "pt": "Os ficheiros com um número de páginas diferente são assinados na sua última página.", + }, + "run.summary_anchor_first": { + "en": "Files with a different page count are signed on their first page.", + "fr": "Les fichiers dont le nombre de pages diffère sont signés sur leur première page.", + "nl": "Bestanden met een afwijkend aantal pagina's worden op hun eerste pagina ondertekend.", + "de": "Dateien mit abweichender Seitenzahl werden auf ihrer ersten Seite signiert.", + "es": "Los archivos con un número de páginas distinto se firman en su primera página.", + "pt": "Os ficheiros com um número de páginas diferente são assinados na sua primeira página.", + }, + "run.start": { + "en": "Start signing", + "fr": "Lancer la signature", + "nl": "Ondertekenen starten", + "de": "Signieren starten", + "es": "Iniciar la firma", + "pt": "Iniciar a assinatura", + }, + "run.pin_note": { + "en": "eID: you will be asked for your PIN once per document.", + "fr": "eID : votre code PIN sera demandé une fois par document.", + "nl": "eID: uw pincode wordt één keer per document gevraagd.", + "de": "eID: Ihre PIN wird einmal pro Dokument abgefragt.", + "es": "eID: se le pedirá el PIN una vez por documento.", + "pt": "eID: o PIN ser-lhe-á pedido uma vez por documento.", + }, + "run.azure_note": { + "en": "Azure: one Microsoft sign-in covers the whole batch.", + "fr": "Azure : une seule connexion Microsoft couvre tout le lot.", + "nl": "Azure: één Microsoft-aanmelding volstaat voor de hele reeks.", + "de": "Azure: eine Microsoft-Anmeldung genügt für den ganzen Stapel.", + "es": "Azure: un solo inicio de sesión de Microsoft cubre todo el lote.", + "pt": "Azure: um único início de sessão Microsoft cobre todo o lote.", + }, + "run.progress": { + "en": "Processing {done}/{total}: {name}", + "fr": "Traitement {done}/{total} : {name}", + "nl": "Verwerken {done}/{total}: {name}", + "de": "Verarbeite {done}/{total}: {name}", + "es": "Procesando {done}/{total}: {name}", + "pt": "A processar {done}/{total}: {name}", + }, + "run.working": { + "en": "Processing…", + "fr": "Traitement en cours…", + "nl": "Bezig met verwerken…", + "de": "Verarbeitung läuft…", + "es": "Procesando…", + "pt": "A processar…", + }, + "run.done": { + "en": "Done: {ok}/{total} document(s) processed.", + "fr": "Terminé : {ok}/{total} document(s) traité(s).", + "nl": "Klaar: {ok}/{total} document(en) verwerkt.", + "de": "Fertig: {ok}/{total} Dokument(e) verarbeitet.", + "es": "Hecho: {ok}/{total} documento(s) procesado(s).", + "pt": "Concluído: {ok}/{total} documento(s) processado(s).", + }, + "run.error": { + "en": "Error: {error}", + "fr": "Erreur : {error}", + "nl": "Fout: {error}", + "de": "Fehler: {error}", + "es": "Error: {error}", + "pt": "Erro: {error}", + }, + # ------------------------------------------------------------- step 8 + "res.col_doc": { + "en": "Document", "fr": "Document", "nl": "Document", + "de": "Dokument", "es": "Documento", "pt": "Documento", + }, + "res.col_status": { + "en": "Status", "fr": "Statut", "nl": "Status", + "de": "Status", "es": "Estado", "pt": "Estado", + }, + "res.col_detail": { + "en": "Detail", "fr": "Détail", "nl": "Detail", + "de": "Detail", "es": "Detalle", "pt": "Detalhe", + }, + "res.ok": { + "en": "✓ OK", "fr": "✓ OK", "nl": "✓ OK", + "de": "✓ OK", "es": "✓ OK", "pt": "✓ OK", + }, + "res.fail": { + "en": "✗ failed", "fr": "✗ échec", "nl": "✗ mislukt", + "de": "✗ fehlgeschlagen", "es": "✗ error", "pt": "✗ falhou", + }, + "res.all_ok": { + "en": "All {total} document(s) were processed successfully.", + "fr": "Les {total} document(s) ont tous été traités avec succès.", + "nl": "Alle {total} document(en) zijn met succes verwerkt.", + "de": "Alle {total} Dokument(e) wurden erfolgreich verarbeitet.", + "es": "Los {total} documento(s) se procesaron correctamente.", + "pt": "Todos os {total} documento(s) foram processados com êxito.", + }, + "res.partial": { + "en": "{ok} of {total} document(s) succeeded — {fail} failed.", + "fr": "{ok} document(s) sur {total} réussi(s) — {fail} en échec.", + "nl": "{ok} van {total} document(en) geslaagd — {fail} mislukt.", + "de": "{ok} von {total} Dokument(en) erfolgreich — {fail} fehlgeschlagen.", + "es": "{ok} de {total} documento(s) correctos — {fail} con error.", + "pt": "{ok} de {total} documento(s) com êxito — {fail} falharam.", + }, + "res.rrn_note": { + "en": ( + "Reminder: every eID signature embeds your national register " + "number (RRN) — mind how you distribute the signed files." + ), + "fr": ( + "Rappel : chaque signature eID intègre votre numéro de registre " + "national (RRN) — attention à la diffusion des fichiers signés." + ), + "nl": ( + "Herinnering: elke eID-handtekening bevat uw rijksregisternummer " + "(RRN) — let op hoe u de ondertekende bestanden verspreidt." + ), + "de": ( + "Hinweis: Jede eID-Signatur enthält Ihre nationale " + "Registernummer (RRN) — achten Sie darauf, wie Sie die signierten " + "Dateien weitergeben." + ), + "es": ( + "Recordatorio: cada firma eID incrusta su número de registro " + "nacional (RRN) — cuide cómo distribuye los archivos firmados." + ), + "pt": ( + "Lembrete: cada assinatura eID incorpora o seu número de registo " + "nacional (RRN) — tenha cuidado com a distribuição dos ficheiros " + "assinados." + ), + }, +} diff --git a/sign_pdfs_beid.py b/sign_pdfs_beid.py index 6a81101..825773f 100644 --- a/sign_pdfs_beid.py +++ b/sign_pdfs_beid.py @@ -28,7 +28,7 @@ # 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.0.0" +__version__ = "1.1.0" import argparse import dataclasses diff --git a/test_azure.py b/test_azure.py index 402aa68..85f76b0 100644 --- a/test_azure.py +++ b/test_azure.py @@ -570,27 +570,49 @@ def setUp(self): except Exception as exc: # noqa: BLE001 self.skipTest(f"tkinter/GUI unavailable: {exc}") + def _wizard_to_mode_step(self, app): + """Complete steps 1-4 with minimal state and enter the mode step.""" + import tempfile + + from test_sign_pdfs_beid import make_pdf + + tmp = Path(tempfile.mkdtemp()) + tpl = make_pdf(tmp / "t.pdf", [(595, 842)]) + app._start_wizard() + app.template_path = tpl + app.template_error = None + app.template_dims = core.page_dimensions(tpl) + app.input_paths = [tpl] + app.output_dir = tmp + app._goto_step(2) # validation auto-runs on entry + app._goto_step(4) # signature-type step + app.update() + def test_azure_panel_toggles_with_mode(self): import gui app = gui.CachetApp(SimpleNamespace(lib=None)) try: app.update() + self._wizard_to_mode_step(app) app.mode_var.set("azure") - app._refresh_placement_section() + app._refresh_azure_visibility() app.update() self.assertTrue(app.azure_section.winfo_manager()) # shown - self.assertFalse(app.image_row.winfo_manager()) # no image picker self.assertEqual(app.azure_auth_var.get(), "interactive") app.mode_var.set("image") - app._refresh_placement_section() + 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 + app._goto_step(5) + app.update() self.assertTrue(app.image_row.winfo_manager()) finally: app.destroy() def test_vault_url_prefilled_and_docs_popup(self): + import customtkinter as ctk import gui with mock.patch.dict("os.environ", {}, clear=False): @@ -599,12 +621,15 @@ def test_vault_url_prefilled_and_docs_popup(self): app = gui.CachetApp(SimpleNamespace(lib=None)) try: app.update() - self.assertEqual(app.azure_vault_var.get(), "https://login.live.com") - self.assertIn("THE THREE MODES", app.doc_box.get("1.0", "end")) + self.assertEqual(app.azure_vault, "https://login.live.com") app._show_docs_popup() app.update() self.assertTrue(app._docs_win.winfo_exists()) self.assertEqual(app._docs_win.title(), "Cachet — Documentation") + box = next(w for w in app._docs_win.winfo_children() + if isinstance(w, ctk.CTkTextbox)) + self.assertIn("THE THREE MODES", box.get("1.0", "end")) + self.assertIn("GLOSSARY", box.get("1.0", "end")) first = app._docs_win app._show_docs_popup() # second click reuses the window self.assertIs(app._docs_win, first) diff --git a/test_i18n.py b/test_i18n.py new file mode 100644 index 0000000..7cf27f9 --- /dev/null +++ b/test_i18n.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Headless tests for the i18n catalog (no tkinter, no network). + +Guards the two invariants the GUI relies on: every key exists in every +supported language, and each translation uses exactly the same ``{...}`` +placeholders as the English reference (a mismatch would raise KeyError at +``tr(...)`` time, i.e. in the middle of the wizard).""" + +import re +import unittest + +import i18n +from i18n import CATALOG, LANGUAGE_NAMES, LANGUAGES, detect_language, tr + +_PLACEHOLDER = re.compile(r"\{(\w+)\}") + + +class CatalogCompleteness(unittest.TestCase): + def test_every_key_has_every_language(self): + for key, entry in CATALOG.items(): + self.assertEqual( + set(entry), set(LANGUAGES), + f"{key}: languages {sorted(entry)} != {sorted(LANGUAGES)}", + ) + + def test_no_empty_translation(self): + for key, entry in CATALOG.items(): + for lang, text in entry.items(): + self.assertTrue( + isinstance(text, str) and text.strip(), + f"{key}/{lang} is empty", + ) + + def test_placeholders_match_english(self): + for key, entry in CATALOG.items(): + ref = set(_PLACEHOLDER.findall(entry["en"])) + for lang in LANGUAGES: + got = set(_PLACEHOLDER.findall(entry[lang])) + self.assertEqual( + got, ref, + f"{key}/{lang}: placeholders {sorted(got)} != {sorted(ref)}", + ) + + def test_language_names_cover_all_languages(self): + self.assertEqual(set(LANGUAGE_NAMES), set(LANGUAGES)) + + def test_no_stray_braces(self): + # Texts only use braces for placeholders; a literal "{" would crash + # str.format on keys formatted with kwargs. + for key, entry in CATALOG.items(): + for lang, text in entry.items(): + stripped = _PLACEHOLDER.sub("", text) + self.assertNotIn("{", stripped, f"{key}/{lang}") + self.assertNotIn("}", stripped, f"{key}/{lang}") + + +class TrBehaviour(unittest.TestCase): + def tearDown(self): + i18n.set_language(i18n.DEFAULT_LANGUAGE) + + def test_translates_in_active_language(self): + i18n.set_language("fr") + self.assertEqual(tr("landing.start"), "Commencer") + + def test_formats_placeholders(self): + i18n.set_language("de") + self.assertEqual(tr("val.summary", ok=2, total=3), "2/3 gültige Datei(en).") + + def test_unknown_key_returns_key(self): + self.assertEqual(tr("no.such.key"), "no.such.key") + + def test_set_language_rejects_unknown(self): + with self.assertRaises(ValueError): + i18n.set_language("it") + + def test_every_language_selectable(self): + for lang in LANGUAGES: + i18n.set_language(lang) + self.assertEqual(i18n.get_language(), lang) + self.assertTrue(tr("landing.heading")) + + +class DetectLanguage(unittest.TestCase): + def test_locale_forms(self): + cases = { + "fr_BE.UTF-8": "fr", + "nl": "nl", + "de_DE@euro": "de", + "es_ES.ISO8859-1": "es", + "pt_PT.UTF-8": "pt", + "en_US": "en", + "C.UTF-8": "en", # unsupported "c." prefix -> default + "it_IT": "en", # unsupported language -> default + "": "en", + None: "en", + } + for raw, expected in cases.items(): + self.assertEqual(detect_language(raw), expected, raw) + + def test_system_language_is_supported(self): + self.assertIn(i18n.system_language(), LANGUAGES) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_sign_pdfs_beid.py b/test_sign_pdfs_beid.py index bce281e..c251ea3 100644 --- a/test_sign_pdfs_beid.py +++ b/test_sign_pdfs_beid.py @@ -784,12 +784,8 @@ def test_no_rrn_note_otherwise(self): self.assertNotIn("national register number", self._capture()) -class GuiImageEndToEnd(unittest.TestCase): - """Regression: GUI launch → thread → queue → table path (image mode). - - Since Tkinter is not thread-safe, the worker must NOT call `after()`; - it pushes onto a queue drained by the main thread. Skipped if - tkinter/display is unavailable (e.g. headless CI).""" +class _GuiTestBase(unittest.TestCase): + """Shared display-guarded setup for the wizard tests.""" def setUp(self): import types @@ -802,50 +798,74 @@ def setUp(self): self.tmp = Path(tempfile.mkdtemp()) self.types = types - def test_launch_populates_summary_and_writes_output(self): - import time + def make_app(self): import gui - tpl = make_pdf(self.tmp / "t.pdf", [(595, 842), (595, 842)]) - png = make_png(self.tmp / "s.png") - out = self.tmp / "out" app = gui.CachetApp(self.types.SimpleNamespace(lib=None)) app.update() + return app + + def wizard_with_state(self, app, tpl, out, inputs=None): + """Start the wizard and inject valid template/files/output state, + then run the validation step (it auto-validates on entry).""" + app._start_wizard() app.template_path = tpl + app.template_error = None app.template_dims = core.page_dimensions(tpl) - app.input_paths = [tpl] + app.input_paths = list(inputs) if inputs is not None else [tpl] app.output_dir = out - app._validate() + app._goto_step(2) # validation auto-runs on first entry + app.update() + + +class GuiImageEndToEnd(_GuiTestBase): + """Regression: wizard → worker thread → queue → report path (image mode). + + Since Tkinter is not thread-safe, the worker must NOT call `after()`; + it pushes onto a queue drained by the main thread. Skipped if + tkinter/display is unavailable (e.g. headless CI).""" + + def test_launch_populates_report_and_writes_output(self): + import time + tpl = make_pdf(self.tmp / "t.pdf", [(595, 842), (595, 842)]) + png = make_png(self.tmp / "s.png") + out = self.tmp / "out" + app = self.make_app() + self.wizard_with_state(app, tpl, out) + self.assertEqual(len(app.valid_paths), 1) app.mode_var.set("image") - app._refresh_placement_section() app.image_path = png + app._goto_step(5) # placement step builds the canvas app._draw_page() app.update() fw, fh, ox, oy = app._frame_geom app._on_canvas_click(self.types.SimpleNamespace(x=ox + fw * 0.5, y=oy + fh * 0.5)) app.update() + app._goto_step(6) # signing step app._launch() + self.assertTrue(app._running) self.assertEqual(str(app.launch_btn.cget("state")), "disabled") # locked during + self.assertEqual(str(app._btn_next.cget("state")), "disabled") # nav locked for _ in range(120): # pump the Tk loop (max ~6 s) app.update() time.sleep(0.05) - if app.status_lbl.cget("text").startswith(("Done", "Error")): + if not app._running: break app.update() - status = app.status_lbl.cget("text") + run_error = app.run_error + results = app.run_results + step = app.step_index n_rows = len(app.summary_table.get_children()) - btn_state = str(app.launch_btn.cget("state")) app.destroy() - self.assertTrue(status.startswith("Done"), status) # not "Error" / not frozen + self.assertIsNone(run_error) + self.assertIsNotNone(results) # not "Error" / not frozen + self.assertEqual(step, 7) # auto-advanced to the report self.assertEqual(n_rows, 1) - self.assertEqual(btn_state, "normal") # re-enabled afterwards self.assertTrue((out / "t_signe.pdf").exists()) def test_run_batch_reports_systemexit_without_hanging(self): # open_eid_session() raises SystemExit (no reader/card); the worker # must catch it and publish an error, not die silently. - import gui - app = gui.CachetApp(self.types.SimpleNamespace(lib=None)) - app.update() + app = self.make_app() app._result_q = __import__("queue").Queue() def boom(*a, **k): @@ -862,32 +882,18 @@ def boom(*a, **k): self.assertIn("reader", payload) -class GuiBeidPlacement(unittest.TestCase): - """GUI-side refinements 1/4/5 in beid mode: 3:1 placeholder (1/5 page), - no image picker, canvas that follows the window. Skipped without a display.""" - - def setUp(self): - import types - try: - import tkinter - tkinter.Tk().destroy() - import gui # noqa: F401 - except Exception as exc: # noqa: BLE001 - self.skipTest(f"tkinter/GUI unavailable: {exc}") - self.types = types - self.tmp = Path(tempfile.mkdtemp()) +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.""" def test_beid_placeholder_and_resize(self): - import gui tpl = make_pdf(self.tmp / "t.pdf", [(600, 800), (600, 800)]) - app = gui.CachetApp(self.types.SimpleNamespace(lib=None)) + app = self.make_app() app.geometry("900x950") app.update() - app.template_path = tpl - app._page_img_cache.clear() - app.template_dims = core.page_dimensions(tpl) + self.wizard_with_state(app, tpl, self.tmp) app.mode_var.set("beid") - app._refresh_placement_section() + 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(), "") @@ -895,10 +901,12 @@ def test_beid_placeholder_and_resize(self): iw, ih = app._placeholder_size_pt() self.assertAlmostEqual(iw, 600 / 5) self.assertAlmostEqual(iw / ih, 3.0) - # a click sets the position (in beid mode too, not just image) + # 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.assertEqual(app.page_text, "1") big = app.canvas.winfo_reqwidth() # shrink the window -> the canvas shrinks, proportions preserved app.geometry("520x680") @@ -908,34 +916,98 @@ def test_beid_placeholder_and_resize(self): self.assertLess(small, big) -class GuiPageAnchor(unittest.TestCase): - """Step-4 first/last selector: appears only when some files' page count - differs from the template, locks the step-6 preview onto that template - page, and the batch signs those files on their own first/last page. - Skipped without a display.""" +class GuiWizardChrome(_GuiTestBase): + """Wizard shell: step gating, dynamic nav labels, stepper state colors, + cancel-confirm reset, and localized chrome.""" - def setUp(self): - import types - try: - import tkinter - tkinter.Tk().destroy() - import gui # noqa: F401 - except Exception as exc: # noqa: BLE001 - self.skipTest(f"tkinter/GUI unavailable: {exc}") - self.types = types - self.tmp = Path(tempfile.mkdtemp()) + def test_gating_and_dynamic_labels(self): + from i18n import tr + app = self.make_app() + app._start_wizard() + app.update() + # step 1: no template yet -> Previous and Next both disabled, and the + # Next label names the target step + self.assertEqual(str(app._btn_prev.cget("state")), "disabled") + self.assertEqual(str(app._btn_next.cget("state")), "disabled") + self.assertIn(tr("step.files.title"), app._btn_next.cget("text")) + # steps beyond the first incomplete one are locked + self.assertEqual(str(app._stepper_btns[4].cget("state")), "disabled") + app._goto_step(4) + self.assertEqual(app.step_index, 0) + # completing step 1 unlocks Next + tpl = make_pdf(self.tmp / "t.pdf", [(595, 842)]) + app.template_path = tpl + app.template_dims = core.page_dimensions(tpl) + app._refresh_chrome() + self.assertEqual(str(app._btn_next.cget("state")), "normal") + app.destroy() - def test_selector_hidden_without_mismatch(self): + def test_validation_errors_color_the_stepper(self): import gui tpl = make_pdf(self.tmp / "t.pdf", [(595, 842)]) - same = make_pdf(self.tmp / "same.pdf", [(595, 842)]) - app = gui.CachetApp(self.types.SimpleNamespace(lib=None)) + bad = make_pdf(self.tmp / "bad.pdf", [(300, 300)]) + app = self.make_app() + app._start_wizard() + app.template_path = tpl + app.template_error = None + app.template_dims = core.page_dimensions(tpl) + app.input_paths = [tpl, bad] + app._goto_step(2) app.update() + self.assertEqual(len(app.valid_paths), 1) + # a rejected file marks the validation step red; passed steps green + self.assertEqual(app._stepper_btns[2].cget("border_color"), gui._COL_ERROR) + self.assertEqual(app._stepper_btns[0].cget("border_color"), gui._COL_DONE) + app.destroy() + + def test_cancel_confirm_resets_to_landing(self): + import customtkinter as ctk + + from i18n import tr + app = self.make_app() + app._start_wizard() + tpl = make_pdf(self.tmp / "t.pdf", [(595, 842)]) app.template_path = tpl app.template_dims = core.page_dimensions(tpl) - app.input_paths = [same] - app.output_dir = self.tmp / "o" - app._validate() + app._refresh_chrome() + app._cancel_wizard() + app.update() + tops = [w for w in app.winfo_children() if isinstance(w, ctk.CTkToplevel)] + self.assertTrue(tops, "cancel confirmation modal missing") + btns = [w for w in tops[0].winfo_children()[-1].winfo_children() + if isinstance(w, ctk.CTkButton)] + confirm = next(b for b in btns if b.cget("text") == tr("cancel.confirm")) + confirm.invoke() + app.update() + self.assertIsNone(app.template_path) # all data reset + self.assertEqual(app._stepper_btns, []) # back on the landing page + app.destroy() + + def test_localized_wizard_chrome(self): + import i18n + app = self.make_app() + try: + i18n.set_language("fr") + app._start_wizard() + app.update() + self.assertEqual(app._stepper_btns[0].cget("text"), "1. Modèle") + self.assertEqual(app._btn_cancel.cget("text"), "Annuler") + finally: + i18n.set_language("en") + app.destroy() + + +class GuiPageAnchor(_GuiTestBase): + """Validation-step first/last selector: appears only when some files' + page count differs from the template, locks the placement preview onto + that template page (target-page field and Prev/Next disabled), and the + batch signs those files on their own first/last page.""" + + def test_selector_hidden_without_mismatch(self): + tpl = make_pdf(self.tmp / "t.pdf", [(595, 842)]) + same = make_pdf(self.tmp / "same.pdf", [(595, 842)]) + app = self.make_app() + self.wizard_with_state(app, tpl, self.tmp / "o", inputs=[same]) manager = app.anchor_row.winfo_manager() anchor = app._page_anchor() app.destroy() @@ -944,45 +1016,47 @@ def test_selector_hidden_without_mismatch(self): def test_selector_locks_page_and_signs_short_file(self): import time - import gui tpl = make_pdf(self.tmp / "t.pdf", [(595, 842), (595, 842)]) short = make_pdf(self.tmp / "short.pdf", [(595, 842)]) # 1 page vs 2 png = make_png(self.tmp / "s.png") out = self.tmp / "out" - app = gui.CachetApp(self.types.SimpleNamespace(lib=None)) - app.update() - app.template_path = tpl - app.template_dims = core.page_dimensions(tpl) - app.input_paths = [short] - app.output_dir = out - app._validate() - app.update() + app = self.make_app() + self.wizard_with_state(app, tpl, out, inputs=[short]) self.assertNotEqual(app.anchor_row.winfo_manager(), "") # selector shown self.assertEqual(app.valid_paths, [short]) # accepted + self.assertEqual(app._page_anchor(), "last") # default anchor self.assertEqual(app.cur_page, 1) # locked on the template's LAST page - app.anchor_choice_var.set("first page") # switch the anchor - app._validate() # (menu command re-validates) - self.assertEqual(app.cur_page, 0) - app._turn_page(1) # navigation is locked + app._set_page_anchor_choice("first") # switch (menu re-validates) + self.assertEqual(app._page_anchor(), "first") 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._refresh_placement_section() app.image_path = png - app._draw_page() + app._goto_step(5) # placement step, locked app.update() + self.assertEqual(app.cur_page, 0) + app._turn_page(1) # navigation is locked + self.assertEqual(app.cur_page, 0) + self.assertEqual(str(app.page_entry.cget("state")), "disabled") fw, fh, ox, oy = app._frame_geom app._on_canvas_click(self.types.SimpleNamespace(x=ox + fw * 0.5, y=oy + fh * 0.5)) + app.update() + app._goto_step(6) # signing step app._launch() for _ in range(120): # pump the Tk loop (max ~6 s) app.update() time.sleep(0.05) - if app.status_lbl.cget("text").startswith(("Done", "Error")): + if not app._running: break - status = app.status_lbl.cget("text") + app.update() + results = app.run_results + step = app.step_index n_rows = len(app.summary_table.get_children()) app.destroy() - self.assertTrue(status.startswith("Done"), status) + self.assertIsNotNone(results) + self.assertTrue(results[0].ok, results[0].detail) + self.assertIn("first page", results[0].detail) + self.assertEqual(step, 7) # auto-advanced to the report self.assertEqual(n_rows, 1) self.assertTrue((out / "short_signe.pdf").exists())