Une lettre chacun, dans un seul fichier

Quatre courriers personnalisés derrière un sommaire. Aucun courrier n'indique de numéro de page : chaque destinataire prend les feuillets qui suivent, autant qu'il lui en faut, et chaque ligne du sommaire mène au destinataire.

Python write_one_letter_each.py 420 lignes
  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
"""Writes a run of personal letters into one file, and lets its first sheet jump to any
of them.

The Python twin of the `write_one_letter_each` example in Rust. A mailing house
receives one PDF and has to know where each letter starts, how many sheets it runs to,
and which envelope it goes in. The file says it itself: each recipient is a document
part that claims the next so many sheets, counted rather than numbered by hand. The
index on the first sheet then leads to the part rather than to a page, so a letter that
gains a sheet moves nothing.

Both sets of words are held in `Words`, once per language, and `HQF_PDF_LANG` picks
which set is drawn.

Usage: python examples/write_one_letter_each.py [out.pdf] [font.ttf]
       HQF_PDF_LANG=fr python examples/write_one_letter_each.py
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import _language
import _licence
import _out

import hqf_pdf


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

    # What the file as a whole is called.
    title: str
    # What the file carries, said once at the top of the first sheet.
    lead: str
    # What the hierarchy says, said in grey at the foot of the first sheet.
    note: str
    # The heading above the list of recipients.
    index_heading: str
    # Who each of the four letters is for.
    recipients: tuple[str, str, str, str]
    # Where each of them is sent.
    towns: tuple[str, str, str, str]
    # The country every recipient is in.
    country: str
    # The word a reference is introduced by.
    reference: str
    # The word a subject is introduced by.
    subject: str
    # What each of the four letters is about.
    subjects: tuple[str, str, str, str]
    # How a letter opens.
    greeting: str
    # What every letter says.
    body: str
    # How a letter closes.
    closing: str
    # Who signs it.
    signer: str
    # What the signer does.
    signer_role: str
    # The heading of the sheet that follows the first letter.
    enclosure_title: str
    # What that sheet says.
    enclosure: str
    # The word a letter is called by.
    letter: str
    # The word a sheet is counted by.
    sheet: str
    # The word one sheet is counted by, in the middle of a line.
    sheet_one: str
    # The word several sheets are counted by, in the middle of a line.
    sheet_many: str


# The pages in English.
ENGLISH = Words(
    title="One letter each, in one file",
    lead=(
        "The five sheets that follow are four letters, and this index leads to them. "
        "No letter names a page number, and none had to: each recipient claims the "
        "next so many sheets, and the index leads to the recipient rather than to a "
        "sheet. Give the second letter another page and nothing above has to be "
        "counted again."
    ),
    note=(
        "Each entry of the index is a go-to document part action: it names the part "
        "that claims the sheet, not the sheet itself, so a reader following it lands "
        "on that recipient's first page wherever that page has moved to. A mailing "
        "house reads the same parts to know where to cut and what to fold."
    ),
    index_heading="Who this run is for",
    recipients=(
        "Baker and Sons",
        "Halden Joinery",
        "Westmill Dairy",
        "Corran Bookbinders",
    ),
    towns=("Bristol", "Kendal", "Truro", "Oban"),
    country="United Kingdom",
    reference="Our reference:",
    subject="Subject:",
    subjects=(
        "Your delivery window from September",
        "The crates returned in July",
        "Collection moved to Tuesdays",
        "The paper stock held for you",
    ),
    greeting="Dear Sir or Madam,",
    body=(
        "We are writing to every account served from this depot, because the round "
        "that reaches you changes at the end of the month. Nothing you have ordered is "
        "affected, and the prices agreed in March stand until they are next reviewed. "
        "Should the new arrangement not suit you, reply to this letter quoting the "
        "reference above and we will keep the old one for you until the end of the "
        "year."
    ),
    closing="Yours faithfully,",
    signer="R. Alderton",
    signer_role="Depot manager",
    enclosure_title="What changes, and when",
    enclosure=(
        "The round leaves the depot an hour earlier and calls in the reverse order, so "
        "an address served last is now served first. Deliveries falling on a public "
        "holiday move to the working day after it rather than the one before. An order "
        "placed after four in the afternoon travels on the round after next, which is "
        "one day later than it used to."
    ),
    letter="Letter",
    sheet="Sheet",
    sheet_one="sheet",
    sheet_many="sheets",
)

# The pages in French.
FRENCH = Words(
    title="Une lettre chacun, dans un seul fichier",
    lead=(
        "Les cinq feuillets qui suivent sont quatre lettres, et le présent sommaire y "
        "mène. Aucune lettre ne nomme un numéro de page, et aucune n'a eu à le faire : "
        "chaque destinataire prend les feuillets suivants, autant qu'il lui en faut, "
        "et le sommaire mène au destinataire plutôt qu'à un feuillet. Donnez une page "
        "de plus à la deuxième lettre : rien de ce qui précède n'est à recompter."
    ),
    note=(
        "Chaque ligne du sommaire est une action « aller à une partie » : elle nomme "
        "la partie qui revendique le feuillet, et non le feuillet lui-même, de sorte "
        "qu'un lecteur qui la suit tombe sur la première page de ce destinataire, où "
        "qu'elle soit passée. Un routeur lit les mêmes parties pour savoir où couper "
        "et quoi plier."
    ),
    index_heading="À qui ce lot s'adresse",
    recipients=(
        "Boulangerie Petit",
        "Menuiserie Vallon",
        "Laiterie du Coteau",
        "Reliure de Corran",
    ),
    towns=("Aix-en-Provence", "Annecy", "Quimper", "Sète"),
    country="France",
    reference="Nos références :",
    subject="Objet :",
    subjects=(
        "Votre créneau de livraison à partir de septembre",
        "Les caisses rendues en juillet",
        "Ramassage reporté au mardi",
        "Le papier tenu à votre disposition",
    ),
    greeting="Madame, Monsieur,",
    body=(
        "Nous écrivons à tous les comptes desservis par ce dépôt, car la tournée qui "
        "vous dessert change à la fin du mois. Rien de ce que vous avez commandé n'est "
        "touché, et les prix convenus en mars tiennent jusqu'à leur prochaine "
        "révision. Si la nouvelle organisation ne vous convient pas, répondez à ce "
        "courrier en citant la référence ci-dessus et nous vous garderons l'ancienne "
        "jusqu'à la fin de l'année."
    ),
    closing="Veuillez agréer nos salutations distinguées.",
    signer="R. Alderton",
    signer_role="Responsable du dépôt",
    enclosure_title="Ce qui change, et quand",
    enclosure=(
        "La tournée quitte le dépôt une heure plus tôt et passe dans l'ordre inverse : "
        "une adresse desservie en dernier l'est désormais en premier. Une livraison "
        "qui tombe un jour férié est reportée au jour ouvré suivant plutôt qu'au "
        "précédent. Une commande passée après seize heures part à la tournée d'après, "
        "soit un jour plus tard qu'auparavant."
    ),
    letter="Lettre",
    sheet="Feuillet",
    sheet_one="feuillet",
    sheet_many="feuillets",
)

# 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}

# The left edge of everything on the pages.
LEFT = 72.0

# How wide a block of text is.
WIDTH = 451.0

# The reference each letter is written under.
REFERENCES = ("CT-4711", "CT-4712", "CT-4713", "CT-4714")

# How many sheets each letter runs to. The first carries an enclosure; the others are a
# single sheet.
SHEETS = (2, 1, 1, 1)


def first_sheets() -> list[int]:
    """The first sheet of each letter, counting the index as sheet zero.

    This is the same running count the parts themselves are given, said once so that
    the index and the hierarchy cannot drift apart."""
    first = []
    cursor = 1
    for run in SHEETS:
        first.append(cursor)
        cursor += run
    return first


def block(
    content: hqf_pdf.Content,
    handle: hqf_pdf.FontHandle,
    size: float,
    top: float,
    text: str,
) -> float:
    """Sets a block of words at `top`, and hands back the ordinate it ends at."""
    flow = hqf_pdf.TextFlow(handle, size)
    lines = flow.break_lines(text, WIDTH)
    # The binding opens the text object itself, so opening another here would write a
    # pair of operators its twin in Rust does not.
    flow.draw(content, lines, LEFT, top, WIDTH)
    return top - flow.height(lines)


def index_line(
    content: hqf_pdf.Content,
    handle: hqf_pdf.FontHandle,
    top: float,
    text: str,
) -> tuple[float, hqf_pdf.Rect]:
    """Sets one line of the index, and hands back the ordinate it ends at together with
    the rectangle it covers."""
    flow = hqf_pdf.TextFlow(handle, 10.5)
    lines = flow.break_lines(text, WIDTH)
    boxes = flow.line_boxes(lines, LEFT, top, WIDTH)
    flow.draw(content, lines, LEFT, top, WIDTH)
    return top - flow.height(lines), boxes[0]


def entry(words: Words, index: int) -> str:
    """How a letter is announced in the index."""
    sheets = words.sheet_many if SHEETS[index] > 1 else words.sheet_one
    return (
        f"{words.recipients[index]}{words.towns[index]}"
        f" — {REFERENCES[index]}{SHEETS[index]} {sheets}"
    )


def foot(
    content: hqf_pdf.Content,
    words: Words,
    text: hqf_pdf.FontHandle,
    index: int,
    sheet: int,
) -> None:
    """Draws the foot of a sheet: which letter it belongs to, and where it stands in
    it."""
    flow = hqf_pdf.TextFlow(text, 8.0)
    line = (
        f"{words.letter} {index + 1}/{len(SHEETS)}{words.recipients[index]}"
        f" — {words.sheet} {sheet + 1}/{SHEETS[index]}"
    )
    content.set_fill(hqf_pdf.Rgb.gray(0.45))
    flow.draw(content, flow.break_lines(line, WIDTH), LEFT, 56.0, WIDTH)
    content.set_fill(hqf_pdf.Rgb.gray(0.0))


def index_sheet(
    words: Words, text: hqf_pdf.FontHandle
) -> tuple[hqf_pdf.Content, list[hqf_pdf.Link]]:
    """Draws the index sheet, and hands back the links its entries carry."""
    content = hqf_pdf.Content()
    top = block(content, text, 17.0, 782.0, words.title) - 12.0
    top = block(content, text, 9.5, top, words.lead) - 26.0
    top = block(content, text, 13.0, top, words.index_heading) - 14.0

    links = []
    for index, first in enumerate(first_sheets()):
        below, covered = index_line(content, text, top, entry(words, index))
        # The link names the part that claims that sheet, not the sheet: the recipient
        # is what a reader is after, and the part is what says where the recipient
        # begins.
        links.append(covered.link_to_part(first))
        top = below - 7.0

    content.set_fill(hqf_pdf.Rgb.gray(0.35))
    block(content, text, 8.5, top - 20.0, words.note)
    content.set_fill(hqf_pdf.Rgb.gray(0.0))
    return content, links


def letter_sheet(
    words: Words, text: hqf_pdf.FontHandle, index: int
) -> hqf_pdf.Content:
    """Draws the sheet one letter is written on."""
    content = hqf_pdf.Content()
    top = block(content, text, 13.0, 782.0, words.recipients[index]) - 6.0
    whereabouts = f"{words.towns[index]}, {words.country}"
    top = block(content, text, 9.0, top, whereabouts) - 22.0

    heading = f"{words.reference} {REFERENCES[index]}"
    top = block(content, text, 9.0, top, heading) - 6.0
    about = f"{words.subject} {words.subjects[index]}"
    top = block(content, text, 10.0, top, about) - 20.0

    top = block(content, text, 9.5, top, words.greeting) - 12.0
    top = block(content, text, 9.5, top, words.body) - 22.0
    top = block(content, text, 9.5, top, words.closing) - 20.0
    top = block(content, text, 9.5, top, words.signer) - 4.0
    block(content, text, 8.5, top, words.signer_role)

    foot(content, words, text, index, 0)
    return content


def enclosure_sheet(words: Words, text: hqf_pdf.FontHandle) -> hqf_pdf.Content:
    """Draws the sheet enclosed with the first letter."""
    content = hqf_pdf.Content()
    top = block(content, text, 13.0, 782.0, words.enclosure_title) - 14.0
    block(content, text, 9.5, top, words.enclosure)
    foot(content, words, text, 0, 1)
    return content


def stated(words: Words, index: int) -> hqf_pdf.PartData:
    """What one letter states about itself, for whatever sends it."""
    return hqf_pdf.PartData(
        [
            ("Recipient", words.recipients[index]),
            ("Town", words.towns[index]),
            ("Reference", REFERENCES[index]),
            ("Sheets", SHEETS[index]),
        ]
    )


def hierarchy(words: Words) -> hqf_pdf.DocumentParts:
    """The hierarchy: the index, then one part per recipient, each taking the sheets
    that follow rather than naming them."""
    parts = [
        hqf_pdf.DocumentPart(
            next_pages=1,
            data=hqf_pdf.PartData([("Section", "Index")]),
        )
    ]
    parts.extend(
        hqf_pdf.DocumentPart(next_pages=SHEETS[index], data=stated(words, index))
        for index in range(len(SHEETS))
    )
    return hqf_pdf.DocumentParts(hqf_pdf.DocumentPart(children=parts))


def build(words: Words, font: Path) -> bytes:
    """Draws the index and the four letters, and says which sheets belong to whom."""
    doc = hqf_pdf.Document()
    doc.set_license(_licence.licensed())
    # The hierarchy of document parts, and the action that leads to one, are written on
    # PDF 2.0 and on nothing earlier.
    doc.set_version(hqf_pdf.Version.V2_0)
    text = doc.add_font(hqf_pdf.Font.from_path(font))

    drawn, links = index_sheet(words, text)
    index = hqf_pdf.Page.a4()
    index.set_content(drawn)
    for link in links:
        index.add_link(link)
    doc.add_page(index)

    for letter in range(len(SHEETS)):
        sheet = hqf_pdf.Page.a4()
        sheet.set_content(letter_sheet(words, text, letter))
        doc.add_page(sheet)
        if SHEETS[letter] > 1:
            enclosed = hqf_pdf.Page.a4()
            enclosed.set_content(enclosure_sheet(words, text))
            doc.add_page(enclosed)

    doc.set_document_parts(hierarchy(words))
    return doc.to_bytes()


def main() -> None:
    """Writes the index and the four letters."""
    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("one_letter_each.pdf", language)).stem
    )

    data = build(words, _out.font_path())

    Path(out).parent.mkdir(parents=True, exist_ok=True)
    Path(out).write_bytes(data)
    print(f"wrote {out}: {len(data)} bytes")


if __name__ == "__main__":
    main()