Skip to content

Add LFRM (Le Mans Arnage) customs form - #15

Merged
roznet merged 3 commits into
mainfrom
feat/lfrm-customs-form
Sep 9, 2026
Merged

roznet merged 3 commits into
mainfrom
feat/lfrm-customs-form

Conversation

@roznet

@roznet roznet commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Adds the airport-specific préavis douane for LFRM, and fixes a rendering bug in the shared PDF filler that it surfaced.

The form

lfrm.json + lfrm_customs.pdf, scoped to icao: LFRM, 4 crew / 7 passenger rows, routed to preavis-vols-douane-le-mans@douane.finances.gouv.fr cc codt-idf@… — the same routing the generic french_customs entry already used for LFRM. That generic form stays available as a second option for LFRM; no other airport's form list changes.

The template asks for Heure Locale, so the mapping declares time_reference: "local" with Europe/Paris.

Two quirks of the template are handled in the mapping rather than by re-authoring the PDF:

  • the four tick boxes are text fields, not checkboxes, so they take an "X" via checkbox_on
  • passenger rows are named …Row{n}_2 for rows 1–4 and plain …Row{n} for rows 5–7, which no single {n} pattern spans, so they use the filler's literal-index form

The filler fix

_fix_autosize_fields only rebuilt appearance streams for auto-size (0 Tf) fields. Fields with a fixed size were left alone even when that size overflowed a short box — pypdf then places the text baseline below the box and the value renders clipped.

This is already affecting LFQA in production: "Nature du vol: private" is cut off on every form generated today. Confirmed by rendering the current main output.

The function now rebuilds the appearance whenever the declared size doesn't fit, clamped to the box height, and additionally handles two things LibreOffice-authored templates do that the old code skipped: widgets whose /T lives on a parent field node, and fields with no appearance stream at all (previously left to whatever the viewer chose to draw).

Font sizes are read from the template, because filling rewrites an inherited auto-size /DA to a concrete 12 Tf on the field node — the same check against the filled document finds nothing.

Value-neutral: no existing snapshot changed; only the two new LFRM snapshots were created. The change is purely in how text is drawn.

Also in the filler

Three additions, none used by any existing mapping:

  • local-time conversion when a mapping declares time_reference: "local"
  • arrival.remote / departure.remote (+ _name, _country) for the Provenance / Aéroport / Pays triple, filled only on the side matching the flight direction
  • | alternation on enum checkboxes (flight.nature.private|business). Without it, mapping two nature values to the same field would have the second silently overwrite the first with "off"

Testing

153 passed. The 2 flatten failures are pre-existing — verified they fail on a clean tree too.

Previews visually verified in both directions with self-describing values; ORIG/OriginCity/OriginCountry on arrival and DEST/DestCity/DestCountry on departure land in the right boxes, and only the active direction's column fills.

Note for review

The lower rows (Provenance / Aéroport / Pays / Nature du vol) render at ~7.6pt vs 12pt above, because those boxes are progressively shorter and sit slightly below their printed labels. Nothing is clipped and it's legible. Making those boxes ~14pt tall in LibreOffice would even it out — deliberately not done here as a re-export would discard any geometry edited outside the source document.

🤖 Generated with Claude Code

https://claude.ai/code/session_015cLZEJUmiCnbxfytMKN18e

roznet and others added 2 commits September 9, 2026 20:28
_fix_autosize_fields only rebuilt the appearance stream for fields whose
/DA asked for auto-sized text (0 Tf).  Fields with a fixed size were left
alone even when that size overflowed a short box, where pypdf places the
baseline below the box and the value renders clipped.  LFQA has shipped
this way: "Nature du vol: private" is cut off on every generated form.

Rebuild the appearance for a fixed size too when it does not fit, clamped
to the box height.  Along the way, handle two things LibreOffice-authored
templates do that the old code skipped:

  - a widget whose /T lives on a parent field node rather than the widget
  - a field with no appearance stream at all, which previously left the
    rendering up to whatever the viewer decided to draw

Font sizes are read from the template, because filling rewrites an
inherited auto-size /DA to a concrete "12 Tf" on the field node.

Values are unchanged — no existing snapshot moves.

Three additions to the filler's vocabulary, all unused by existing
mappings, for a form landing in the next commit:

  - convert times to the form's time zone when a mapping declares
    time_reference "local"
  - arrival.remote / departure.remote (plus _name and _country), the
    other end of the leg, filled only on the side matching the flight
    direction
  - allow several enum values to share one checkbox via "|", e.g.
    flight.nature.private|business; mapping them as separate entries
    made the later one overwrite the earlier one's "on" with "off"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cLZEJUmiCnbxfytMKN18e
Airport-specific préavis douane for LFRM, alongside the generic LF-prefix
form which stays available as a second option.  Same customs routing as
the french_customs entry for LFRM (preavis-vols-douane-le-mans, cc
codt-idf).

The template asks for local time, so the mapping declares time_reference
"local" with Europe/Paris.

Two quirks of the template the mapping works around:

  - the four tick boxes are text fields, not checkboxes, so they take an
    "X" (checkbox_on) rather than a checkbox state
  - passenger rows are named ...Row{n}_2 for rows 1-4 and plain ...Row{n}
    for rows 5-7, which no single {n} pattern spans, so they use literal
    indices

Provenance / Aéroport / Pays map to arrival.remote / departure.remote, so
only the side matching the flight direction is filled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cLZEJUmiCnbxfytMKN18e
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Code Review

Reviewed against designs/form-system.md (no repo-root CLAUDE.md exists in this repo). The mapping structure, registry resolution, and text-appearance fix all match the documented design. One correctness issue worth fixing before merge:

Local time can cross midnight without the paired date field following it — src/flightforms/fillers/pdf_filler.py:110-120

def _time(time_str, date_str):
if mapping.time_reference == "local" and mapping.time_zone:
return _utc_to_local(time_str, date_str, mapping.time_zone)
return time_str
...
local_date = request.flight.arrival_date if is_arrival else request.flight.departure_date
local_time = arr_time if is_arrival else dep_time

arr_time/dep_time are converted to local wall-clock time, but arrival.date/departure.date (and flight.date) still use the raw UTC-based date from the request, unconverted. For a flight where the UTC time is late evening, converting to Europe/Paris can roll the local time into the next calendar day (e.g. 2024-06-01 23:50 UTC -> 2024-06-02 01:50 local CEST), but the date field printed on the form stays 2024-06-01. This is exactly the field pair (ARRIVAL_DATE/ARRIVAL_LOCAL_TIME, DEPARTURE_DATE/DEPARTURE_LOCAL_TIME) that lfrm.json fills, and this is a customs pre-notification document, so a mismatched date+time is a real problem for flights near midnight UTC, not just cosmetic.

Since this is the first mapping to actually exercise time_reference: "local" in pdf_filler.py, this edge case has not been hit by any existing form. Suggest deriving the printed date from the same converted local datetime (e.g. have _utc_to_local also expose the local date) rather than reusing the UTC-based date string.

Everything else looks solid: the _fix_autosize_fields -> _fix_text_appearances change is well-scoped (font size read from template, box-height clamping, LibreOffice parent-node /T lookup, missing-/AP handling) and is documented as value-neutral with passing snapshots for existing forms; the | -alternation fix for enum checkboxes correctly addresses a real last-value-wins overwrite bug when two enum values map to the same box; lfrm.json follows the documented mapping schema (scope, email_overrides, literal-index person fields for the irregular Row{n}_2/Row{n} naming) and preview.py additions match the checklist in designs/form-system.md.

A form declaring time_reference "local" had its times converted to the
airport's wall clock while its dates stayed on the UTC calendar day.  A
late-evening UTC slot falls on the next day locally — 23:50Z on 1 June is
01:50 on 2 June in Paris — so the form printed "01/06 at 01:50", misdating
the flight by a day on a customs pre-notification.

