write_text_shapes.py

Le fichier Python de l’exemple « Un texte qui épouse une forme ». Une page, trois blocs de texte : une colonne qui contourne une note insérée dans son coin, un cercle rempli ligne à ligne, et une forme à six côtés dont les lignes se poursuivent au-delà d’un cadre d’image placé en son milieu.

Python 393 lignes

À quoi sert cet exemple

Une page de magazine range rarement son texte dans de simples rectangles : une citation s’insère dans le coin d’une colonne, une image interrompt un paragraphe, un macaron est rond. À la main, on mesure et on coupe les lignes une à une, et tout est à refaire le jour où le texte est traduit ou change de longueur.

Ce que montre cet exemple

  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
"""Sets text inside three shapes on one page: a column with a note set into its top
right-hand corner, the text going round the note; a circle; and a six-sided shape with a
picture box in its middle, each line meeting the box set in two parts, left of it then
right of it. The outline of every shape is drawn in grey, and a caption says what each
one shows.

The Python twin of the `write_text_shapes` example in Rust: the same page, through the
binding rather than through the library directly.

The words are held in `Words`, once per language, and `HQF_PDF_LANG` picks which set the
page is written in.

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

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import _language
import _licence
import _out

import hqf_pdf

# How far in from the left edge of the sheet everything starts, in points.
MARGIN = 56.0

# Where the baseline of the page's heading sits, in points up from the foot of the
# sheet.
HEAD_TOP = 790.0

# The size the heading is set at, in points.
HEADING = 16.0

# The size the lines under the heading and the captions are set at, in points.
BODY = 9.5

# The size the text inside the shapes is set at, in points.
TEXT = 10.5

# The size the note's text is set at, in points.
NOTE = 8.5

# How far below one line the next one sits, in points.
LINE_STEP = 13.0

# The column the first text is set in.
COLUMN = hqf_pdf.Rect(MARGIN, 420.0, 230.0, 320.0)

# The note set into the column's top right-hand corner.
NOTE_BOX = hqf_pdf.Rect(166.0, 620.0, 120.0, 120.0)

# The room held clear round the note and round the picture box, in points.
GAP = 8.0

# How far the note's text is kept from the note's edges, in points.
NOTE_PADDING = 6.0

# The square the circle is drawn in.
CIRCLE = hqf_pdf.Rect(309.0, 510.0, 230.0, 230.0)

# The corners of the six-sided shape's outline, in order.
HEXAGON = [
    (116.0, 375.0),
    (479.0, 375.0),
    (539.0, 255.0),
    (479.0, 135.0),
    (116.0, 135.0),
    (56.0, 255.0),
]

# The corners of the six-sided shape the text is set in, eight points inside the
# outline.
HEXAGON_TEXT = [
    (121.0, 367.0),
    (474.0, 367.0),
    (530.0, 255.0),
    (474.0, 143.0),
    (121.0, 143.0),
    (65.0, 255.0),
]

# How far inside the outline of the column and of the circle the text is set, in points.
INSET = 7.0

# The picture box in the middle of the six-sided shape.
PICTURE = hqf_pdf.Rect(237.0, 265.0, 121.0, 80.0)


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

    # The page's heading, and what the file says of itself.
    title: str
    # What the page says under its heading, one sentence over two lines.
    intro: tuple[str, str]
    # What the note set into the column says.
    note: str
    # The text set round the note.
    around: str
    # The text set inside the circle.
    circle: str
    # The text set inside the six-sided shape, round the picture box.
    polygon: str
    # What the picture box is labelled with.
    picture: str
    # The captions under the column, the circle and the six-sided shape.
    captions: tuple[str, str, str]


# The page in English.
ENGLISH = Words(
    title="Text that keeps to a shape",
    intro=(
        "Each block below is one text set line by line: a line stands only where the shape holds it",
        "whole, and where a rectangle sits in its way, the line goes round it or carries on beyond it.",
    ),
    note="A note set into the column. The text keeps eight points clear of it.",
    around="A column of text with a note set into its top right-hand corner. The lines beside the note are shorter: each one stops eight points before the note's edge, and the line under it takes the whole width again. Nothing here was measured by hand. The flow is told where the column is and where the note stands, and it works out, line by line, how much room each line has. The same text in another language runs to a different number of lines, and the note still sits where it was placed, with the text going round it.",
    circle="Inside a circle, every line is as wide as the circle is at the height of its letters, measured where the circle is narrowest along that line, so no letter crosses the edge. The lines grow longer towards the middle and shorter towards the bottom, and each one is set flush left inside its own width.",
    polygon="A six-sided shape with a picture box in its middle. Where a line meets the box, it is set in two parts: the text fills the stretch to the left of the box, then carries on in the stretch to its right, and only then goes down to the next line. A word never sits in a stretch too narrow for it. Above and below the box, the lines take the whole width the shape gives them, and along the slanted sides they grow shorter, so the block follows the outline from its top to its bottom. The shape is given as a list of corners, and any outline whose sides do not cross can be filled the same way.",
    picture="Picture",
    captions=(
        "Around a note",
        "Inside a circle",
        "Inside a polygon, around a picture box",
    ),
)


# The page in French.
FRENCH = Words(
    title="Un texte qui épouse une forme",
    intro=(
        "Chaque bloc ci-dessous est un seul texte composé ligne à ligne : une ligne ne se place que là où",
        "la forme la contient entière, et quand un rectangle la gêne, elle le contourne ou continue au-delà.",
    ),
    note="Une note insérée dans la colonne. Le texte s’en écarte de huit points.",
    around="Une colonne de texte, avec une note insérée dans son coin supérieur droit. Les lignes voisines de la note sont plus courtes : chacune s’arrête huit points avant le bord de la note, et la ligne qui passe dessous retrouve toute la largeur. Rien n’a été mesuré à la main. On indique au flux où se trouvent la colonne et la note, et il calcule, ligne après ligne, la place dont chacune dispose. Dans une autre langue, le même texte occupe un autre nombre de lignes ; la note reste là où on l’a posée, et le texte continue de la contourner.",
    circle="Dans un cercle, chaque ligne a la largeur du cercle à la hauteur de ses lettres, prise là où le cercle est le plus étroit sur cette ligne : aucune lettre ne dépasse le bord. Les lignes s’allongent vers le milieu, raccourcissent vers le bas, et chacune est alignée à gauche dans sa propre largeur.",
    polygon="Une forme à six côtés, avec un cadre d’image en son milieu. Quand une ligne rencontre le cadre, elle est composée en deux parties : le texte remplit la portion à gauche du cadre, continue dans celle de droite, et ne passe qu’ensuite à la ligne suivante. Un mot ne se place jamais dans une portion trop étroite pour lui. Au-dessus et au-dessous du cadre, les lignes prennent toute la largeur que la forme leur laisse, et le long des côtés obliques elles raccourcissent : le bloc suit le contour de haut en bas. La forme est donnée par la liste de ses sommets, et tout contour dont les côtés ne se croisent pas se remplit de la même façon.",
    picture="Image",
    captions=(
        "Autour d’une note",
        "Dans un cercle",
        "Dans un polygone, autour d’un cadre d’image",
    ),
)


# The page in Dutch.
DUTCH = Words(
    title="Tekst die een vorm volgt",
    intro=(
        "Elk blok hieronder is één tekst, regel voor regel gezet: een regel staat alleen waar de vorm hem",
        "helemaal bevat; waar een rechthoek in de weg staat, gaat de regel eromheen of erachter verder.",
    ),
    note="Een notitie in de kolom. De tekst houdt er acht punten afstand van.",
    around="Een tekstkolom met een notitie in de hoek rechtsboven. De regels naast de notitie zijn korter: elke regel stopt acht punten vóór de rand van de notitie, en de regel eronder krijgt weer de volle breedte. Niets hiervan is met de hand gemeten. De tekstopmaak krijgt de plaats van de kolom en van de notitie mee, en berekent regel voor regel hoeveel ruimte elke regel heeft. Dezelfde tekst in een andere taal beslaat een ander aantal regels, en de notitie blijft staan waar ze is geplaatst, met de tekst eromheen.",
    circle="In een cirkel is elke regel zo breed als de cirkel op de hoogte van zijn letters, gemeten waar de cirkel langs die regel het smalst is, zodat geen letter over de rand komt. De regels worden naar het midden toe langer en naar onderen toe korter, en elke regel staat links uitgelijnd binnen zijn eigen breedte.",
    polygon="Een zeshoekige vorm met een afbeeldingskader in het midden. Waar een regel het kader raakt, wordt hij in twee delen gezet: de tekst vult het stuk links van het kader, gaat verder in het stuk rechts ervan en gaat pas daarna naar de volgende regel. Een woord komt nooit in een stuk dat er te smal voor is. Boven en onder het kader krijgen de regels de volle breedte die de vorm hun laat, en langs de schuine zijden worden ze korter, zodat het blok de omtrek van boven tot onder volgt. De vorm wordt opgegeven als een lijst hoekpunten, en elke omtrek waarvan de zijden elkaar niet kruisen, kan op dezelfde manier worden gevuld.",
    picture="Afbeelding",
    captions=(
        "Om een notitie heen",
        "In een cirkel",
        "In een veelhoek, om een afbeeldingskader heen",
    ),
)


# The page in Portuguese.
PORTUGUESE = Words(
    title="Um texto que segue uma forma",
    intro=(
        "Cada bloco abaixo é um só texto composto linha a linha: uma linha só fica onde a forma a contém",
        "inteira, e onde um retângulo lhe barra o caminho, a linha contorna-o ou continua para lá dele.",
    ),
    note="Uma nota inserida na coluna. O texto mantém-se a oito pontos dela.",
    around="Uma coluna de texto com uma nota inserida no canto superior direito. As linhas ao lado da nota são mais curtas: cada uma termina oito pontos antes do bordo da nota, e a linha por baixo dela volta a ter a largura toda. Nada foi medido à mão. O fluxo de texto recebe a posição da coluna e a da nota, e calcula, linha a linha, o espaço de que cada linha dispõe. O mesmo texto noutra língua ocupa outro número de linhas, e a nota continua onde foi colocada, com o texto a contorná-la.",
    circle="Dentro de um círculo, cada linha tem a largura do círculo à altura das suas letras, medida onde o círculo é mais estreito ao longo dessa linha, para que nenhuma letra ultrapasse o bordo. As linhas alongam-se até ao meio e encurtam daí para baixo, e cada uma fica alinhada à esquerda dentro da sua própria largura.",
    polygon="Uma forma de seis lados com uma moldura de imagem no meio. Quando uma linha encontra a moldura, é composta em duas partes: o texto preenche o troço à esquerda da moldura, continua no troço à direita, e só então desce para a linha seguinte. Uma palavra nunca fica num troço demasiado estreito para ela. Acima e abaixo da moldura, as linhas ocupam toda a largura que a forma lhes dá, e ao longo dos lados oblíquos encurtam, de modo que o bloco segue o contorno de cima a baixo. A forma é descrita por uma lista de vértices, e qualquer contorno cujos lados não se cruzem pode ser preenchido da mesma maneira.",
    picture="Imagem",
    captions=(
        "À volta de uma nota",
        "Dentro de um círculo",
        "Dentro de um polígono, à volta de uma moldura de imagem",
    ),
)


# The page in Italian.
ITALIAN = Words(
    title="Un testo che segue una forma",
    intro=(
        "Ogni blocco qui sotto è un solo testo composto riga per riga: una riga sta solo dove la forma",
        "la contiene intera, e dove un rettangolo è d’intralcio, la riga gli gira attorno o prosegue oltre.",
    ),
    note="Una nota inserita nella colonna. Il testo ne resta a otto punti di distanza.",
    around="Una colonna di testo con una nota inserita nell’angolo in alto a destra. Le righe accanto alla nota sono più corte: ognuna si ferma otto punti prima del bordo della nota, e la riga sotto di essa riprende tutta la larghezza. Qui nulla è stato misurato a mano. Il flusso riceve la posizione della colonna e quella della nota, e calcola riga per riga lo spazio di cui ciascuna dispone. Lo stesso testo in un’altra lingua occupa un numero diverso di righe, e la nota resta dove è stata collocata, con il testo che le gira attorno.",
    circle="Dentro un cerchio, ogni riga ha la larghezza del cerchio all’altezza delle sue lettere, presa nel punto in cui il cerchio è più stretto lungo quella riga, così nessuna lettera supera il bordo. Le righe si allungano verso il centro e si accorciano verso il basso, e ciascuna è allineata a sinistra entro la propria larghezza.",
    polygon="Una forma a sei lati con un riquadro per immagine al centro. Dove una riga incontra il riquadro, viene composta in due parti: il testo riempie il tratto a sinistra del riquadro, poi prosegue in quello a destra, e solo allora passa alla riga seguente. Una parola non finisce mai in un tratto troppo stretto per contenerla. Sopra e sotto il riquadro, le righe prendono tutta la larghezza che la forma concede, e lungo i lati obliqui si accorciano, così il blocco segue il contorno dall’alto in basso. La forma è descritta da un elenco di vertici, e qualsiasi contorno i cui lati non si incrocino si riempie allo stesso modo.",
    picture="Immagine",
    captions=(
        "Attorno a una nota",
        "Dentro un cerchio",
        "Dentro un poligono, attorno a un riquadro per immagine",
    ),
)


# The page in Spanish.
SPANISH = Words(
    title="Texto que se ciñe a una forma",
    intro=(
        "Cada bloque de abajo es un solo texto compuesto línea a línea: una línea solo va donde la forma",
        "la contiene entera, y donde un rectángulo se interpone, la línea lo rodea o sigue más allá.",
    ),
    note="Una nota insertada en la columna. El texto se mantiene a ocho puntos de ella.",
    around="Una columna de texto con una nota insertada en su esquina superior derecha. Las líneas junto a la nota son más cortas: cada una se detiene ocho puntos antes del borde de la nota, y la línea que pasa por debajo recupera todo el ancho. Nada se ha medido a mano. Al flujo se le indica dónde está la columna y dónde se sitúa la nota, y calcula, línea a línea, de cuánto espacio dispone cada una. El mismo texto en otro idioma ocupa otro número de líneas, y la nota sigue donde se colocó, rodeada por el texto.",
    circle="Dentro de un círculo, cada línea tiene el ancho del círculo a la altura de sus letras, medido donde el círculo es más estrecho a lo largo de esa línea, de modo que ninguna letra rebasa el borde. Las líneas se alargan hacia el centro y se acortan hacia abajo, y cada una se alinea a la izquierda dentro de su propio ancho.",
    polygon="Una forma de seis lados con un marco de imagen en el centro. Donde una línea se encuentra con el marco, se compone en dos partes: el texto llena el tramo a la izquierda del marco, sigue en el tramo de la derecha y solo entonces baja a la línea siguiente. Una palabra nunca queda en un tramo demasiado estrecho para ella. Encima y debajo del marco, las líneas ocupan todo el ancho que la forma les deja, y a lo largo de los lados oblicuos se acortan, de modo que el bloque sigue el contorno de arriba abajo. La forma se define mediante una lista de vértices, y cualquier contorno cuyos lados no se crucen puede rellenarse de la misma manera.",
    picture="Imagen",
    captions=(
        "Alrededor de una nota",
        "Dentro de un círculo",
        "Dentro de un polígono, alrededor de un marco de imagen",
    ),
)


# The page in German.
GERMAN = Words(
    title="Text, der einer Form folgt",
    intro=(
        "Jeder Block unten ist ein einziger Text, Zeile für Zeile gesetzt: Eine Zeile steht nur dort, wo die Form",
        "sie ganz fasst, und wo ein Rechteck im Weg steht, umgeht sie es oder läuft rechts davon weiter.",
    ),
    note="Eine Notiz in der Spalte. Der Text hält acht Punkt Abstand zu ihr.",
    around="Eine Textspalte mit einer Notiz in der rechten oberen Ecke. Die Zeilen neben der Notiz sind kürzer: Jede endet acht Punkt vor dem Rand der Notiz, und die Zeile darunter hat wieder die volle Breite. Hier wurde nichts von Hand gemessen. Der Textfluss erfährt, wo die Spalte liegt und wo die Notiz steht, und berechnet daraus Zeile für Zeile, wie viel Platz jede Zeile hat. Derselbe Text ergibt in einer anderen Sprache eine andere Zeilenzahl, und die Notiz bleibt trotzdem dort, wo sie gesetzt wurde; der Text läuft um sie herum.",
    circle="In einem Kreis ist jede Zeile so breit wie der Kreis auf der Höhe ihrer Buchstaben, gemessen dort, wo der Kreis entlang dieser Zeile am schmalsten ist. So ragt kein Buchstabe über den Rand. Die Zeilen werden zur Mitte hin länger und nach unten hin kürzer, und jede steht linksbündig in ihrer eigenen Breite.",
    polygon="Eine sechseckige Form mit einem Bildrahmen in der Mitte. Wo eine Zeile auf den Rahmen trifft, wird sie in zwei Teilen gesetzt: Der Text füllt das Stück links vom Rahmen, läuft im Stück rechts davon weiter und geht erst dann in die nächste Zeile. Kein Wort steht in einem Stück, das zu schmal dafür ist. Über und unter dem Rahmen nutzen die Zeilen die volle Breite, die ihnen die Form lässt, und entlang der schrägen Seiten werden sie kürzer, sodass der Block dem Umriss von oben bis unten folgt. Die Form wird als Liste von Eckpunkten angegeben, und jeder Umriss, dessen Seiten sich nicht kreuzen, lässt sich ebenso füllen.",
    picture="Bild",
    captions=(
        "Um eine Notiz herum",
        "In einem Kreis",
        "In einem Vieleck, um einen Bildrahmen herum",
    ),
)


# 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 inside(area: hqf_pdf.Rect, inset: float) -> hqf_pdf.Rect:
    """`area` with `inset` points taken off each side."""
    return hqf_pdf.Rect(
        area.x + inset,
        area.y + inset,
        area.width - 2.0 * inset,
        area.height - 2.0 * inset,
    )


def around_the_note() -> hqf_pdf.TextShape:
    """The column, round the note."""
    return hqf_pdf.TextShape.rect(inside(COLUMN, INSET)).around(NOTE_BOX, GAP)


def circle() -> hqf_pdf.TextShape:
    """The circle."""
    return hqf_pdf.TextShape.ellipse(inside(CIRCLE, INSET))


def hexagon() -> hqf_pdf.TextShape:
    """The six-sided shape, round the picture box."""
    return hqf_pdf.TextShape.polygon(HEXAGON_TEXT).around(PICTURE, GAP)


def outlines(content: hqf_pdf.Content) -> None:
    """The grey outlines of the column, the circle and the six-sided shape."""
    content.save_state()
    content.set_stroke(hqf_pdf.Rgb(0.6, 0.6, 0.6))
    content.set_line_width(0.5)
    content.rect(COLUMN.x, COLUMN.y, COLUMN.width, COLUMN.height)
    content.stroke()
    radius = CIRCLE.width / 2.0
    content.circle(CIRCLE.x + radius, CIRCLE.y + radius, radius)
    content.stroke()
    (x, y), *rest = HEXAGON
    content.move_to(x, y)
    for x, y in rest:
        content.line_to(x, y)
    content.close_path()
    content.stroke()
    content.restore_state()


def note_and_picture(
    content: hqf_pdf.Content, font: hqf_pdf.FontHandle, words: Words
) -> None:
    """The note, a pale box with its text inside, and the picture box, a grey one with
    its label."""
    content.save_state()
    content.set_fill(hqf_pdf.Rgb(1.0, 0.97, 0.85))
    content.set_stroke(hqf_pdf.Rgb(0.6, 0.5, 0.2))
    content.set_line_width(0.5)
    content.rect(NOTE_BOX.x, NOTE_BOX.y, NOTE_BOX.width, NOTE_BOX.height)
    content.fill_and_stroke()
    content.set_fill(hqf_pdf.Rgb(0.85, 0.85, 0.85))
    content.rect(PICTURE.x, PICTURE.y, PICTURE.width, PICTURE.height)
    content.fill()
    content.restore_state()

    flow = hqf_pdf.TextFlow(font, NOTE)
    inner = NOTE_BOX.width - NOTE_PADDING - NOTE_PADDING
    lines = flow.break_lines(words.note, inner)
    flow.draw(
        content,
        lines,
        NOTE_BOX.x + NOTE_PADDING,
        NOTE_BOX.y + NOTE_BOX.height - NOTE_PADDING,
        inner,
    )
    content.draw_text(
        font,
        BODY,
        PICTURE.x + 8.0,
        PICTURE.y + PICTURE.height / 2.0 - 3.0,
        words.picture,
    )


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

    document = hqf_pdf.Document()
    document.set_license(_licence.licensed())
    document.set_info("Title", words.title)
    font = document.add_font(hqf_pdf.Font.from_path(_out.font_path()))

    content = hqf_pdf.Content()
    content.set_fill(hqf_pdf.Rgb(0.0, 0.0, 0.0))
    content.draw_text(font, HEADING, MARGIN, HEAD_TOP, words.title)
    y = HEAD_TOP - 24.0
    for line in words.intro:
        content.draw_text(font, BODY, MARGIN, y, line)
        y -= LINE_STEP

    outlines(content)
    note_and_picture(content, font, words)

    flow = hqf_pdf.TextFlow(font, TEXT)
    for text, shape in (
        (words.around, around_the_note()),
        (words.circle, circle()),
        (words.polygon, hexagon()),
    ):
        flow.shape(text, shape).draw(content)

    column, round_, polygon = words.captions
    content.draw_text(font, BODY, COLUMN.x, COLUMN.y - 14.0, column)
    content.draw_text(font, BODY, CIRCLE.x, CIRCLE.y - 14.0, round_)
    content.draw_text(font, BODY, MARGIN, 118.0, polygon)

    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()