write_picture_cells.py

Die Python-Datei des Beispiels „Bilder in Tabellenzellen“. Ein Katalog, dessen erste Spalte ein Bild statt Text ist: vier Bilder sehr unterschiedlicher Größe und Proportion, jedes vollständig und unverzerrt in die Zelle eingepasst, die die Tabelle ihm gibt, und an eine andere Ecke davon gerückt, ohne Breite von seiner Spalte oder Höhe von seiner Zeile zu beanspruchen.

Python 334 Zeilen

Wofür dieses Beispiel da ist

Ein Produktkatalog bedeutet Fotos, und Fotos kommen von Lieferanten: eines ist neunhundert Pixel breit, das nächste sechzehn, und keine zwei haben dieselben Proportionen. Sie alle auf eine gemeinsame Größe zu bringen, bevor die Seite gesetzt werden kann, ist ein Schritt, den jemand ausführen, im nächsten Quartal wieder ausführen und an dem Tag erklären muss, an dem er ausgelassen wurde.

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
"""Writes a catalogue whose first column is a picture rather than text.

The Python twin of the `write_picture_cells` example in Rust. The four pictures
are wildly different sizes — one is nine hundred pixels across, another sixteen
— and no two share the same proportions, yet the column they sit in is the width
the table gave it and every row is as tall as its own text. A picture asks its
column for no width and its row for no height: it is fitted, whole and
undistorted, into whatever rectangle it ends up with, and where it sits in that
rectangle is the alignment the cell already had.

The catalogue is held in `Words`, once per language, and `HQF_PDF_LANG` picks which
set is printed. What a picture is called is also what a reader hears in its place: the
alternative text of a picture cell reads the same field as the line beside it, so the
two cannot disagree.

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

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import _language
import _licence
import _out

import hqf_pdf

# The margins, and the box the table is fitted into.
MARGIN = 56.0
TOP = 760.0
BOTTOM = 64.0
PAGE_WIDTH = 595.276
TABLE_WIDTH = PAGE_WIDTH - 2 * MARGIN

# The colour the heading is painted on.
HEAD_FILL = hqf_pdf.Rgb.gray(0.86)

# The height every row of the catalogue takes, whatever its text needs.
ROW_HEIGHT = 54.0

# The pictures the catalogue shows, committed with the tests.
PICTURES = Path(__file__).resolve().parents[2] / "hqf-pdf" / "tests" / "images"


@dataclass(frozen=True)
class Item:
    """One line of the catalogue, save what it is called and what is said about it.

    A picture, a corner and a price are the same in every language; the line reads its
    words from `Words` instead.
    """

    picture: str
    across: hqf_pdf.Align
    down: hqf_pdf.VAlign
    reference: str
    price: str


# The catalogue's lines, in the order they are printed. The pictures are shown against
# each edge of their cells in turn, so that the room a picture does not fill can be seen
# going to one side or the other.
ITEMS = [
    Item(
        "hqf_development.png",
        hqf_pdf.Align.Left,
        hqf_pdf.VAlign.Top,
        "BN-900",
        "148.00",
    ),
    Item(
        "photo.jpg",
        hqf_pdf.Align.Center,
        hqf_pdf.VAlign.Middle,
        "PH-160",
        "24.50",
    ),
    Item(
        "logo.png",
        hqf_pdf.Align.Right,
        hqf_pdf.VAlign.Bottom,
        "SQ-96",
        "9.90",
    ),
    Item(
        "bilevel.png",
        hqf_pdf.Align.Center,
        hqf_pdf.VAlign.Middle,
        "RL-16",
        "3.75",
    ),
]


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

    # The heading of each column, in the order the columns are set.
    headings: tuple[str, str, str, str]
    # What each item is called, in the order the lines are printed. A picture cell
    # carries the same words as the words a reader hears in its place.
    names: tuple[str, str, str, str]
    # What is said about each item.
    notes: tuple[str, str, str, str]


# The catalogue in English.
ENGLISH = Words(
    headings=("Picture", "Item", "Reference", "Price, €"),
    names=("Wide banner", "Photograph", "Square mark", "Two-colour rule"),
    notes=(
        "Nine hundred pixels across and half as tall, drawn against the top "
        "left of its cell.",
        "Four to three, in the middle of its cell — the room it does not fill "
        "is shared either side.",
        "As tall as it is wide, against the bottom right. The height runs out "
        "first, so nothing is left below it.",
        "Sixteen pixels by eight, drawn as large as the cell allows: a picture "
        "is fitted to its cell, not to its own pixels.",
    ),
)

# The catalogue in French.
FRENCH = Words(
    headings=("Image", "Article", "Référence", "Prix, €"),
    names=("Bandeau large", "Photographie", "Marque carrée", "Filet bicolore"),
    notes=(
        "Neuf cents pixels de large et deux fois moins haut, calé en haut "
        "à gauche de sa cellule.",
        ("Au format quatre tiers, au milieu de sa cellule : la place qu’elle laisse se"
         " répartit des deux côtés."),
        ("Aussi haute que large, calée en bas à droite. C’est la hauteur qui limite en"
         " premier : rien ne reste en dessous."),
        ("Seize pixels sur huit, dessiné aussi grand que la cellule le permet : une "
         "image s’ajuste à sa cellule, pas à ses propres pixels."),
    ),
)

# The catalogue in Dutch.
DUTCH = Words(
    headings=("Afbeelding", "Artikel", "Referentie", "Prijs, €"),
    names=("Brede banner", "Foto", "Vierkant merk", "Tweekleurige lijn"),
    notes=(
        "Negenhonderd pixels breed en half zo hoog, tegen de linkerbovenhoek van de "
        "cel getekend.",
        "Vier bij drie, midden in de cel: de ruimte die ze niet vult, wordt aan beide "
        "kanten verdeeld.",
        "Even hoog als breed, tegen de rechteronderhoek. De hoogte raakt eerst op, "
        "dus blijft er eronder niets over.",
        "Zestien pixels bij acht, zo groot getekend als de cel toelaat: een "
        "afbeelding wordt aan haar cel aangepast, niet aan haar pixels.",
    ),
)

# The catalogue in Portuguese.
PORTUGUESE = Words(
    headings=("Imagem", "Artigo", "Referência", "Preço, €"),
    names=("Faixa larga", "Fotografia", "Marca quadrada", "Filete bicolor"),
    notes=(
        "Novecentos píxeis de largura e metade de altura, encostada ao canto superior "
        "esquerdo da célula.",
        "Quatro por três, no meio da célula: o espaço que não preenche reparte-se "
        "pelos dois lados.",
        "Tão alta como larga, encostada ao canto inferior direito. A altura esgota-se "
        "primeiro, por isso nada fica por baixo.",
        "Dezasseis píxeis por oito, desenhada tão grande quanto a célula permite: uma "
        "imagem ajusta-se à célula, não aos seus píxeis.",
    ),
)

# The catalogue in Italian.
ITALIAN = Words(
    headings=("Immagine", "Articolo", "Riferimento", "Prezzo, €"),
    names=("Banner largo", "Fotografia", "Marchio quadrato", "Filetto bicolore"),
    notes=(
        "Novecento pixel di larghezza e metà di altezza, contro l’angolo in alto a "
        "sinistra della sua cella.",
        "Quattro per tre, al centro della sua cella: lo spazio che non riempie è "
        "diviso ai due lati.",
        "Alta quanto larga, contro l’angolo in basso a destra. L’altezza finisce "
        "prima, così sotto non resta nulla.",
        "Sedici pixel per otto, disegnata grande quanto la cella consente: "
        "un’immagine si adatta alla cella, non ai suoi pixel.",
    ),
)

# The catalogue in Spanish.
SPANISH = Words(
    headings=("Imagen", "Artículo", "Referencia", "Precio, €"),
    names=("Banda ancha", "Fotografía", "Marca cuadrada", "Filete bicolor"),
    notes=(
        "Novecientos píxeles de ancho y la mitad de alto, contra la esquina superior "
        "izquierda de su celda.",
        "Cuatro por tres, en el centro de su celda: el sitio que no llena se reparte "
        "a cada lado.",
        "Tan alta como ancha, contra la esquina inferior derecha. La altura se agota "
        "antes, así que no queda nada debajo.",
        "Dieciséis píxeles por ocho, dibujada tan grande como deja la celda: una "
        "imagen se ajusta a su celda, no a sus píxeles.",
    ),
)

# The catalogue in German.
GERMAN = Words(
    headings=("Bild", "Artikel", "Referenz", "Preis (€)"),
    names=("Breites Banner", "Foto", "Quadratisches Zeichen", "Zweifarbige Linie"),
    notes=(
        "Neunhundert Pixel breit und halb so hoch, oben links in seiner Zelle "
        "gezeichnet.",
        "Vier zu drei, in der Mitte seiner Zelle — der Raum, den es nicht füllt, "
        "verteilt sich auf beide Seiten.",
        "So hoch wie breit, unten rechts. Die Höhe reicht zuerst nicht mehr, also "
        "bleibt darunter nichts übrig.",
        "Sechzehn mal acht Pixel, so groß gezeichnet, wie die Zelle erlaubt: Ein Bild "
        "wird in seine Zelle eingepasst, nicht nach seinen Pixeln.",
    ),
)


# 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 catalogue(
    document: hqf_pdf.Document, font: hqf_pdf.FontHandle, words: Words
) -> hqf_pdf.Table:
    """The catalogue: a heading, then one row per item, the first cell of each a
    picture."""
    # The picture column is a fixed width, the note takes what is left, and the
    # reference and the price are each as wide as their own widest cell.
    columns = hqf_pdf.Columns(
        [
            hqf_pdf.ColumnWidth.points(96.0),
            hqf_pdf.ColumnWidth.fraction(1.0),
            hqf_pdf.ColumnWidth.content(),
            hqf_pdf.ColumnWidth.content(),
        ],
        TABLE_WIDTH,
    )

    table = hqf_pdf.Table(columns)
    table.header(1)
    table.rule(hqf_pdf.Rule.frame(), hqf_pdf.Stroke(0.8))
    hairline = hqf_pdf.Stroke(0.25, color=hqf_pdf.Rgb.gray(0.75))
    table.rule(hqf_pdf.Rule.horizontal_other(), hairline)
    table.rule(hqf_pdf.Rule.vertical_other(), hairline)
    table.rule(hqf_pdf.Rule.horizontal(1), hqf_pdf.Stroke(0.8))

    pad = hqf_pdf.Padding.symmetric(6.0, 5.0)

    table.push(
        hqf_pdf.Row(
            cells=[
                hqf_pdf.Cell(font, 9.0, words.headings[0], padding=pad, fill=HEAD_FILL),
                hqf_pdf.Cell(font, 9.0, words.headings[1], padding=pad, fill=HEAD_FILL),
                hqf_pdf.Cell(font, 9.0, words.headings[2], padding=pad, fill=HEAD_FILL),
                hqf_pdf.Cell(
                    font,
                    9.0,
                    words.headings[3],
                    align=hqf_pdf.Align.Right,
                    padding=pad,
                    fill=HEAD_FILL,
                ),
            ],
            min_height=20.0,
        )
    )

    for item, name, note in zip(ITEMS, words.names, words.notes):
        image = document.add_image(hqf_pdf.Image.from_path(PICTURES / item.picture))
        table.push(
            hqf_pdf.Row(
                cells=[
                    hqf_pdf.Cell.image(
                        image,
                        alt=name,
                        align=item.across,
                        valign=item.down,
                        padding=pad,
                    ),
                    hqf_pdf.Cell(font, 9.0, f"{name}\n{note}", padding=pad),
                    hqf_pdf.Cell(font, 9.0, item.reference, padding=pad),
                    hqf_pdf.Cell(
                        font,
                        9.0,
                        item.price,
                        align=hqf_pdf.Align.Right,
                        align_on=".",
                        padding=pad,
                    ),
                ],
                min_height=ROW_HEIGHT,
            )
        )

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

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

    table = catalogue(document, font, words)
    placed = table.fit(MARGIN, TOP, TOP - BOTTOM, 0)

    content = hqf_pdf.Content()
    placed.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, {table.row_count} rows")


if __name__ == "__main__":
    main()