write_portfolio.py

Die Python-Datei des Beispiels „Ein Ausschreibungspaket mit drei Losen“. Drei Lose, jedes als separate Datei an das Dokument angehängt, beschrieben durch die Spalten, die das Dokument selbst definiert — Los, Versandtag, Betrag —, und neueste zuerst sortiert. Das Deckblatt wiederholt dieselben Spalten offen sichtbar, für Anzeigesoftware, die angehängte Dateien nicht zeigt.

Python 941 Zeilen

Wofür dieses Beispiel da ist

Drei Firmen beantworten eine Ausschreibung, und was ankommt, sind drei Zahlenwerke, die getrennt bleiben und zusammen reisen müssen. Jedes Los kommt als eigene Datei in dieses Dokument, vollständig, in der Form, in der es gesendet wurde. Was daraus statt eines Sacks voller Anhänge eine Mappe macht, die jemand durcharbeiten kann, ist der Rest: Das Dokument nennt die Spalten, unter denen die Dateien gezeigt werden — das Los, der Tag des Versands, der Betrag —, sagt, was jede Datei in jede davon einträgt, und sagt, dass die Liste nach dem Tag sortiert ist, neueste zuerst.

Was dieses Beispiel zeigt

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
"""Writes a tender pack: a cover sheet, three files attached to it, and the table a
reading software shows them in.

The Python twin of the `write_portfolio` example in Rust. A document that carries
files is a portfolio when it says how to show them: which columns to lay out, what
each file states for each column, and which way the list is sorted. Without that a
reading software falls back on a list of file names, which tells a buyer nothing
about which lot came in when and for how much.

Each lot is attached as a small XML file, and each states its own lot, the day it was
sent and its total. The columns are declared once on the document, the sort is on the
day, newest first, and the cover sheet lists the files as well, so that a reading
software with no portfolio of its own still shows what is inside.

The dates the columns carry are written the way PDF writes a moment, which is what
`hqf_pdf.pdf_date` turns an ISO 8601 moment into. A file's own `modified` is stated in
ISO 8601 and converted by the library.

Two more files belong to the pack without travelling in it: a site plan kept beside the
pack, named by its path, and a survey served from an address. The columns add four the
reader fills in from the files themselves — the name, what the file is, when it last
changed, its size, the last of them hidden — and the sheet ends on what the pack states:
how it is shown, the file it opens on, the column it sorts on and which way, each column
and what it holds, and for every file what it is, its media type, what it is to the pack
and where its bytes are.

The file opens with the attachments panel shown.

The terminal counts the files named by a path beside the pack and those named by an
address, each read off what the file is made of.

Usage: python examples/write_portfolio.py [out.pdf]
       HQF_PDF_LANG=fr python examples/write_portfolio.py
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import _language
import _licence
import _out

import hqf_pdf

# The margin the page is laid out inside.
MARGIN = 56.0

# How far the page runs across, between the margins.
WIDTH = 483.0

# The name each lot is attached under.
FILES = ("lot-a.xml", "lot-b.xml", "lot-c.xml")

# When each lot was sent, in ISO 8601.
SENT = (
    "2026-07-14T09:30:00+02:00",
    "2026-08-03T16:05:00+02:00",
    "2026-06-28T11:20:00+02:00",
)

# What each lot comes to, in euros.
TOTALS = (18400.0, 9250.0, 31700.0)

# The keys the columns are read under. They are not words anybody is shown: a file
# states its columns under these, and the sort names one of them.
KEYS = ("lot", "sent", "total")

# The number of the folder each lot is placed in: the two groundworks lots in the first
# folder under the root, the surfacing in the second.
FOLDER_OF = (2, 2, 3)

# The number of the root folder.
ROOT = 1

# Where each line of the list sits.
ROWS = (648.0, 626.0, 604.0)

# Where the heading of the list sits.
HEADING = 676.0

# Where each column of the list starts, from the margin.
COLUMNS = (0.0, 96.0, 300.0, 392.0)

# The path the site plan is kept at, beside the pack.
PLAN = "plans/site-plan.pdf"

# The address the ground survey is served from.
SURVEY = "https://hqf-pdf.com/examples/tender/ground-survey.csv"

# The keys of the four columns the reader fills in from the files.
READ_KEYS = ("file", "what", "changed", "size")

# Where the heading over what the pack states sits.
STATED_TOP = 500.0


@dataclass(frozen=True)
class Words:
    """Every word the page draws, in one language."""

    # The line at the head of the page.
    title: str
    # The paragraph under it.
    intro: str
    # The heading each column is shown under.
    columns: tuple[str, str, str]
    # What each lot is called.
    lots: tuple[str, str, str]
    # What each lot comes to, written the way the language writes money.
    totals: tuple[str, str, str]
    # The heading over the column of file names.
    file_heading: str
    # The line under the list.
    caption: str
    # What the file kept beside the pack is, and what the file served from an address
    # is.
    elsewhere: tuple[str, str]
    # The four columns the reader fills in from the files: name, what it is, when it
    # changed, its size.
    read_columns: tuple[str, str, str, str]
    # The heading over what the pack states.
    stated: str
    # How the pack is shown; `{view}`, `{file}`, `{key}` and `{order}` stand for them.
    shown: str
    # The three ways a pack is shown: rows, tiles, hidden.
    views: tuple[str, str, str]
    # Which way a sort runs: upward, then downward.
    orders: tuple[str, str]
    # The columns; `{columns}` stands for them.
    columns_line: str
    # The folders the files are grouped in: the root, then the two it holds.
    folders: tuple[str, str, str]
    # The folders and the files in each; `{folders}` stands for them.
    folders_line: str
    # What stands between two items of the columns line and of the folders line.
    separator: str
    # What a column holds: words, a moment, a number, the file name, what the file is,
    # when it changed, its size.
    kinds: tuple[str, ...]
    # What a hidden column is marked with.
    hidden: str
    # One file; `{file}`, `{description}`, `{media}`, `{relationship}` and `{payload}`
    # stand for them.
    file_line: str
    # What a file named without a media type is said to have.
    no_media: str
    # What a file is to the pack: its source, an alternative, its data, a supplement,
    # nothing said.
    relationships: tuple[str, str, str, str, str]
    # Where a carried file is; `{bytes}` and `{modified}` stand for them.
    carried: str
    # Where a file kept beside the pack is; `{path}` stands for it.
    beside: str
    # Where a served file is; `{address}` stands for it.
    served: str


# The page in English.
ENGLISH = Words(
    title="Tender pack: three lots",
    intro=(
        "Every lot of this tender travels in the pack as a file of its own. The "
        "document states the columns the files are shown in, what each file holds for "
        "each of them, and that the list is sorted on the day it was sent, newest "
        "first."
    ),
    columns=(
        "Lot",
        "Sent",
        "Total",
    ),
    lots=(
        "Earthworks and site clearance",
        "Drainage and services",
        "Surfacing and lining",
    ),
    totals=(
        "EUR 18,400.00",
        "EUR 9,250.00",
        "EUR 31,700.00",
    ),
    file_heading="File",
    caption=(
        "A reading software that shows a portfolio lays the three files out in these "
        "columns. One that does not still opens the pack and lists the files, which is "
        "why they are named on this sheet too."
    ),
    elsewhere=(
        "The site plan, kept beside the pack",
        "The ground survey, served from our site",
    ),
    read_columns=(
        "File",
        "What it is",
        "Changed",
        "Size",
    ),
    stated="What the pack states",
    shown="Shown as {view}, opening on {file}, sorted on the column {key}, {order}.",
    views=(
        "rows",
        "tiles",
        "nothing until asked",
    ),
    orders=(
        "oldest first",
        "newest first",
    ),
    columns_line="Columns: {columns}.",
    folders=("Tender pack", "Groundworks", "Surfacing"),
    folders_line="Folders: {folders}.",
    separator="; ",
    kinds=(
        "words",
        "a moment",
        "a number",
        "the file name",
        "what the file is",
        "when it changed",
        "its size",
    ),
    hidden="hidden",
    file_line="{file}: {description}; {media}; {relationship}; {payload}.",
    no_media="no media type",
    relationships=(
        "its source",
        "an alternative to it",
        "its data",
        "a supplement to it",
        "nothing said",
    ),
    carried="carried, {bytes} bytes, changed {modified}",
    beside="kept beside the pack at {path}",
    served="served from {address}",
)

# The page in French.
FRENCH = Words(
    title="Dossier d’appel d’offres : trois lots",
    intro=(
        "Chaque lot de cet appel d’offres voyage dans le dossier comme un fichier "
        "distinct. Le document indique les colonnes sous lesquelles les fichiers sont "
        "présentés, ce que chacun contient pour chacune d’elles, et que la liste est "
        "triée par date d’envoi, du plus récent au plus ancien."
    ),
    columns=(
        "Lot",
        "Envoi",
        "Montant",
    ),
    lots=(
        "Terrassement et dégagement du terrain",
        "Drainage et réseaux",
        "Revêtement et marquage",
    ),
    totals=(
        "18 400,00 EUR",
        "9 250,00 EUR",
        "31 700,00 EUR",
    ),
    file_heading="Fichier",
    caption=(
        "Un logiciel de lecture qui présente un portefeuille dispose les trois "
        "fichiers sous ces colonnes. Un logiciel qui ne le fait pas ouvre tout de même "
        "le dossier et en énumère les fichiers : c’est pourquoi ils sont aussi nommés "
        "sur cette feuille."
    ),
    elsewhere=(
        "Le plan du site, rangé à côté du dossier",
        "Le relevé du terrain, servi par notre site",
    ),
    read_columns=(
        "Fichier",
        "Description",
        "Modifié",
        "Taille",
    ),
    stated="Ce que le dossier indique",
    shown="Présenté {view}, ouvert sur {file}, trié selon la colonne {key}, {order}.",
    views=(
        "en lignes",
        "en vignettes",
        "seulement sur demande",
    ),
    orders=(
        "du plus ancien au plus récent",
        "du plus récent au plus ancien",
    ),
    columns_line="Colonnes : {columns}.",
    folders=("Appel d’offres", "Terrassement et réseaux", "Finitions"),
    folders_line="Sous-dossiers : {folders}.",
    separator=" ; ",
    kinds=(
        "des mots",
        "une date",
        "un nombre",
        "le nom du fichier",
        "ce qu’est le fichier",
        "sa date de modification",
        "sa taille",
    ),
    hidden="masquée",
    file_line="{file} : {description} ; {media} ; {relationship} ; {payload}.",
    no_media="aucun type de média",
    relationships=(
        "sa source",
        "une variante",
        "ses données",
        "un complément",
        "rien de précisé",
    ),
    carried="incorporé, {bytes} octets, modifié le {modified}",
    beside="rangé à côté du dossier : {path}",
    served="servi par {address}",
)

# The page in Dutch.
DUTCH = Words(
    title="Aanbestedingspakket: drie percelen",
    intro=(
        "Elk perceel van deze aanbesteding reist in het pakket mee als eigen bestand. "
        "Het document noemt de kolommen waarin de bestanden getoond worden, wat elk "
        "bestand voor elke kolom bevat, en dat de lijst gesorteerd is op "
        "verzenddatum, nieuwste eerst."
    ),
    columns=(
        "Perceel",
        "Verzonden",
        "Totaal",
    ),
    lots=(
        "Grondwerk en bouwrijp maken",
        "Riolering en nutsvoorzieningen",
        "Verharding en belijning",
    ),
    totals=(
        "EUR 18.400,00",
        "EUR 9.250,00",
        "EUR 31.700,00",
    ),
    file_heading="Bestand",
    caption=(
        "Leessoftware die een portfolio toont, zet de drie bestanden in deze "
        "kolommen. Leessoftware die dat niet doet, opent het pakket toch en somt de "
        "bestanden op, en daarom worden ze ook op dit blad genoemd."
    ),
    elsewhere=(
        "De bouwtekening, naast het pakket bewaard",
        "Het bodemonderzoek, geleverd vanaf onze site",
    ),
    read_columns=(
        "Bestand",
        "Wat het is",
        "Gewijzigd",
        "Grootte",
    ),
    stated="Wat het pakket vermeldt",
    shown="Getoond als {view}, geopend op {file}, gesorteerd op de kolom {key}, {order}.",
    views=(
        "rijen",
        "tegels",
        "niets tot erom gevraagd wordt",
    ),
    orders=(
        "oudste eerst",
        "nieuwste eerst",
    ),
    columns_line="Kolommen: {columns}.",
    folders=("Aanbestedingspakket", "Grond- en leidingwerk", "Afwerking"),
    folders_line="Mappen: {folders}.",
    separator="; ",
    kinds=(
        "woorden",
        "een moment",
        "een getal",
        "de bestandsnaam",
        "wat het bestand is",
        "wanneer het veranderde",
        "de grootte",
    ),
    hidden="verborgen",
    file_line="{file}: {description}; {media}; {relationship}; {payload}.",
    no_media="geen mediatype",
    relationships=(
        "de bron ervan",
        "een alternatief ervoor",
        "de gegevens ervan",
        "een aanvulling erop",
        "niets vermeld",
    ),
    carried="ingesloten, {bytes} bytes, gewijzigd op {modified}",
    beside="naast het pakket bewaard op {path}",
    served="geleverd vanaf {address}",
)

# The page in Portuguese.
PORTUGUESE = Words(
    title="Dossiê de concurso: três lotes",
    intro=(
        "Cada lote deste concurso viaja no dossiê como ficheiro próprio. O documento "
        "indica as colunas em que os ficheiros são mostrados, o que cada ficheiro "
        "contém para cada uma, e que a lista é ordenada pela data de envio, a mais "
        "recente primeiro."
    ),
    columns=(
        "Lote",
        "Enviado",
        "Total",
    ),
    lots=(
        "Movimentação de terras e limpeza do terreno",
        "Drenagem e redes",
        "Pavimentação e sinalização",
    ),
    totals=(
        "18 400,00 EUR",
        "9 250,00 EUR",
        "31 700,00 EUR",
    ),
    file_heading="Ficheiro",
    caption=(
        "Um programa de leitura que mostra um portefólio dispõe os três ficheiros "
        "nestas colunas. Um que não o faça abre na mesma o dossiê e lista os "
        "ficheiros, e é por isso que também são nomeados nesta folha."
    ),
    elsewhere=(
        "A planta da obra, guardada ao lado do dossiê",
        "O levantamento do terreno, servido pelo nosso site",
    ),
    read_columns=(
        "Ficheiro",
        "O que é",
        "Alterado",
        "Tamanho",
    ),
    stated="O que o dossiê indica",
    shown="Mostrado como {view}, aberto em {file}, ordenado pela coluna {key}, {order}.",
    views=(
        "linhas",
        "mosaicos",
        "nada até ser pedido",
    ),
    orders=(
        "o mais antigo primeiro",
        "o mais recente primeiro",
    ),
    columns_line="Colunas: {columns}.",
    folders=("Dossiê de concurso", "Movimentação de terras e redes", "Pavimentação"),
    folders_line="Pastas: {folders}.",
    separator="; ",
    kinds=(
        "palavras",
        "um momento",
        "um número",
        "o nome do ficheiro",
        "o que é o ficheiro",
        "quando mudou",
        "o seu tamanho",
    ),
    hidden="oculta",
    file_line="{file}: {description}; {media}; {relationship}; {payload}.",
    no_media="sem tipo de suporte",
    relationships=(
        "a sua origem",
        "uma alternativa a ele",
        "os seus dados",
        "um complemento a ele",
        "nada indicado",
    ),
    carried="incluído, {bytes} bytes, alterado em {modified}",
    beside="guardado ao lado do dossiê em {path}",
    served="servido a partir de {address}",
)

# The page in Italian.
ITALIAN = Words(
    title="Fascicolo di gara: tre lotti",
    intro=(
        "Ogni lotto di questa gara viaggia nel fascicolo come file a sé. Il documento "
        "indica le colonne in cui si mostrano i file, cosa contiene ciascun file per "
        "ciascuna, e che l’elenco è ordinato per data di invio, dal più recente."
    ),
    columns=(
        "Lotto",
        "Inviato",
        "Totale",
    ),
    lots=(
        "Movimento terra e sgombero del cantiere",
        "Drenaggio e reti",
        "Pavimentazione e segnaletica",
    ),
    totals=(
        "18.400,00 EUR",
        "9.250,00 EUR",
        "31.700,00 EUR",
    ),
    file_heading="File",
    caption=(
        "Un programma di lettura che mostra un portfolio dispone i tre file in queste "
        "colonne. Uno che non lo fa apre comunque il fascicolo ed elenca i file, ed è "
        "per questo che sono nominati anche su questo foglio."
    ),
    elsewhere=(
        "La planimetria del cantiere, tenuta accanto al fascicolo",
        "Il rilievo del terreno, servito dal nostro sito",
    ),
    read_columns=(
        "File",
        "Che cos’è",
        "Modificato",
        "Dimensione",
    ),
    stated="Cosa indica il fascicolo",
    shown="Mostrato come {view}, aperto su {file}, ordinato sulla colonna {key}, {order}.",
    views=(
        "righe",
        "riquadri",
        "nulla finché non si chiede",
    ),
    orders=(
        "dal più vecchio",
        "dal più recente",
    ),
    columns_line="Colonne: {columns}.",
    folders=("Fascicolo di gara", "Movimento terra e reti", "Opere di completamento"),
    folders_line="Cartelle: {folders}.",
    separator="; ",
    kinds=(
        "parole",
        "un momento",
        "un numero",
        "il nome del file",
        "cos’è il file",
        "quando è cambiato",
        "la sua dimensione",
    ),
    hidden="nascosta",
    file_line="{file}: {description}; {media}; {relationship}; {payload}.",
    no_media="nessun tipo di supporto",
    relationships=(
        "la sua fonte",
        "un’alternativa a esso",
        "i suoi dati",
        "un complemento a esso",
        "nulla di detto",
    ),
    carried="incluso, {bytes} byte, modificato il {modified}",
    beside="tenuto accanto al fascicolo in {path}",
    served="servito da {address}",
)

# The page in Spanish.
SPANISH = Words(
    title="Expediente de licitación: tres lotes",
    intro=(
        "Cada lote de esta licitación viaja en el expediente como archivo propio. El "
        "documento indica las columnas en que se muestran los archivos, lo que cada "
        "archivo contiene para cada una, y que la lista se ordena por fecha de envío, "
        "el más reciente primero."
    ),
    columns=(
        "Lote",
        "Enviado",
        "Total",
    ),
    lots=(
        "Movimiento de tierras y desbroce",
        "Drenaje y redes",
        "Firme y señalización",
    ),
    totals=(
        "18.400,00 EUR",
        "9.250,00 EUR",
        "31.700,00 EUR",
    ),
    file_heading="Archivo",
    caption=(
        "Un programa de lectura que muestra una cartera coloca los tres archivos en "
        "estas columnas. Uno que no lo hace abre igualmente el expediente y enumera "
        "los archivos, por eso también se nombran en esta hoja."
    ),
    elsewhere=(
        "El plano de la obra, guardado junto al expediente",
        "El estudio del terreno, servido desde nuestro sitio",
    ),
    read_columns=(
        "Archivo",
        "Qué es",
        "Modificado",
        "Tamaño",
    ),
    stated="Lo que indica el expediente",
    shown="Mostrado como {view}, abierto en {file}, ordenado por la columna {key}, {order}.",
    views=(
        "filas",
        "mosaico",
        "nada hasta que se pida",
    ),
    orders=(
        "el más antiguo primero",
        "el más reciente primero",
    ),
    columns_line="Columnas: {columns}.",
    folders=("Expediente de licitación", "Explanación y redes", "Firme y señalización"),
    folders_line="Carpetas: {folders}.",
    separator="; ",
    kinds=(
        "palabras",
        "un momento",
        "un número",
        "el nombre del archivo",
        "qué es el archivo",
        "cuándo cambió",
        "su tamaño",
    ),
    hidden="oculta",
    file_line="{file}: {description}; {media}; {relationship}; {payload}.",
    no_media="sin tipo de soporte",
    relationships=(
        "su fuente",
        "una alternativa a él",
        "sus datos",
        "un complemento de él",
        "nada indicado",
    ),
    carried="incluido, {bytes} bytes, modificado el {modified}",
    beside="guardado junto al expediente en {path}",
    served="servido desde {address}",
)

# The page in German.
GERMAN = Words(
    title="Ausschreibungsmappe: drei Lose",
    intro=(
        "Jedes Los dieser Ausschreibung reist als eigene Datei in der Mappe. Das "
        "Dokument nennt die Spalten, in denen die Dateien gezeigt werden, was jede "
        "Datei zu jeder davon enthält, und dass die Liste nach dem Versanddatum "
        "sortiert ist, das neueste zuerst."
    ),
    columns=(
        "Los",
        "Versandt",
        "Summe",
    ),
    lots=(
        "Erdarbeiten und Baufeldräumung",
        "Entwässerung und Leitungen",
        "Belag und Markierung",
    ),
    totals=(
        "18.400,00 EUR",
        "9.250,00 EUR",
        "31.700,00 EUR",
    ),
    file_heading="Datei",
    caption=(
        "Eine Lesesoftware, die ein Portfolio anzeigt, ordnet die drei Dateien in "
        "diesen Spalten an. Eine, die das nicht tut, öffnet die Mappe trotzdem und "
        "listet die Dateien auf, darum sind sie auch auf diesem Blatt genannt."
    ),
    elsewhere=(
        "Der Lageplan, neben der Mappe aufbewahrt",
        "Die Bodenuntersuchung, von unserer Website bereitgestellt",
    ),
    read_columns=(
        "Datei",
        "Was es ist",
        "Geändert",
        "Größe",
    ),
    stated="Was die Mappe angibt",
    shown="Gezeigt als {view}, geöffnet auf {file}, sortiert nach der Spalte {key}, {order}.",
    views=(
        "Zeilen",
        "Kacheln",
        "nichts, bis es verlangt wird",
    ),
    orders=(
        "älteste zuerst",
        "neueste zuerst",
    ),
    columns_line="Spalten: {columns}.",
    folders=("Ausschreibungsmappe", "Tiefbau", "Fahrbahn"),
    folders_line="Ordner: {folders}.",
    separator="; ",
    kinds=(
        "Wörter",
        "ein Zeitpunkt",
        "eine Zahl",
        "der Dateiname",
        "was die Datei ist",
        "wann sie geändert wurde",
        "ihre Größe",
    ),
    hidden="verborgen",
    file_line="{file}: {description}; {media}; {relationship}; {payload}.",
    no_media="kein Medientyp",
    relationships=(
        "ihre Quelle",
        "eine Alternative dazu",
        "ihre Daten",
        "eine Ergänzung dazu",
        "nichts angegeben",
    ),
    carried="mitgeführt, {bytes} Bytes, geändert {modified}",
    beside="neben der Mappe aufbewahrt unter {path}",
    served="bereitgestellt von {address}",
)

# Every language the example is written in. A language is added by writing its own set
# of words and naming it here.
WORDS = {_language.ENGLISH: ENGLISH, _language.FRENCH: FRENCH, _language.GERMAN: GERMAN, _language.SPANISH: SPANISH, _language.ITALIAN: ITALIAN, _language.PORTUGUESE: PORTUGUESE, _language.DUTCH: DUTCH}


def day_of(moment: str) -> str:
    """The day of an ISO 8601 moment, which is what the list shows."""
    return moment.split("T")[0]


def lot_file(name: str, sent: str, total: float) -> bytes:
    """The file one lot is sent as."""
    return (
        '<?xml version="1.0" encoding="UTF-8"?>\n'
        f"<lot>\n  <name>{name}</name>\n  <sent>{sent}</sent>\n  "
        f'<total currency="EUR">{total:.2f}</total>\n</lot>\n'
    ).encode()


def paragraph(
    content: hqf_pdf.Content,
    font: hqf_pdf.FontHandle,
    size: float,
    top: float,
    color: hqf_pdf.Rgb,
    text: str,
) -> None:
    """Writes a paragraph in a column the width of the page."""
    flow = hqf_pdf.TextFlow(font, size, leading=size * 1.4, color=color)
    lines = flow.break_lines(text, WIDTH)
    flow.draw(content, lines, MARGIN, top, WIDTH)


# The three ways a pack is shown, in the order `Words.views` names them.
VIEWS = (
    hqf_pdf.CollectionView.rows(),
    hqf_pdf.CollectionView.tiles(),
    hqf_pdf.CollectionView.hidden(),
)

# What a column may hold, in the order `Words.kinds` names it.
KINDS = (
    hqf_pdf.CollectionData.text(),
    hqf_pdf.CollectionData.date(),
    hqf_pdf.CollectionData.number(),
    hqf_pdf.CollectionData.file_name(),
    hqf_pdf.CollectionData.description(),
    hqf_pdf.CollectionData.modified(),
    hqf_pdf.CollectionData.size(),
)

# What a file may be to the pack, in the order `Words.relationships` names it.
RELATIONSHIPS = (
    hqf_pdf.Relationship.Source,
    hqf_pdf.Relationship.Alternative,
    hqf_pdf.Relationship.Data,
    hqf_pdf.Relationship.Supplement,
    hqf_pdf.Relationship.Unspecified,
)


def stated(collection, files, words: Words) -> list[str]:
    """What the pack states of `collection` and of `files`, one line each."""
    upward = collection.sorts_upward
    order = "" if upward is None else words.orders[0 if upward else 1]
    shown = (
        words.shown.replace("{view}", words.views[VIEWS.index(collection.view)])
        .replace("{file}", collection.opens_on or "")
        .replace("{key}", collection.sorted_on or "")
        .replace("{order}", order)
    )
    listed = []
    for field in collection.columns:
        named = f"{field.title} ({words.kinds[KINDS.index(field.data)]})"
        if not field.is_visible:
            named = f"{named}, {words.hidden}"
        listed.append(named)
    tree = collection.folder_tree
    grouped = []
    for folder in tree.children if tree is not None else []:
        held = [file.file_name for file in files if file.folder == folder.id]
        grouped.append(f"{folder.name} ({', '.join(held)})")
    lines = [
        shown,
        words.columns_line.replace("{columns}", words.separator.join(listed)),
        words.folders_line.replace("{folders}", words.separator.join(grouped)),
    ]
    for file in files:
        if file.data is not None:
            payload = words.carried.replace("{bytes}", str(len(file.data))).replace(
                "{modified}", day_of(file.modified)
            )
        elif file.path is not None:
            payload = words.beside.replace("{path}", file.path)
        else:
            payload = words.served.replace("{address}", file.address or "")
        media = file.media_type or words.no_media
        relationship = words.relationships[RELATIONSHIPS.index(file.relationship)]
        lines.append(
            words.file_line.replace("{file}", file.file_name)
            .replace("{description}", file.description)
            .replace("{media}", media)
            .replace("{relationship}", relationship)
            .replace("{payload}", payload)
        )
    return lines


def main() -> None:
    language = _language.from_environment()
    words = _language.words_of(WORDS, language)

    # A named file is written as named; the default one carries the language, so the two
    # languages do not overwrite each other in `tmp/`.
    out = _out.output_path(Path(_language.file_name("portfolio.pdf", language)).stem)

    document = hqf_pdf.Document()
    document.set_license(_licence.licensed())
    # A file named beside the document or at an address is read from PDF 2.0 on.
    document.set_version(hqf_pdf.Version.V2_0)
    font = document.add_font(hqf_pdf.Font.from_path(_out.DEFAULT_FONT))

    files = []
    for lot in range(len(FILES)):
        files.append(
            hqf_pdf.Attachment(
                FILES[lot],
                words.lots[lot],
                "application/xml",
                hqf_pdf.Relationship.Source,
                SENT[lot],
                lot_file(words.lots[lot], SENT[lot], TOTALS[lot]),
            )
            .stating(KEYS[0], hqf_pdf.CollectionValue.text(words.lots[lot]))
            .stating(KEYS[1], hqf_pdf.CollectionValue.date(hqf_pdf.pdf_date(SENT[lot])))
            .stating(KEYS[2], hqf_pdf.CollectionValue.number(TOTALS[lot]))
            .in_folder(FOLDER_OF[lot])
        )

    files.append(
        hqf_pdf.Attachment.beside(
            PLAN, words.elsewhere[0], hqf_pdf.Relationship.Supplement
        )
    )
    files.append(
        hqf_pdf.Attachment.at(SURVEY, words.elsewhere[1], hqf_pdf.Relationship.Data)
    )

    data = hqf_pdf.CollectionData
    columns = [
        hqf_pdf.CollectionField(KEYS[0], words.columns[0], data.text()),
        hqf_pdf.CollectionField(KEYS[1], words.columns[1], data.date()),
        hqf_pdf.CollectionField(KEYS[2], words.columns[2], data.number()),
    ]
    read = (data.file_name(), data.description(), data.modified(), data.size())
    for key, title, kind in zip(READ_KEYS, words.read_columns, read):
        field = hqf_pdf.CollectionField(key, title, kind)
        columns.append(field.hidden() if kind == data.size() else field)
    collection = (
        hqf_pdf.Collection(hqf_pdf.CollectionView.rows())
        .with_columns(columns)
        .sorted_by(KEYS[1], False)
        .opening_on(FILES[0])
        .with_folder_tree(
            hqf_pdf.CollectionFolder(ROOT, words.folders[0]).holding(
                [
                    hqf_pdf.CollectionFolder(FOLDER_OF[0], words.folders[1]),
                    hqf_pdf.CollectionFolder(FOLDER_OF[2], words.folders[2]),
                ]
            )
        )
    )
    said = stated(collection, files, words)
    beside = sum(1 for file in files if file.path is not None)
    served = sum(1 for file in files if file.address is not None)
    document.set_collection(collection)
    for file in files:
        document.attach(file)

    content = hqf_pdf.Content()
    content.draw_text(font, 16.0, MARGIN, 780.0, words.title)
    paragraph(content, font, 10.0, 750.0, hqf_pdf.Rgb.gray(0.0), words.intro)

    headings = (
        words.file_heading,
        words.columns[0],
        words.columns[1],
        words.columns[2],
    )
    for column, heading in zip(COLUMNS, headings):
        content.draw_text(font, 9.0, MARGIN + column, HEADING, heading)
    content.save_state()
    content.set_stroke(hqf_pdf.Rgb.gray(0.75))
    content.set_line_width(0.5)
    content.move_to(MARGIN, HEADING - 6.0)
    content.line_to(MARGIN + WIDTH, HEADING - 6.0)
    content.stroke()
    content.restore_state()

    for lot, at in enumerate(ROWS):
        cells = (FILES[lot], words.lots[lot], day_of(SENT[lot]), words.totals[lot])
        for column, cell in zip(COLUMNS, cells):
            content.draw_text(font, 10.0, MARGIN + column, at, cell)

    paragraph(content, font, 8.5, 566.0, hqf_pdf.Rgb.gray(0.35), words.caption)

    content.draw_text(font, 11.0, MARGIN, STATED_TOP, words.stated)
    top = STATED_TOP - 18.0
    for line in said:
        flow = hqf_pdf.TextFlow(font, 8.5, leading=11.0)
        lines = flow.break_lines(line, WIDTH)
        flow.draw(content, lines, MARGIN, top, WIDTH)
        top -= flow.height(lines) + 3.0

    page = hqf_pdf.Page.a4()
    page.set_content(content)
    document.add_page(page)

    document.set_open_mode(hqf_pdf.OpenMode.Attachments)

    written = document.write(out)
    print(
        f"wrote {out}: {written} bytes, {len(FILES)} lots, {beside} file kept beside the "
        f"pack and {served} served from an address"
    )


if __name__ == "__main__":
    main()