write_contents.py

The Python file of the “A contents page with dot leaders” example. A contents page whose entries are led to their page numbers by dots the layout draws.

Python 279 lines

What this example is for

Everybody has met the contents page where the dots run right into the page number on one line and stop two centimetres short on the next. It happens because the dots were typed in, and the day a title changes length the count is wrong. Nobody sees it while the document is being written; it shows up on the printed copy, at the customer's.

What this example shows

  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
"""A contents page: every entry led to its page number by a row of dots.

The Python twin of the `write_contents` example in Rust.

The dots are not written into the text. Each entry is a cell that asks for a leader,
and the layout fills whatever room the entry leaves — which is why the dots still meet
the numbers when an entry is edited, translated or set in another font.

What the entries say is written in the language `HQF_PDF_LANG` names. What they are
filed under is not: `1`, `2.3` and the page numbers are the shape of the report, and a
translation that renumbered them would send the reader elsewhere.

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

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import _language
import _licence
import _out

import hqf_pdf

# A4's width, and the margins the contents are laid out between.
PAGE_WIDTH = 595.276
MARGIN = 64.0
TABLE_WIDTH = PAGE_WIDTH - 2.0 * MARGIN

# The company the report is about, which no language renames.
COMPANY = "ACME Ltd"

# How many spaces stand between what an entry is filed under and what it says.
GAP = "  "


@dataclass(frozen=True)
class Entry:
    """One line of the contents: how deep it sits, what it is filed under, and the
    page it sends the reader to.

    What the entry says is not here: it is a word, and it lives with the other words of
    its language.
    """

    depth: int
    # The number the entry is filed under, or None for the appendix, which is headed by
    # a word rather than a number.
    number: str | None
    page: str


# The shape of the report this page opens: what each entry is filed under, and where it
# sends the reader.
ENTRIES = (
    Entry(0, "1", "3"),
    Entry(1, "1.1", "3"),
    Entry(1, "1.2", "5"),
    Entry(0, "2", "8"),
    Entry(1, "2.1", "8"),
    Entry(1, "2.2", "11"),
    Entry(1, "2.3", "14"),
    Entry(0, "3", "17"),
    Entry(1, "3.1", "17"),
    Entry(1, "3.2", "20"),
    Entry(1, "3.3", "23"),
    Entry(0, "4", "27"),
    Entry(1, "4.1", "27"),
    Entry(1, "4.2", "31"),
    Entry(0, None, "34"),
)

# How far a sub-entry is set in from the margin, in points.
INDENT = 18.0


@dataclass(frozen=True)
class Words:
    """Every word the contents are written in, in one language.

    What each entry is filed under is not among them: the numbers and the page they
    send the reader to are the shape of the report, and they are held with the entries
    themselves.
    """

    # What the page is called, both drawn at its head and in what the file says of
    # itself.
    title: str
    # What stands before the name of the company in the line under the title.
    subtitle_before: str
    # What stands after it.
    subtitle_after: str
    # What heads the entry that carries no number.
    appendix: str
    # What each entry says, in the order ENTRIES files them.
    entries: tuple[str, ...]


# The contents in English.
ENGLISH = Words(
    title="Contents",
    subtitle_before="Audit of document production — ",
    subtitle_after=", second quarter",
    appendix="Appendix",
    entries=(
        "Scope of the audit",
        "What was examined",
        "What was left out, and why",
        "How the documents are produced today",
        "The invoicing run",
        "The nightly statement of accounts",
        "Documents produced on demand from the counter",
        "What the archival standard asks for",
        "Fonts that travel with the file",
        "Colour that means the same on every screen",
        "Text a reading machine can follow",
        "What it would cost to change",
        "The work, month by month",
        "What is saved once it is done",
        "Files read for this report",
    ),
)

# The contents in French.
FRENCH = Words(
    title="Sommaire",
    subtitle_before="Audit de la production documentaire — ",
    subtitle_after=", deuxième trimestre",
    appendix="Annexe",
    entries=(
        "Périmètre de l'audit",
        "Ce qui a été examiné",
        "Ce qui a été laissé de côté, et pourquoi",
        "Comment les documents sont produits aujourd'hui",
        "La passe de facturation",
        "Le relevé de comptes de la nuit",
        "Documents produits à la demande au guichet",
        "Ce que la norme d'archivage exige",
        "Les polices qui voyagent avec le fichier",
        "Une couleur qui dit la même chose sur tous les écrans",
        "Un texte qu'une machine de lecture peut suivre",
        "Ce que le changement coûterait",
        "Le travail, mois par mois",
        "Ce qu'on économise une fois que c'est fait",
        "Fichiers lus pour ce rapport",
    ),
)

# 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 width of the column the page numbers stand in, in points.
NUMBER_COLUMN = 28.0

# How much of the row an entry leaves free on its right, in points.
RIGHT_PADDING = 4.0

# The size an entry is set at, top level first.
SIZES = (10.5, 9.5)

# Where the head of the table sits, and how much room it is given below.
TABLE_TOP = 730.0
TABLE_ROOM = 660.0

# The sizes the two lines above the table are set at, in points.
TITLE_SIZE = 20.0
SUBTITLE_SIZE = 8.5


def label(words: Words, entry: Entry, index: int) -> str:
    """What one entry reads on the page: what it is filed under, then what it says."""
    filed = words.appendix if entry.number is None else entry.number
    return f"{filed}{GAP}{words.entries[index]}"


def subtitle(words: Words) -> str:
    """The line under the title, which names the company the report is about."""
    return f"{words.subtitle_before}{COMPANY}{words.subtitle_after}"


def contents(font: hqf_pdf.FontHandle, words: Words) -> hqf_pdf.Table:
    """The contents, as one table: the entry, led by dots to its page number."""
    columns = hqf_pdf.Columns(
        [
            hqf_pdf.ColumnWidth.fraction(1.0),
            hqf_pdf.ColumnWidth.points(NUMBER_COLUMN),
        ],
        TABLE_WIDTH,
    )

    table = hqf_pdf.Table(columns)
    table.rule(hqf_pdf.Rule.horizontal(0), hqf_pdf.Stroke(0.5, hqf_pdf.Rgb.gray(0.7)))

    for index, entry in enumerate(ENTRIES):
        top = entry.depth == 0
        size = SIZES[0] if top else SIZES[1]
        color = hqf_pdf.Rgb(0.0, 0.0, 0.0) if top else hqf_pdf.Rgb.gray(0.25)
        padding = hqf_pdf.Padding(
            left=INDENT * entry.depth,
            right=RIGHT_PADDING,
            top=7.0 if top else 2.0,
            bottom=2.0,
        )

        table.push(
            hqf_pdf.Row(
                [
                    # The dots are drawn by the layout, in the room the entry leaves.
                    hqf_pdf.Cell(
                        font,
                        size,
                        label(words, entry, index),
                        color=color,
                        padding=padding,
                        leader=".",
                    ),
                    hqf_pdf.Cell(
                        font,
                        size,
                        entry.page,
                        color=color,
                        align=hqf_pdf.Align.Right,
                        padding=padding,
                    ),
                ]
            )
        )

    return table


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("contents.pdf", language)).stem)

    document = hqf_pdf.Document()
    document.set_license(_licence.licensed())
    font = document.add_font(hqf_pdf.Font.from_path(_out.font_path()))

    content = hqf_pdf.Content()

    title = hqf_pdf.TextFlow(font, TITLE_SIZE)
    title.draw(
        content,
        title.break_lines(words.title, TABLE_WIDTH),
        MARGIN,
        790.0,
        TABLE_WIDTH,
    )

    under_title = hqf_pdf.TextFlow(font, SUBTITLE_SIZE, color=hqf_pdf.Rgb.gray(0.4))
    under_title.draw(
        content,
        under_title.break_lines(subtitle(words), TABLE_WIDTH),
        MARGIN,
        762.0,
        TABLE_WIDTH,
    )

    contents(font, words).fit(MARGIN, TABLE_TOP, TABLE_ROOM).draw(content)

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

    written = document.write(out)
    print(f"wrote {out}: {written} bytes")


if __name__ == "__main__":
    main()