Skip to content

feat(exports): render instructor endorsements in the PDF remarks column - #205

Merged
fjaeckel merged 2 commits into
mainfrom
feat/pdf-instructor-endorsements
Aug 26, 2026
Merged

feat(exports): render instructor endorsements in the PDF remarks column#205
fjaeckel merged 2 commits into
mainfrom
feat/pdf-instructor-endorsements

Conversation

@fjaeckel

Copy link
Copy Markdown
Owner

What

A flight locked by a completed FlightSignature now prints that sign-off with the row it attests. In the right-hand part of the REMARKS AND ENDORSEMENTS cell: the instructor's captured ink, their name, and No. <credential> · <signed date>, on a tinted panel with a gold edge marker and a baseline rule under the ink. The remark text keeps the space to its left.

Why

A printed logbook dropped its endorsements entirely — the sign-off existed only in the app. The endorsement beside the flight is exactly what an EASA or FAA inspector looks for on the page.

How it adapts

The same remarks cell is 111 mm wide on an FAA spread and 21 mm on an EASA single-page A5, so the block degrades down a ladder:

Layout ink name credential line
EASA/FAA spread A4, FAA single A4, EASA spread A5 yes full yes
EASA single A4, dense rows (rows_per_page=60) yes full or K. Vogelsang
EASA single A5 (narrowest) full

Two judgment calls worth a reviewer's attention:

  • Where ink and name cannot both fit, the name wins. An unattributed mark tells an inspector nothing, so text beats ink at the bottom of the ladder.
  • Where the credential line is squeezed, the signing date is dropped rather than the credential number cut off — the number is what identifies the signer.

Names abbreviate to initials-plus-surname (Katrin VogelsangK. Vogelsang) before fitText resorts to truncating them.

Raster handling

Signature images are decoded, box-downscaled to at most 480×200 px and re-encoded as clean 8-bit PNG, so fpdf never meets an interlaced or 16-bit image and a career export does not carry hundreds of megabytes of ink. Each signature is embedded once per document.

  • A DecodeConfig pixel-count check rejects a decompression bomb before it is decoded (the stored image is not validated as PNG at upload time).
  • A per-document byte budget caps total embedded raster.
  • Anything unusable costs the ink and not the signer's details, and fpdf's error state is cleared so one bad image cannot poison the whole document.

Data path

FlightSignatureService.ListSignedForFlights follows each flight's signature_id lock pointer and filters to completed rows the caller owns, backed by a new batched FlightSignatureRepository.GetByIDs. Voiding a signature clears the pointer, so the block disappears from the next export. A lookup failure costs the endorsements, not the export.

No schema change, no spec change — make route-check reports the same 158 operations.

Reviewer notes

  • A real bug was found by looking at the rendered output: drawEndorsement left the fpdf cursor wherever its last cell ended, so the row after every signed row printed at the wrong position — visible as stray cells outside the right margin. drawDataRow now saves and restores the cursor around it.
  • drawDataRow gained a flightID parameter, which is the bulk of the test-file churn.
  • Sample flights now carry real UUIDs, and TestGenerateSamplePDFs emits two dense _60rows variants for reviewing the tightest case:
    GENERATE_SAMPLE_PDFS=1 SAMPLE_PDF_DIR=/tmp/ninerlog-pdf-samples go test ./internal/api/handlers/ -run TestGenerateSamplePDFs -count=1
    

Verification

Every format × layout × page size was rendered and inspected at 200–300 dpi.

  • make fmt, make test, make migrate-check, make route-check — clean.
  • Full scripts/run-e2e-tests.sh green, including the new TestExportPDFRendersInstructorEndorsement (signed flight prints signer + embeds the ink for both regulations; voiding removes both).
  • Unit tests cover all four layouts, raster dedup, four bad-raster paths, the zone-width ladder, and normalization.
  • make lint reports the same 57 pre-existing issues before and after this branch (verified by stashing); this change adds none.

Docs

docs/API.md (the GET /exports/pdf section) and docs/FEATURES.md (the Export bullet) both describe the endorsement block, its adaptation ladder and the raster limits.

Not in scope

A certification appendix page listing every signature in full (signer, credential, signed-at UTC, method, signature ID, ink at legible size) would be the strongest verifiable-by-an-authority surface, but it is beyond "render under remarks" and is left for a follow-up.

🤖 Generated with Claude Code

fjaeckel and others added 2 commits August 26, 2026 16:17
A flight locked by a completed FlightSignature now prints that sign-off
with the row it attests: the instructor's captured ink, their name and
"No. <credential> · <signed date>", on a tinted panel with a gold edge
marker in the right-hand part of the REMARKS AND ENDORSEMENTS cell. A
printed logbook previously dropped its endorsements entirely, which is
exactly what an authority looks for.

The block adapts to the column it lands in, because the same remarks cell
is 111 mm wide on an FAA spread and 21 mm on an EASA single-page A5. Two
choices are worth recording:

  - Where ink and name cannot both fit, the name takes the space. An
    unattributed mark tells an inspector nothing, so text beats ink at
    the bottom of the ladder.
  - Where the credential line is squeezed, the signing date is dropped
    rather than the credential number cut off — the number is what
    identifies the signer.

Names abbreviate to initials-plus-surname ("Katrin Vogelsang" ->
"K. Vogelsang") before fitText resorts to truncating them.

Rasters are decoded, box-downscaled to at most 480x200 px and re-encoded
as clean 8-bit PNG, so fpdf never meets an interlaced or 16-bit image and
a career export does not carry hundreds of megabytes of ink. A
DecodeConfig pixel-count check rejects a decompression bomb before it is
decoded; a per-document byte budget caps total embedded raster. Anything
unusable costs the ink and not the signer's details, and fpdf's error
state is cleared so one bad image cannot poison the whole document.

Signatures reach the renderers through FlightSignatureService, which
follows each flight's signature_id lock pointer and filters to rows the
caller owns — voiding a signature clears the pointer and so removes the
block from the next export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gosec G115 flagged uint8(a >> 8) in downscaleNRGBA: the averaged alpha is
a uint64 the analyser cannot bound, even though every channel RGBA()
returns is at most 0xffff. The colour channels already narrowed through a
clamp, which gosec accepts, so the alpha now takes the same path via a
shared to8 helper rather than converting inline.

Output is unchanged — the clamp never fires for valid input, and the
embedded rasters of a rendered sample are byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fjaeckel
fjaeckel merged commit 5d42e07 into main Aug 26, 2026
15 checks passed
@fjaeckel
fjaeckel deleted the feat/pdf-instructor-endorsements branch August 26, 2026 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant