From 9f7ed11f1db6541233af959aa1a71a10991234e8 Mon Sep 17 00:00:00 2001 From: brice Date: Wed, 9 Sep 2026 20:28:34 +0100 Subject: [PATCH 1/3] fix(pdf): draw form text that fits its field box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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) Claude-Session: https://claude.ai/code/session_015cLZEJUmiCnbxfytMKN18e --- src/flightforms/fillers/pdf_filler.py | 192 ++++++++++++++++++++------ 1 file changed, 149 insertions(+), 43 deletions(-) diff --git a/src/flightforms/fillers/pdf_filler.py b/src/flightforms/fillers/pdf_filler.py index 0dfa9f0..527322f 100644 --- a/src/flightforms/fillers/pdf_filler.py +++ b/src/flightforms/fillers/pdf_filler.py @@ -6,9 +6,16 @@ from datetime import datetime from io import BytesIO from pathlib import Path +from zoneinfo import ZoneInfo from pypdf import PdfReader, PdfWriter -from pypdf.generic import NameObject +from pypdf.generic import ( + ArrayObject, + DecodedStreamObject, + DictionaryObject, + FloatObject, + NameObject, +) from ..api.models import GenerateRequest from ..registry import FormMapping @@ -50,6 +57,13 @@ def _parse_date(date_str: str, fmt: str) -> str: return dt.strftime(fmt) +def _utc_to_local(time_str: str, date_str: str, tz_name: str) -> str: + """Convert HH:MM UTC to local time in the given timezone.""" + dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M") + dt_utc = dt.replace(tzinfo=ZoneInfo("UTC")) + return dt_utc.astimezone(ZoneInfo(tz_name)).strftime("%H:%M") + + def _resolve_field_pattern(pattern: str, index: int) -> str: """Resolve {i} (0-based) and {n} (1-based) in field name patterns.""" return pattern.replace("{i}", str(index)).replace("{n}", str(index + 1)) @@ -89,15 +103,27 @@ def fill_pdf( # Build values dict for simple fields observations = request.observations or mapping.default_observations or "" + # Request times are always UTC. Forms that ask for the airport's wall + # clock ("Heure Locale") declare time_reference "local" plus a time_zone; + # every time value below goes through _time() so the whole form stays in + # one reference. + def _time(time_str: str, date_str: str) -> str: + if mapping.time_reference == "local" and mapping.time_zone: + return _utc_to_local(time_str, date_str, mapping.time_zone) + return time_str + + dep_time = _time(request.flight.departure_time_utc, request.flight.departure_date) + arr_time = _time(request.flight.arrival_time_utc, request.flight.arrival_date) + # Direction-aware date/time: resolves to arrival or departure based on direction local_date = request.flight.arrival_date if is_arrival else request.flight.departure_date - local_time = request.flight.arrival_time_utc if is_arrival else request.flight.departure_time_utc + local_time = arr_time if is_arrival else dep_time values = { "flight.departure_date": _parse_date(request.flight.departure_date, mapping.date_format), "flight.arrival_date": _parse_date(request.flight.arrival_date, mapping.date_format), - "flight.departure_time_utc": request.flight.departure_time_utc, - "flight.arrival_time_utc": request.flight.arrival_time_utc, + "flight.departure_time_utc": dep_time, + "flight.arrival_time_utc": arr_time, "flight.date": _parse_date(local_date, mapping.date_format), "flight.time": local_time, "flight.origin": request.flight.origin, @@ -139,20 +165,28 @@ def fill_pdf( if is_arrival: values.update({ "arrival.date": _parse_date(request.flight.arrival_date, mapping.date_format), - "arrival.time": request.flight.arrival_time_utc, + "arrival.time": arr_time, "arrival.registration": request.aircraft.registration, "arrival.type": request.aircraft.type, "arrival.owner": request.aircraft.owner or "", "arrival.nature": request.flight.nature, + # The other end of the leg (where the flight came from) + "arrival.remote": remote_icao, + "arrival.remote_name": airport_resolver.get_name(remote_icao), + "arrival.remote_country": remote_country, }) else: values.update({ "departure.date": _parse_date(request.flight.departure_date, mapping.date_format), - "departure.time": request.flight.departure_time_utc, + "departure.time": dep_time, "departure.registration": request.aircraft.registration, "departure.type": request.aircraft.type, "departure.owner": request.aircraft.owner or "", "departure.nature": request.flight.nature, + # The other end of the leg (where the flight is going) + "departure.remote": remote_icao, + "departure.remote_name": airport_resolver.get_name(remote_icao), + "departure.remote_country": remote_country, }) # Add extra fields @@ -173,9 +207,9 @@ def fill_pdf( values["connecting.origin"] = cf.origin values["connecting.destination"] = cf.destination values["connecting.departure_date"] = _parse_date(cf.departure_date, mapping.date_format) - values["connecting.departure_time_utc"] = cf.departure_time_utc + values["connecting.departure_time_utc"] = _time(cf.departure_time_utc, cf.departure_date) values["connecting.arrival_date"] = _parse_date(cf.arrival_date, mapping.date_format) - values["connecting.arrival_time_utc"] = cf.arrival_time_utc + values["connecting.arrival_time_utc"] = _time(cf.arrival_time_utc, cf.arrival_date) # Airport-centric leg values: for forms that show both an arrival and a # departure section at the target airport (e.g. Jersey GenDec). The @@ -185,24 +219,24 @@ def fill_pdf( values["airport.arrival.from"] = request.flight.origin values["airport.arrival.from_name"] = airport_resolver.get_name(request.flight.origin) values["airport.arrival.date"] = _parse_date(request.flight.arrival_date, mapping.date_format) - values["airport.arrival.time"] = request.flight.arrival_time_utc + values["airport.arrival.time"] = arr_time if request.connecting_flight: cf = request.connecting_flight values["airport.departure.to"] = cf.destination values["airport.departure.to_name"] = airport_resolver.get_name(cf.destination) values["airport.departure.date"] = _parse_date(cf.departure_date, mapping.date_format) - values["airport.departure.time"] = cf.departure_time_utc + values["airport.departure.time"] = _time(cf.departure_time_utc, cf.departure_date) else: values["airport.departure.to"] = request.flight.destination values["airport.departure.to_name"] = airport_resolver.get_name(request.flight.destination) values["airport.departure.date"] = _parse_date(request.flight.departure_date, mapping.date_format) - values["airport.departure.time"] = request.flight.departure_time_utc + values["airport.departure.time"] = dep_time if request.connecting_flight: cf = request.connecting_flight values["airport.arrival.from"] = cf.origin values["airport.arrival.from_name"] = airport_resolver.get_name(cf.origin) values["airport.arrival.date"] = _parse_date(cf.arrival_date, mapping.date_format) - values["airport.arrival.time"] = cf.arrival_time_utc + values["airport.arrival.time"] = _time(cf.arrival_time_utc, cf.arrival_date) # Fill fields updates = {} @@ -236,13 +270,17 @@ def fill_pdf( updates[pdf_field] = f"/{radio_state}" continue - # Handle enum-to-checkbox (e.g. flight.nature.private) + # Handle enum-to-checkbox (e.g. flight.nature.private). Several enum + # values can share one box via "|" (e.g. flight.nature.private|business + # for a form whose only distinction is passengers vs cargo) — mapping + # them as separate entries would make the later one overwrite the + # earlier one's "on" with "off". if len(parts) == 3 and parts[0] + "." + parts[1] in values: enum_key = parts[0] + "." + parts[1] enum_val = values[enum_key].lower() - check_val = parts[2].lower() + check_vals = parts[2].lower().split("|") on_val = checkbox_on_values.get(pdf_field, mapping.checkbox_on) - updates[pdf_field] = on_val if enum_val == check_val else mapping.checkbox_off + updates[pdf_field] = on_val if enum_val in check_vals else mapping.checkbox_off continue # Handle aircraft.airplane / aircraft.helicopter checkboxes @@ -284,10 +322,12 @@ def fill_pdf( for page in writer.pages: writer.update_page_form_field_values(page, updates, auto_regenerate=flatten) - # Fix auto-size fields: pypdf uses field height as font size instead of - # calculating a size that fits the text width. We rewrite the appearance - # stream for any field whose original /DA had font size 0. - _fix_autosize_fields(writer, updates) + # Draw text that fits: pypdf renders auto-size fields at a flat 12pt, and + # leaves a fixed size alone even when it overflows a short box (which puts + # the baseline below the box, clipping the value). We rebuild the + # appearance for both cases. Sizes come from the template, because filling + # rewrites an inherited auto-size /DA to a concrete "12 Tf". + _fix_text_appearances(writer, updates, _template_font_sizes(reader)) output = BytesIO() writer.write(output) @@ -301,27 +341,75 @@ def fill_pdf( _PADDING = 4 # 2px each side -def _fix_autosize_fields(writer: PdfWriter, updates: dict): - """Rewrite appearance streams for fields whose template DA had font size 0.""" - # Get form-level DA as fallback for fields that inherit it - acroform = writer._root_object.get("/AcroForm") +def _widget_field_name(annot, parent): + """Field name for a widget, looking through to the parent field node.""" + name = annot.get("/T") + if name is None and parent is not None: + name = parent.get("/T") + return name + + +def _template_font_sizes(reader: PdfReader) -> dict: + """Font size each field's /DA asks for in the template; 0 means auto-size. + + Fields whose /DA declares no size at all are left out, so callers skip + them rather than guessing. + + Must be read from the template: filling replaces an inherited auto-size + /DA with a concrete "/Helv 12 Tf" on the field node, so the same lookup + against the filled document would report every field as fixed-size. + """ + acroform = reader.trailer["/Root"].get("/AcroForm") form_da = "" + if acroform is not None: + form_da = acroform.get_object().get("/DA", "") + + sizes: dict[str, float] = {} + for page in reader.pages: + for annot_ref in page.get("/Annots", []) or []: + annot = annot_ref.get_object() + if annot.get("/Subtype") != "/Widget": + continue + parent = annot.get("/Parent") + parent = parent.get_object() if parent is not None else None + name = _widget_field_name(annot, parent) + if not name: + continue + da = annot.get("/DA", "") + if not da and parent is not None: + da = parent.get("/DA", "") + da = da or form_da + match = re.search(r"([\d.]+)\s+Tf\b", da or "") + if match: + sizes[name] = float(match.group(1)) + return sizes + + +def _fix_text_appearances(writer: PdfWriter, updates: dict, template_sizes: dict): + """Rebuild appearance streams for text that would not fit its field box.""" + acroform = writer._root_object.get("/AcroForm") + form_resources = None if acroform: af = acroform.get_object() if hasattr(acroform, "get_object") else acroform - form_da = af.get("/DA", "") + dr = af.get("/DR") + if dr is not None: + form_resources = dr.get_object() for page in writer.pages: annots = page.get("/Annots", []) for annot_ref in annots: annot = annot_ref.get_object() - field_name = annot.get("/T") + + # A widget may carry the field name itself, or be the kid of a + # separate field node that holds /T (how LibreOffice emits some + # fields). Look through to the parent in that case. + parent = annot.get("/Parent") + parent = parent.get_object() if parent is not None else None + field_name = _widget_field_name(annot, parent) if not field_name or field_name not in updates: continue - - # Check if this field's DA specifies auto-size (font size 0) - # Fall back to form-level DA if the annotation has none - da = annot.get("/DA", "") or form_da - if not re.search(r"\b0\s+Tf\b", da): + declared_size = template_sizes.get(field_name) + if declared_size is None: continue text = updates[field_name] @@ -339,24 +427,27 @@ def _fix_autosize_fields(writer: PdfWriter, updates: dict): if field_width <= 0 or field_height <= 0: continue - # Calculate font size that fits the text width, capped at a - # sensible default (Acrobat auto-size typically picks ~12pt for - # standard form fields, never larger than the box allows). + # A fixed size that already fits its box is left as authored. + if 0 < declared_size <= field_height - 2: + continue + + # Calculate font size that fits the text width, capped by the box + # height and by the authored size (auto-size fields cap at 12pt, + # what Acrobat typically picks for a standard form field). usable_width = field_width - _PADDING size_by_width = usable_width / (len(text) * _HELV_AVG_WIDTH_RATIO) - max_size = min(field_height - 2, 12) # cap at 12pt + max_size = min(field_height - 2, declared_size or 12) font_size = min(size_by_width, max_size) font_size = max(font_size, 4) # floor at 4pt - # Rebuild the appearance stream + # Rebuild the appearance stream. Fields authored without one + # (again, common from LibreOffice) get a fresh stream rather than + # being left to whatever the viewer decides to draw. ap = annot.get("/AP") - if not ap or "/N" not in ap: - continue - - stream_obj = ap["/N"].get_object() - try: - data = stream_obj.get_data().decode("latin-1") - except Exception: + stream_obj = ap["/N"].get_object() if ap and "/N" in ap else None + # A button's /N is a dictionary of appearance states keyed by + # value, not a text stream — nothing to re-draw. + if stream_obj is not None and not hasattr(stream_obj, "set_data"): continue # Build a clean appearance stream from scratch. pypdf encodes @@ -384,7 +475,22 @@ def _fix_autosize_fields(writer: PdfWriter, updates: dict): "Q\n" ) - stream_obj.set_data(new_data.encode("latin-1")) + if stream_obj is not None: + stream_obj.set_data(new_data.encode("latin-1")) + else: + stream_obj = DecodedStreamObject() + stream_obj.set_data(new_data.encode("latin-1")) + stream_obj[NameObject("/Type")] = NameObject("/XObject") + stream_obj[NameObject("/Subtype")] = NameObject("/Form") + stream_obj[NameObject("/BBox")] = ArrayObject( + [FloatObject(0), FloatObject(0), + FloatObject(field_width), FloatObject(field_height)] + ) + if form_resources is not None: + stream_obj[NameObject("/Resources")] = form_resources + annot[NameObject("/AP")] = DictionaryObject( + {NameObject("/N"): writer._add_object(stream_obj)} + ) def _fill_person_fields( From f14f55d74b14c945cdc4f122ac5077b7a3c7b459 Mon Sep 17 00:00:00 2001 From: brice Date: Wed, 9 Sep 2026 20:28:42 +0100 Subject: [PATCH 2/3] feat(lfrm): add Le Mans Arnage customs form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_015cLZEJUmiCnbxfytMKN18e --- src/flightforms/mappings/lfrm.json | 90 +++++++++++++++++++++ src/flightforms/preview.py | 3 +- src/flightforms/templates/lfrm_customs.pdf | Bin 0 -> 56866 bytes tests/snapshots/lfrm.json | 32 ++++++++ tests/snapshots/lfrm_departure.json | 32 ++++++++ 5 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/flightforms/mappings/lfrm.json create mode 100644 src/flightforms/templates/lfrm_customs.pdf create mode 100644 tests/snapshots/lfrm.json create mode 100644 tests/snapshots/lfrm_departure.json diff --git a/src/flightforms/mappings/lfrm.json b/src/flightforms/mappings/lfrm.json new file mode 100644 index 0000000..59c0bdc --- /dev/null +++ b/src/flightforms/mappings/lfrm.json @@ -0,0 +1,90 @@ +{ + "icao": "LFRM", + "label": "Préavis Douane (Le Mans Arnage)", + "template": "lfrm_customs.pdf", + "type": "pdf_acroform", + "version": "1.0", + "time_reference": "local", + "time_zone": "Europe/Paris", + "max_crew": 4, + "max_passengers": 7, + "send_to": "preavis-vols-douane-le-mans@douane.finances.gouv.fr", + "email_overrides": { + "LFRM": {"cc": ["codt-idf@douane.finances.gouv.fr"]} + }, + "default_observations": "Rien à déclarer", + "date_format": "%d/%m/%Y", + "checkbox_on": "X", + "checkbox_off": "", + "has_connecting_flight": false, + "extra_fields": [], + "required_fields": { + "flight": ["origin", "destination", "departure_date", "departure_time_utc", "arrival_date", "arrival_time_utc"], + "aircraft": ["registration", "type"], + "crew": ["first_name", "last_name", "nationality", "id_number"], + "passengers": ["first_name", "last_name", "nationality", "id_number"] + }, + "field_map": { + "direction.arrival_mark": "DEMANDE ARRIVEE ARRIVAL REQUEST", + "direction.departure_mark": "DEMANDE DEPART DEPARTURE REQUEST", + + "arrival.date": "ARRIVAL_DATE", + "arrival.time": "ARRIVAL_LOCAL_TIME", + "arrival.owner": "ARRIVAL_OWNER", + "arrival.registration": "ARRIVAL_REGISTRATION", + "arrival.type": "ARRIVAL_AIRCRAFT_TYPE", + "arrival.remote": "ARRIVAL_ORIGIN", + "arrival.remote_name": "ARRIVAL_AIRPORT", + "arrival.remote_country": "ARRIVAL_COUNTRY", + "arrival.nature": "ARRIVAL_FLIGHT_TYPE", + + "departure.date": "DEPARTURE_DATE", + "departure.time": "DEPARTURE_LOCAL_TIME", + "departure.owner": "DEPARTURE_OWNER", + "departure.registration": "DEPARTURE_REGISTRATION", + "departure.type": "DEPARTURE_AIRCRAFT_TYPE", + "departure.remote": "DEPARTURE_ORIGIN", + "departure.remote_name": "DEPARTURE_AIRPORT", + "departure.remote_country": "DEPARTURE_COUNTRY", + "departure.nature": "DEPARTURE_FLIGHT_TYPE", + + "flight.nature.private|business|commercial|other": "PASSAGERS PASSENGERS", + "flight.nature.fret": "FRET CARGO", + + "flight.observations": "OBSERVATIONS", + + "crew[{i}].last_name": "NOM NAMERow{n}", + "crew[{i}].first_name": "PRENOM FIRST NAMERow{n}", + "crew[{i}].nationality": "NATIONALITE NATIONALITYRow{n}", + "crew[{i}].id_number": "N PASSEPORTCI PASSPORTRow{n}", + + "passengers[0].last_name": "NOM NAMERow1_2", + "passengers[0].first_name": "PRENOM FIRST NAMERow1_2", + "passengers[0].nationality": "NATIONALITE NATIONALITYRow1_2", + "passengers[0].id_number": "N PASSEPORTCI PASSPORTRow1_2", + "passengers[1].last_name": "NOM NAMERow2_2", + "passengers[1].first_name": "PRENOM FIRST NAMERow2_2", + "passengers[1].nationality": "NATIONALITE NATIONALITYRow2_2", + "passengers[1].id_number": "N PASSEPORTCI PASSPORTRow2_2", + "passengers[2].last_name": "NOM NAMERow3_2", + "passengers[2].first_name": "PRENOM FIRST NAMERow3_2", + "passengers[2].nationality": "NATIONALITE NATIONALITYRow3_2", + "passengers[2].id_number": "N PASSEPORTCI PASSPORTRow3_2", + "passengers[3].last_name": "NOM NAMERow4_2", + "passengers[3].first_name": "PRENOM FIRST NAMERow4_2", + "passengers[3].nationality": "NATIONALITE NATIONALITYRow4_2", + "passengers[3].id_number": "N PASSEPORTCI PASSPORTRow4_2", + "passengers[4].last_name": "NOM NAMERow5", + "passengers[4].first_name": "PRENOM FIRST NAMERow5", + "passengers[4].nationality": "NATIONALITE NATIONALITYRow5", + "passengers[4].id_number": "N PASSEPORTCI PASSPORTRow5", + "passengers[5].last_name": "NOM NAMERow6", + "passengers[5].first_name": "PRENOM FIRST NAMERow6", + "passengers[5].nationality": "NATIONALITE NATIONALITYRow6", + "passengers[5].id_number": "N PASSEPORTCI PASSPORTRow6", + "passengers[6].last_name": "NOM NAMERow7", + "passengers[6].first_name": "PRENOM FIRST NAMERow7", + "passengers[6].nationality": "NATIONALITE NATIONALITYRow7", + "passengers[6].id_number": "N PASSEPORTCI PASSPORTRow7" + } +} diff --git a/src/flightforms/preview.py b/src/flightforms/preview.py index a7733a9..6a2e14d 100644 --- a/src/flightforms/preview.py +++ b/src/flightforms/preview.py @@ -181,6 +181,7 @@ def _find_mapping_by_id(registry: MappingRegistry, form_id: str) -> FormMapping "lsgs": "LSGS", "french_customs": "LFAC", "lfqa": "LFQA", + "lfrm": "LFRM", "gar": "EGKA", "gendec_form": "DEST", "gendec_icao": "DEST", @@ -191,7 +192,7 @@ def _find_mapping_by_id(registry: MappingRegistry, form_id: str) -> FormMapping } # Forms where arrival vs departure changes what appears on the form -DIRECTION_AWARE_FORMS = {"lsgs", "french_customs", "lfqa", "gar", "jersey_gendec"} +DIRECTION_AWARE_FORMS = {"lsgs", "french_customs", "lfqa", "lfrm", "gar", "jersey_gendec"} def generate_preview( diff --git a/src/flightforms/templates/lfrm_customs.pdf b/src/flightforms/templates/lfrm_customs.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1ee88bf4ee90561def3d076c751321eadaa7db05 GIT binary patch literal 56866 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 literal 0 HcmV?d00001 diff --git a/tests/snapshots/lfrm.json b/tests/snapshots/lfrm.json new file mode 100644 index 0000000..4893017 --- /dev/null +++ b/tests/snapshots/lfrm.json @@ -0,0 +1,32 @@ +{ + "ARRIVAL_AIRCRAFT_TYPE": "AcType", + "ARRIVAL_AIRPORT": "OriginCity", + "ARRIVAL_COUNTRY": "OriginCountry", + "ARRIVAL_DATE": "15/01/2099", + "ARRIVAL_FLIGHT_TYPE": "private", + "ARRIVAL_LOCAL_TIME": "11:45", + "ARRIVAL_ORIGIN": "ORIG", + "ARRIVAL_OWNER": "AcOwner", + "ARRIVAL_REGISTRATION": "AcReg", + "DEMANDE ARRIVEE ARRIVAL REQUEST": "X", + "DEMANDE DEPART DEPARTURE REQUEST": "", + "FRET CARGO": "", + "N PASSEPORTCI PASSPORTRow1": "CrewId1", + "N PASSEPORTCI PASSPORTRow1_2": "PaxId1", + "N PASSEPORTCI PASSPORTRow2": "CrewId2", + "N PASSEPORTCI PASSPORTRow2_2": "PaxId2", + "NATIONALITE NATIONALITYRow1": "CrewNat1", + "NATIONALITE NATIONALITYRow1_2": "PaxNat1", + "NATIONALITE NATIONALITYRow2": "CrewNat2", + "NATIONALITE NATIONALITYRow2_2": "PaxNat2", + "NOM NAMERow1": "CrewLast1", + "NOM NAMERow1_2": "PaxLast1", + "NOM NAMERow2": "CrewLast2", + "NOM NAMERow2_2": "PaxLast2", + "OBSERVATIONS": "PreviewObs", + "PASSAGERS PASSENGERS": "X", + "PRENOM FIRST NAMERow1": "CrewFirst1", + "PRENOM FIRST NAMERow1_2": "PaxFirst1", + "PRENOM FIRST NAMERow2": "CrewFirst2", + "PRENOM FIRST NAMERow2_2": "PaxFirst2" +} diff --git a/tests/snapshots/lfrm_departure.json b/tests/snapshots/lfrm_departure.json new file mode 100644 index 0000000..19ea2e5 --- /dev/null +++ b/tests/snapshots/lfrm_departure.json @@ -0,0 +1,32 @@ +{ + "DEMANDE ARRIVEE ARRIVAL REQUEST": "", + "DEMANDE DEPART DEPARTURE REQUEST": "X", + "DEPARTURE_AIRCRAFT_TYPE": "AcType", + "DEPARTURE_AIRPORT": "DestCity", + "DEPARTURE_COUNTRY": "DestCountry", + "DEPARTURE_DATE": "15/01/2099", + "DEPARTURE_FLIGHT_TYPE": "private", + "DEPARTURE_LOCAL_TIME": "09:30", + "DEPARTURE_ORIGIN": "DEST", + "DEPARTURE_OWNER": "AcOwner", + "DEPARTURE_REGISTRATION": "AcReg", + "FRET CARGO": "", + "N PASSEPORTCI PASSPORTRow1": "CrewId1", + "N PASSEPORTCI PASSPORTRow1_2": "PaxId1", + "N PASSEPORTCI PASSPORTRow2": "CrewId2", + "N PASSEPORTCI PASSPORTRow2_2": "PaxId2", + "NATIONALITE NATIONALITYRow1": "CrewNat1", + "NATIONALITE NATIONALITYRow1_2": "PaxNat1", + "NATIONALITE NATIONALITYRow2": "CrewNat2", + "NATIONALITE NATIONALITYRow2_2": "PaxNat2", + "NOM NAMERow1": "CrewLast1", + "NOM NAMERow1_2": "PaxLast1", + "NOM NAMERow2": "CrewLast2", + "NOM NAMERow2_2": "PaxLast2", + "OBSERVATIONS": "PreviewObs", + "PASSAGERS PASSENGERS": "X", + "PRENOM FIRST NAMERow1": "CrewFirst1", + "PRENOM FIRST NAMERow1_2": "PaxFirst1", + "PRENOM FIRST NAMERow2": "CrewFirst2", + "PRENOM FIRST NAMERow2_2": "PaxFirst2" +} From 55bf44926c559f57bdcb6fc707d27eb46d8a4441 Mon Sep 17 00:00:00 2001 From: brice Date: Wed, 9 Sep 2026 20:37:58 +0100 Subject: [PATCH 3/3] fix(time): convert the date along with the time for local-time forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_015cLZEJUmiCnbxfytMKN18e --- src/flightforms/fillers/_datetime.py | 24 +++++ .../fillers/french_customs_filler.py | 29 +++--- src/flightforms/fillers/pdf_filler.py | 64 +++++++------ tests/unit/test_fillers.py | 89 +++++++++++++++++++ 4 files changed, 157 insertions(+), 49 deletions(-) create mode 100644 src/flightforms/fillers/_datetime.py diff --git a/src/flightforms/fillers/_datetime.py b/src/flightforms/fillers/_datetime.py new file mode 100644 index 0000000..e219f5e --- /dev/null +++ b/src/flightforms/fillers/_datetime.py @@ -0,0 +1,24 @@ +"""Date/time conversion shared by the form fillers. + +Requests always carry UTC. A form that prints the airport's wall clock +("Heure Locale") declares ``time_reference: "local"`` and a ``time_zone`` +in its mapping. +""" + +from datetime import datetime +from zoneinfo import ZoneInfo + + +def utc_to_local(date_str: str, time_str: str, tz_name: str) -> tuple[str, str]: + """Convert a YYYY-MM-DD / HH:MM pair from UTC to *tz_name*. + + Date and time must convert together: a UTC evening can land on the next + day locally (23:50Z on 1 June is 01:50 on 2 June in Paris), so printing a + converted time against the original UTC date would misdate the flight by + a day on a customs pre-notification. + + The date comes back as YYYY-MM-DD, for the caller to format. + """ + dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M") + local = dt.replace(tzinfo=ZoneInfo("UTC")).astimezone(ZoneInfo(tz_name)) + return local.strftime("%Y-%m-%d"), local.strftime("%H:%M") diff --git a/src/flightforms/fillers/french_customs_filler.py b/src/flightforms/fillers/french_customs_filler.py index e7b4510..922fb20 100644 --- a/src/flightforms/fillers/french_customs_filler.py +++ b/src/flightforms/fillers/french_customs_filler.py @@ -8,12 +8,12 @@ from datetime import datetime from io import BytesIO from pathlib import Path -from zoneinfo import ZoneInfo from pypdf import PdfReader, PdfWriter from ..api.models import GenerateRequest from ..registry import FormMapping +from ._datetime import utc_to_local def _parse_date(date_str: str, fmt: str) -> str: @@ -21,14 +21,6 @@ def _parse_date(date_str: str, fmt: str) -> str: return dt.strftime(fmt) -def _utc_to_local(time_str: str, date_str: str, tz_name: str) -> str: - """Convert HH:MM UTC to local time in the given timezone.""" - dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M") - dt_utc = dt.replace(tzinfo=ZoneInfo("UTC")) - dt_local = dt_utc.astimezone(ZoneInfo(tz_name)) - return dt_local.strftime("%H:%M") - - def _suffix(index: int) -> str: """Row 0 = no suffix, row 1 = '_2', row 2 = '_3', etc.""" if index == 0: @@ -51,15 +43,20 @@ def fill_french_customs( observations = request.observations or mapping.default_observations or "" - # Use local time conversion only when time_reference is "local" + # Use local time conversion only when time_reference is "local". The date + # converts with the time: a late-evening UTC slot falls on the next day + # locally, and the form would otherwise print that local time against the + # UTC date. if mapping.time_reference == "local" and mapping.time_zone: - dep_time = _utc_to_local( - request.flight.departure_time_utc, request.flight.departure_date, mapping.time_zone + dep_date, dep_time = utc_to_local( + request.flight.departure_date, request.flight.departure_time_utc, mapping.time_zone ) - arr_time = _utc_to_local( - request.flight.arrival_time_utc, request.flight.arrival_date, mapping.time_zone + arr_date, arr_time = utc_to_local( + request.flight.arrival_date, request.flight.arrival_time_utc, mapping.time_zone ) else: + dep_date = request.flight.departure_date + arr_date = request.flight.arrival_date dep_time = request.flight.departure_time_utc arr_time = request.flight.arrival_time_utc @@ -78,8 +75,8 @@ def fill_french_customs( "destination": request.flight.destination, "registration": request.aircraft.registration, "aircraft_type": request.aircraft.type, - "departure_date": _parse_date(request.flight.departure_date, mapping.date_format), - "arrival_date": _parse_date(request.flight.arrival_date, mapping.date_format), + "departure_date": _parse_date(dep_date, mapping.date_format), + "arrival_date": _parse_date(arr_date, mapping.date_format), "departure_time": dep_time, "arrival_time": arr_time, "contact": contact, diff --git a/src/flightforms/fillers/pdf_filler.py b/src/flightforms/fillers/pdf_filler.py index 527322f..3acf991 100644 --- a/src/flightforms/fillers/pdf_filler.py +++ b/src/flightforms/fillers/pdf_filler.py @@ -6,7 +6,6 @@ from datetime import datetime from io import BytesIO from pathlib import Path -from zoneinfo import ZoneInfo from pypdf import PdfReader, PdfWriter from pypdf.generic import ( @@ -19,6 +18,7 @@ from ..api.models import GenerateRequest from ..registry import FormMapping +from ._datetime import utc_to_local # Characters that don't decompose via NFKD but have obvious Latin base letters. @@ -57,13 +57,6 @@ def _parse_date(date_str: str, fmt: str) -> str: return dt.strftime(fmt) -def _utc_to_local(time_str: str, date_str: str, tz_name: str) -> str: - """Convert HH:MM UTC to local time in the given timezone.""" - dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M") - dt_utc = dt.replace(tzinfo=ZoneInfo("UTC")) - return dt_utc.astimezone(ZoneInfo(tz_name)).strftime("%H:%M") - - def _resolve_field_pattern(pattern: str, index: int) -> str: """Resolve {i} (0-based) and {n} (1-based) in field name patterns.""" return pattern.replace("{i}", str(index)).replace("{n}", str(index + 1)) @@ -103,25 +96,26 @@ def fill_pdf( # Build values dict for simple fields observations = request.observations or mapping.default_observations or "" - # Request times are always UTC. Forms that ask for the airport's wall - # clock ("Heure Locale") declare time_reference "local" plus a time_zone; - # every time value below goes through _time() so the whole form stays in - # one reference. - def _time(time_str: str, date_str: str) -> str: + # Request dates and times are always UTC. Forms that ask for the + # airport's wall clock ("Heure Locale") declare time_reference "local" + # plus a time_zone; every date/time pair below goes through _when() so the + # whole form stays in one reference — and so a date never gets printed + # against a time from the other side of midnight. + def _when(date_str: str, time_str: str) -> tuple[str, str]: if mapping.time_reference == "local" and mapping.time_zone: - return _utc_to_local(time_str, date_str, mapping.time_zone) - return time_str + return utc_to_local(date_str, time_str, mapping.time_zone) + return date_str, time_str - dep_time = _time(request.flight.departure_time_utc, request.flight.departure_date) - arr_time = _time(request.flight.arrival_time_utc, request.flight.arrival_date) + dep_date, dep_time = _when(request.flight.departure_date, request.flight.departure_time_utc) + arr_date, arr_time = _when(request.flight.arrival_date, request.flight.arrival_time_utc) # Direction-aware date/time: resolves to arrival or departure based on direction - local_date = request.flight.arrival_date if is_arrival else request.flight.departure_date + local_date = arr_date if is_arrival else dep_date local_time = arr_time if is_arrival else dep_time values = { - "flight.departure_date": _parse_date(request.flight.departure_date, mapping.date_format), - "flight.arrival_date": _parse_date(request.flight.arrival_date, mapping.date_format), + "flight.departure_date": _parse_date(dep_date, mapping.date_format), + "flight.arrival_date": _parse_date(arr_date, mapping.date_format), "flight.departure_time_utc": dep_time, "flight.arrival_time_utc": arr_time, "flight.date": _parse_date(local_date, mapping.date_format), @@ -151,7 +145,7 @@ def _time(time_str: str, date_str: str) -> str: # page with differently-named fields (e.g. gendec + passenger manifest). "manifest.operator": request.aircraft.owner or "", "manifest.registration": request.aircraft.registration, - "manifest.date": _parse_date(request.flight.departure_date, mapping.date_format), + "manifest.date": _parse_date(dep_date, mapping.date_format), "airport.name": airport_resolver.get_name(request.airport), "airport.icao": request.airport, # Direction-dependent text marks (e.g. "X" on the right side) @@ -164,7 +158,7 @@ def _time(time_str: str, date_str: str) -> str: # arrival/departure sections fill only the relevant side. if is_arrival: values.update({ - "arrival.date": _parse_date(request.flight.arrival_date, mapping.date_format), + "arrival.date": _parse_date(arr_date, mapping.date_format), "arrival.time": arr_time, "arrival.registration": request.aircraft.registration, "arrival.type": request.aircraft.type, @@ -177,7 +171,7 @@ def _time(time_str: str, date_str: str) -> str: }) else: values.update({ - "departure.date": _parse_date(request.flight.departure_date, mapping.date_format), + "departure.date": _parse_date(dep_date, mapping.date_format), "departure.time": dep_time, "departure.registration": request.aircraft.registration, "departure.type": request.aircraft.type, @@ -206,10 +200,12 @@ def _time(time_str: str, date_str: str) -> str: cf = request.connecting_flight values["connecting.origin"] = cf.origin values["connecting.destination"] = cf.destination - values["connecting.departure_date"] = _parse_date(cf.departure_date, mapping.date_format) - values["connecting.departure_time_utc"] = _time(cf.departure_time_utc, cf.departure_date) - values["connecting.arrival_date"] = _parse_date(cf.arrival_date, mapping.date_format) - values["connecting.arrival_time_utc"] = _time(cf.arrival_time_utc, cf.arrival_date) + cf_dep_date, cf_dep_time = _when(cf.departure_date, cf.departure_time_utc) + cf_arr_date, cf_arr_time = _when(cf.arrival_date, cf.arrival_time_utc) + values["connecting.departure_date"] = _parse_date(cf_dep_date, mapping.date_format) + values["connecting.departure_time_utc"] = cf_dep_time + values["connecting.arrival_date"] = _parse_date(cf_arr_date, mapping.date_format) + values["connecting.arrival_time_utc"] = cf_arr_time # Airport-centric leg values: for forms that show both an arrival and a # departure section at the target airport (e.g. Jersey GenDec). The @@ -218,25 +214,27 @@ def _time(time_str: str, date_str: str) -> str: if is_arrival: values["airport.arrival.from"] = request.flight.origin values["airport.arrival.from_name"] = airport_resolver.get_name(request.flight.origin) - values["airport.arrival.date"] = _parse_date(request.flight.arrival_date, mapping.date_format) + values["airport.arrival.date"] = _parse_date(arr_date, mapping.date_format) values["airport.arrival.time"] = arr_time if request.connecting_flight: cf = request.connecting_flight values["airport.departure.to"] = cf.destination values["airport.departure.to_name"] = airport_resolver.get_name(cf.destination) - values["airport.departure.date"] = _parse_date(cf.departure_date, mapping.date_format) - values["airport.departure.time"] = _time(cf.departure_time_utc, cf.departure_date) + cf_date, cf_time = _when(cf.departure_date, cf.departure_time_utc) + values["airport.departure.date"] = _parse_date(cf_date, mapping.date_format) + values["airport.departure.time"] = cf_time else: values["airport.departure.to"] = request.flight.destination values["airport.departure.to_name"] = airport_resolver.get_name(request.flight.destination) - values["airport.departure.date"] = _parse_date(request.flight.departure_date, mapping.date_format) + values["airport.departure.date"] = _parse_date(dep_date, mapping.date_format) values["airport.departure.time"] = dep_time if request.connecting_flight: cf = request.connecting_flight values["airport.arrival.from"] = cf.origin values["airport.arrival.from_name"] = airport_resolver.get_name(cf.origin) - values["airport.arrival.date"] = _parse_date(cf.arrival_date, mapping.date_format) - values["airport.arrival.time"] = _time(cf.arrival_time_utc, cf.arrival_date) + cf_date, cf_time = _when(cf.arrival_date, cf.arrival_time_utc) + values["airport.arrival.date"] = _parse_date(cf_date, mapping.date_format) + values["airport.arrival.time"] = cf_time # Fill fields updates = {} diff --git a/tests/unit/test_fillers.py b/tests/unit/test_fillers.py index b2f26f9..dff8b96 100644 --- a/tests/unit/test_fillers.py +++ b/tests/unit/test_fillers.py @@ -436,3 +436,92 @@ def test_no_extra_fields(self, registry, resolver): registry.get_template_path(mapping), mapping, request, resolver ) assert len(result) > 0 + + +class TestLocalTimeConversion: + """Forms printing local time must convert the date along with the time. + + A UTC evening slot falls on the next day in Europe/Paris, and a customs + pre-notification carrying a local time against the UTC date misdates the + flight by a day. + """ + + @pytest.fixture + def registry(self): + return MappingRegistry(str(MAPPINGS_DIR), str(TEMPLATES_DIR)) + + @pytest.fixture + def resolver(self): + return StubAirportResolver() + + def _late_flight(self): + """Arrives 23:50Z on 1 June — 01:50 on 2 June in Paris (CEST).""" + flight = make_flight(origin="ZZZZ", destination="LFRM") + flight.departure_date = "2099-06-01" + flight.departure_time_utc = "22:00" + flight.arrival_date = "2099-06-01" + flight.arrival_time_utc = "23:50" + return flight + + def _fill_lfrm(self, registry, resolver, flight): + mapping = registry.get_form("LFRM", "lfrm") + request = GenerateRequest( + airport="LFRM", + form="lfrm", + flight=flight, + aircraft=make_aircraft(), + crew=[make_pilot()], + ) + pdf_bytes = fill_pdf( + registry.get_template_path(mapping), mapping, request, resolver + ) + return PdfReader(BytesIO(pdf_bytes)).get_fields() or {} + + def test_date_rolls_with_time_across_midnight(self, registry, resolver): + fields = self._fill_lfrm(registry, resolver, self._late_flight()) + assert fields["ARRIVAL_LOCAL_TIME"].get("/V") == "01:50" + assert fields["ARRIVAL_DATE"].get("/V") == "02/06/2099" + + def test_date_unchanged_when_conversion_stays_in_day(self, registry, resolver): + # 10:45Z on 15 June is 12:45 the same day in Paris + fields = self._fill_lfrm( + registry, resolver, make_flight(origin="ZZZZ", destination="LFRM") + ) + assert fields["ARRIVAL_LOCAL_TIME"].get("/V") == "12:45" + assert fields["ARRIVAL_DATE"].get("/V") == "15/06/2099" + + def test_french_customs_date_rolls_with_time(self, registry, resolver): + mapping = registry.get_form("LFRM", "french_customs") + request = GenerateRequest( + airport="LFRM", + form="french_customs", + flight=self._late_flight(), + aircraft=make_aircraft(), + crew=[make_pilot()], + ) + pdf_bytes = fill_french_customs( + registry.get_template_path(mapping), mapping, request, resolver + ) + fields = PdfReader(BytesIO(pdf_bytes)).get_fields() or {} + assert fields["Zone de texte 12"].get("/V") == "01:50" + assert fields["Zone de texte 10"].get("/V") == "2 June 2099" + + def test_utc_form_leaves_date_and_time_alone(self, registry, resolver): + """A mapping without time_reference "local" must not shift anything.""" + mapping = registry.get_form("LFQA", "lfqa") + assert mapping.time_reference == "utc" + flight = self._late_flight() + flight.destination = "LFQA" + request = GenerateRequest( + airport="LFQA", + form="lfqa", + flight=flight, + aircraft=make_aircraft(), + crew=[make_pilot()], + ) + pdf_bytes = fill_pdf( + registry.get_template_path(mapping), mapping, request, resolver + ) + fields = PdfReader(BytesIO(pdf_bytes)).get_fields() or {} + assert fields["ARRIVALFLIGHTTIME"].get("/V") == "23:50" + assert fields["ARRIVALFLIGHTDATE"].get("/V") == "01/06/2099"