Convert each date/time pair together.  Affects the ARRIVAL_DATE /
ARRIVAL_LOCAL_TIME and DEPARTURE_DATE / DEPARTURE_LOCAL_TIME pairs that
lfrm.json fills, and the same pairs in french_customs, which has shipped
this way for the whole LF prefix.

The conversion moves to a shared fillers/_datetime.py, replacing the two
copies that had drifted apart in intent.

Regression tests cover the midnight roll, a same-day conversion that must
not shift, and a UTC form that must not convert at all.

Reported by the PR review bot on #15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cLZEJUmiCnbxfytMKN18e
@roznet

roznet commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Good catch on the midnight roll — confirmed and fixed in 55bf449.

Reproduced on main: a flight arriving 23:50Z on 1 June printed ARRIVAL_DATE = 01/06/2026 against ARRIVAL_LOCAL_TIME = 01:50, a day out.

Fixed by converting each date/time pair together rather than exposing a local date from the time helper — same result, but it makes it structurally hard to convert one without the other, since the caller gets both back or neither.

One thing beyond what the review flagged: french_customs_filler.py has the identical defect, and unlike the new form it is already in production for the entire LF prefix. It converted dep_time/arr_time to local while departure_date/arrival_date stayed UTC-based. Fixed there too, and the conversion now lives in a shared fillers/_datetime.py instead of two copies that had drifted.

Regression tests added for three cases: the midnight roll, a same-day conversion that must not shift the date, and a UTC-reference form (lfqa) that must not convert at all.

157 tests pass; no existing snapshot moved.

@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Code Review

Reviewed the LFRM form addition and the shared filler changes it surfaced. Overall solid: the appearance-stream fix is well targeted at the described LFQA clipping bug, _datetime.py correctly converts date+time together (with good regression tests for the midnight-roll case), and the LFRM mapping's row-naming quirks match what the snapshot fixtures show. No logic bugs found in the diff.

Two minor gaps worth a follow-up (not blocking):

  1. No test coverage for _fix_text_appearances/_template_font_sizes (pdf_filler.py). This is the actual fix for the motivating bug (clipped "Nature du vol" text), but nothing in the suite exercises the sizing/skip logic or the new "build appearance stream from scratch" path — the snapshot tests only assert field /V values, not rendered font size or appearance stream contents. A unit test asserting the computed font size for a too-tall fixed-size field (and that a field within bounds is left untouched) would catch regressions here that snapshots can't.

  2. designs/form-system.md wasn't updated for this PR: the "Current Form Inventory" table doesn't list lfrm, and the "Canonical Field Names" section doesn't mention the new arrival.remote/departure.remote (+ _name/_country) fields added to the filler's vocabulary.

🤖 Generated with Claude Code

@roznet
roznet merged commit 6da79f0 into main Sep 9, 2026
2 checks passed
@roznet
roznet deleted the feat/lfrm-customs-form branch September 9, 2026 19:44
roznet added a commit that referenced this pull request Sep 9, 2026
Both follow-ups from the review on #15, neither of which changes behaviour.

Snapshot tests compare field values only, so nothing in the suite noticed
whether a value was drawn at a size that fits its box — the clipping the
appearance logic exists to prevent.  TestTextAppearances reads the font
size and baseline back out of the generated appearance streams and asserts
every value lands inside its rectangle, plus the specific cases: an
oversized fixed size is shrunk, a fitting one is left as authored, and an
auto-size field still caps at 12pt.  Verified these fail against the
pre-fix behaviour.

Design doc: add lfrm to the form inventory and the architecture tree,
document the arrival.remote/departure.remote triple and the "|" enum
alternation, and record three things this form cost time to learn —
LibreOffice's parent-node /T and missing appearance streams, font size
having to fit the box, and date/time converting together.

Also corrects the lfqa inventory row, which said 3 airports where the
icao_list has 11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cLZEJUmiCnbxfytMKN18e
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