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 | """Pours one long passage into three columns, flowing from the foot of each to
the head of the next.
The Python twin of the `write_columns` example in Rust. The passage is broken
once to the column width, then set column by column: each column fits as many of
the lines as its height allows, draws that slice with `Lines.sub`, and the next
column takes up where it stopped. The passage carries a form feed the flow reads
as a box break: the second column ends on the line before it, room to spare, and
the paragraph after it opens the third. A thin rule outlines each column so the
text can be seen reaching its foot and carrying on. A line of its own runs up the
left margin, the block turned a quarter about its corner.
Under each column a caption says how many lines it took and why it took no more, how
much of the text they carry and how wide the widest of them runs. A second passage is
poured the same way by a flow that lets a column take seven lines at most and keeps
three lines of a paragraph together either side of a cut, so that a column may stop at
its limit or hold back lines it had room for; a marker planted in the text says which
column and line the word after it went into.
The passage is held in `Words`, once per language, and `HQF_PDF_LANG` picks which one
is poured. The measure, the gap and the height are not language: they are the same
three columns whoever reads the page.
The terminal says after which lines of the first passage its box breaks fall, read off
each line.
Usage: python examples/write_columns.py [out.pdf] [font.ttf]
HQF_PDF_LANG=fr python examples/write_columns.py
"""
from __future__ import annotations
import math
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 page draws, in one language."""
# The line that runs up the left margin.
margin: str
# The passage poured through the columns, a form feed sending its second paragraph
# to the third.
passage: str
# The passage poured through the second row of columns, a `MARKER` planted before
# one of its words.
second: str
# How many lines a column took and why it took no more; `{taken}` and `{why}` stand
# for them.
took: str
# Why a column took no more lines, one reason per kind, in the order `REASONS` lists
# them.
reasons: tuple[str, str, str, str, str]
# How much of the text a column carries and how wide its widest line runs;
# `{characters}` and `{widest}` stand for them.
carries: str
# How many lines a column that held some back had room for; `{room}` stands for
# them.
room: str
# Where the marked word went; `{column}` and `{line}` stand for them.
marked: str
# The page in English.
ENGLISH = Words(
margin="Poured into three columns",
passage=(
"A flow too long for one column is set column by column. Each column takes as "
"many of the broken lines as its height allows, draws them, and hands the rest "
"to the column beside it, which takes up exactly where the last one stopped. "
"The words are broken once, to the width one column has, so every column is "
"set to the same measure.\x0cA column break written in the text ends the "
"second column early, and this paragraph opens the third. This is what sets a "
"newspaper's text, a report's two columns, or the narrow measure a wide page "
"reads best in."
),
second=(
"This flow lets a column take seven lines at most, and keeps at least three "
"lines of a paragraph on either side of a cut. A column may therefore stop "
"short of its foot: at its limit of lines, or where a cut would strand fewer "
"than three lines of a paragraph, which then waits for the next column whole. "
"The caption under each column says which it was.\nA §marker planted in the "
"text draws nothing and takes no room; it says which column and which line the "
"word after it went into, as the line under the columns reports."
),
took="{taken} lines: {why}.",
reasons=(
"the text ends here",
"the column is full",
"its limit of lines",
"a column break",
"lines held back",
),
carries="{characters} characters, widest line {widest} pt",
room="It had room for {room} lines.",
marked="The marker: column {column}, line {line}.",
)
# The page in French.
FRENCH = Words(
margin="Réparti sur trois colonnes",
passage=(
"Un texte trop long pour une seule colonne se compose colonne par colonne. "
"Chaque colonne prend autant de lignes coupées que sa hauteur le permet, les "
"place, et passe le reste à sa voisine, qui reprend exactement là où la "
"précédente s’est arrêtée. Les mots ne sont coupés qu’une fois, à la largeur "
"d’une colonne : toutes sont donc composées à la même justification.\x0cUn "
"saut de colonne écrit dans le texte termine la deuxième colonne plus tôt, et "
"ce paragraphe ouvre la troisième. C’est ainsi que se composent le texte d’un "
"journal, les deux colonnes d’un rapport ou la colonne étroite d’une page "
"large."
),
second=(
"Ce texte limite une colonne à sept lignes et garde au moins trois lignes "
"d’un paragraphe de chaque côté d’une coupure. Une colonne peut donc s’arrêter "
"avant le bas : à sa limite de lignes, ou là où une coupure isolerait moins "
"de trois lignes d’un paragraphe, qui passe alors entier à la colonne suivante. "
"Sa légende dit pourquoi.\nUn §repère placé dans le texte ne dessine rien et "
"ne prend pas de place ; il dit dans quelle colonne et à quelle ligne est allé "
"le mot qui le suit, comme l’indique la ligne sous les colonnes."
),
took="{taken} lignes : {why}.",
reasons=(
"fin du texte",
"colonne pleine",
"limite de lignes",
"saut de colonne",
"lignes reportées",
),
carries="{characters} caractères, lignes d’au plus {widest} pt",
room="Elle avait la place pour {room} lignes.",
marked="Le repère : colonne {column}, ligne {line}.",
)
# The page in Dutch.
DUTCH = Words(
margin="Gegoten in drie kolommen",
passage=(
"Een tekst die te lang is voor één kolom wordt kolom voor kolom gezet. Elke "
"kolom neemt zoveel regels als haar hoogte toelaat, tekent ze en geeft de "
"rest door aan de kolom ernaast, die precies verdergaat waar de vorige "
"stopte. De woorden worden één keer afgebroken, op de breedte van één kolom, "
"dus elke kolom heeft dezelfde zetbreedte.\fEen kolomeinde in de tekst "
"beëindigt de tweede kolom vroeger, en deze alinea opent de derde. Zo worden "
"een krant, een rapport in twee kolommen of een smalle zetbreedte gezet."
),
second=(
"Hier neemt een kolom hoogstens zeven regels en houdt ze minstens drie regels "
"van een alinea aan elke kant van een breuk. Een kolom kan dus boven haar "
"voet stoppen: bij haar limiet, of waar een breuk minder dan drie regels zou "
"achterlaten. De regel onder elke kolom zegt waarom.\nEen §-markering in de "
"tekst tekent niets en neemt geen ruimte in; ze zegt in welke kolom en regel "
"het volgende woord terechtkwam."
),
took="{taken} regels: {why}.",
reasons=(
"de tekst eindigt hier",
"de kolom is vol",
"haar limiet aan regels",
"een kolomeinde",
"regels vastgehouden",
),
carries="{characters} tekens, breedste regel {widest} pt",
room="Er was plaats voor {room} regels.",
marked="De markering: kolom {column}, regel {line}.",
)
# The page in Portuguese.
PORTUGUESE = Words(
margin="Vertido em três colunas",
passage=(
"Um texto longo demais para uma coluna compõe-se coluna a coluna. Cada uma "
"leva tantas linhas quantas a altura permite, desenha-as e passa o resto à do "
"lado, que retoma exatamente onde a outra parou. As palavras quebram-se uma "
"vez, à largura de uma coluna, por isso todas têm a mesma medida.\fUma quebra "
"de coluna escrita no texto acaba mais cedo a segunda coluna, e este "
"parágrafo abre a terceira. Assim se compõe um jornal, um relatório a duas "
"colunas ou uma medida estreita."
),
second=(
"Aqui uma coluna leva no máximo sete linhas e deixa pelo menos três linhas de "
"um parágrafo de cada lado de um corte. Uma coluna pode por isso acabar antes "
"do pé: no limite de linhas, ou onde um corte deixaria menos de três linhas. "
"A linha sob cada coluna diz porquê.\nUma §marca no texto não desenha nada "
"nem ocupa espaço; diz em que coluna e linha foi a palavra seguinte."
),
took="{taken} linhas: {why}.",
reasons=(
"o texto acaba aqui",
"a coluna está cheia",
"o seu limite de linhas",
"uma quebra de coluna",
"linhas retidas",
),
carries="{characters} caracteres, linha mais larga {widest} pt",
room="Tinha lugar para {room} linhas.",
marked="A marca: coluna {column}, linha {line}.",
)
# The page in Italian.
ITALIAN = Words(
margin="Versato in tre colonne",
passage=(
"Un testo troppo lungo per una colonna si compone colonna per colonna. Ognuna "
"prende quante righe consente la sua altezza, le disegna e passa il resto a "
"quella accanto, che riprende esattamente dove l’altra si è fermata. Le "
"parole si spezzano una volta, alla larghezza di una colonna, così tutte "
"hanno la stessa giustezza.\fUn salto di colonna scritto nel testo chiude "
"prima la seconda colonna, e questo paragrafo apre la terza. Così si compone "
"un giornale, un rapporto a due colonne o una giustezza stretta."
),
second=(
"Qui una colonna prende al massimo sette righe e lascia almeno tre righe di "
"un paragrafo da ogni lato di un taglio. Una colonna può quindi finire prima "
"del piede: al limite di righe, o dove un taglio lascerebbe meno di tre "
"righe. La riga sotto ogni colonna dice perché.\nUn §segno nel testo non "
"disegna nulla e non occupa spazio; dice in quale colonna e riga è finita la "
"parola seguente."
),
took="{taken} righe: {why}.",
reasons=(
"il testo finisce qui",
"la colonna è piena",
"il suo limite di righe",
"un salto di colonna",
"righe trattenute",
),
carries="{characters} caratteri, riga più larga {widest} pt",
room="Aveva posto per {room} righe.",
marked="Il segno: colonna {column}, riga {line}.",
)
# The page in Spanish.
SPANISH = Words(
margin="Vertido en tres columnas",
passage=(
"Un texto demasiado largo para una columna se compone columna a columna. Cada "
"una toma tantas líneas como permite su altura, las dibuja y pasa el resto a "
"la de al lado, que sigue justo donde paró la anterior. El texto se corta en "
"líneas una sola vez, al ancho de una columna, así que todas tienen la misma "
"medida.\fUn salto de columna escrito en el texto termina antes la segunda "
"columna, y este párrafo abre la tercera. Así se compone un periódico, un "
"informe a dos columnas o una medida estrecha."
),
second=(
"Aquí una columna lleva siete líneas como mucho y deja al menos tres líneas "
"de un párrafo a cada lado de un corte. Por eso una columna puede acabar "
"antes de su pie: en su límite de líneas, o donde un corte dejaría menos de "
"tres líneas. La línea bajo cada columna dice por qué.\nUna §marca en el "
"texto no dibuja nada ni ocupa sitio; dice en qué columna y línea cayó la "
"palabra siguiente."
),
took="{taken} líneas: {why}.",
reasons=(
"el texto acaba aquí",
"la columna está llena",
"su límite de líneas",
"un salto de columna",
"líneas retenidas",
),
carries="{characters} caracteres, línea más ancha {widest} pt",
room="Tenía sitio para {room} líneas.",
marked="La marca: columna {column}, línea {line}.",
)
# The page in German.
GERMAN = Words(
margin="In drei Spalten gegossen",
passage=(
"Ein Text, der für eine Spalte zu lang ist, wird Spalte für Spalte gesetzt. "
"Jede Spalte nimmt so viele Zeilen, wie ihre Höhe erlaubt, und gibt den Rest "
"an die nächste weiter, die genau dort weitermacht. Die Wörter werden einmal "
"auf die Breite einer Spalte umbrochen, also haben alle Spalten dieselbe "
"Satzbreite.\fEin Spaltenumbruch im Text beendet die zweite Spalte früher, "
"und dieser Absatz beginnt die dritte. So setzt man eine Zeitung, einen "
"zweispaltigen Bericht oder eine schmale Satzbreite."
),
second=(
"Hier nimmt eine Spalte höchstens sieben Zeilen und lässt mindestens drei "
"Zeilen eines Absatzes auf jeder Seite eines Umbruchs. Eine Spalte kann also "
"vor ihrem Fuß enden: an ihrer Grenze oder wo ein Umbruch weniger als drei "
"Zeilen zurückließe. Die Zeile unter jeder Spalte nennt den Grund.\nEine "
"§Marke im Text zeichnet nichts und nimmt keinen Platz ein; sie sagt, in "
"welche Spalte und Zeile das nächste Wort kam."
),
took="{taken} Zeilen: {why}.",
reasons=(
"der Text endet hier",
"die Spalte ist voll",
"ihre Zeilengrenze",
"ein Spaltenumbruch",
"zurückgehaltene Zeilen",
),
carries="{characters} Zeichen, breiteste Zeile {widest} pt",
room="Sie hatte Platz für {room} Zeilen.",
marked="Die Marke: Spalte {column}, Zeile {line}.",
)
# 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}
# The left edge of the first column.
LEFT = 70.0
# The top edge every column shares.
TOP = 720.0
# The width of one column.
COLUMN = 145.0
# The gap between one column and the next.
GAP = 25.0
# How far down from TOP a column reaches.
HEIGHT = 130.0
# How many columns the passage is poured through.
COLUMNS = 3
# How far from the page's left edge the line up the margin is turned about.
MARGIN_X = 44.0
# The top edge every column of the second row shares.
SECOND_TOP = 500.0
# The size the captions under the columns are set at, in points.
CAPTION = 7.0
# The character the second passage plants before the word it marks.
MARKER = "\u00a7"
# The reasons a column stops taking lines, in the order `Words.reasons` names them.
REASONS = (
hqf_pdf.BoxStop.TextEnd,
hqf_pdf.BoxStop.Full,
hqf_pdf.BoxStop.LineLimit,
hqf_pdf.BoxStop.BoxBreak,
hqf_pdf.BoxStop.HeldBack,
)
def caption(flow, lines, placed, text, words) -> list[str]:
"""What the column `placed` took of `lines`, laid out from `text` by `flow`, said
in a line or two for the caption under it."""
said = [
words.took.replace("{taken}", str(placed.taken)).replace(
"{why}", words.reasons[REASONS.index(placed.stop)]
)
]
first, end = placed.lines
widest = max(
(lines.natural_width(index) for index in range(first, end)), default=0.0
)
start, stop = placed.source
carried = text[start:stop]
said.append(
words.carries.replace("{characters}", str(len(carried))).replace(
"{widest}", f"{widest:.0f}"
)
)
if placed.stop == hqf_pdf.BoxStop.HeldBack:
room = flow.fit_regardless(lines, HEIGHT, first)
said.append(words.room.replace("{room}", str(room)))
return said
def set_caption(content, handle, x, foot, lines) -> None:
"""Set the caption `lines` under a column whose left edge is `x` and whose foot is
`foot`."""
baseline = foot - 12.0
for line in lines:
content.draw_text(handle, CAPTION, x, baseline, line)
baseline -= 10.0
def pour(content, handle, flow, text, top, words) -> list[tuple[int, int]]:
"""Pour `text`, broken by `flow` to the column's measure, through a row of columns
whose top edge is `top`, a caption under each; hand back where each marker the text
carries went, as a column and a line of it, counted from one."""
lines = flow.break_lines(text, COLUMN)
start = 0
x = LEFT
marked = []
for column in range(COLUMNS):
placed = flow.fit_box(lines, HEIGHT, start)
flow.draw(content, lines.sub(start, placed.next), x, top, COLUMN)
content.save_state()
content.set_stroke(hqf_pdf.Rgb.gray(0.8))
content.set_line_width(0.5)
content.rect(x, top - HEIGHT, COLUMN, HEIGHT)
content.stroke()
content.restore_state()
said = caption(flow, lines, placed, text, words)
set_caption(content, handle, x, top - HEIGHT, said)
first, end = placed.lines
for mark in placed.marks:
line = next(
(
index - first
for index in range(first, end)
if mark in lines.marks(index)
),
0,
)
marked.append((column + 1, line + 1))
start = placed.next
x += COLUMN + GAP
return marked
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("columns.pdf", language)).stem)
document = hqf_pdf.Document()
document.set_license(_licence.licensed())
handle = document.add_font(hqf_pdf.Font.from_path(_out.font_path()))
# A paragraph cut between two columns leaves at least two lines at the foot of the
# first and carries at least two to the top of the next.
flow = hqf_pdf.TextFlow(
handle,
10.0,
leading=14.0,
align=hqf_pdf.Align.Justify,
orphans=2,
widows=2,
box_break="\f",
)
content = hqf_pdf.Content()
pour(content, handle, flow, words.passage, TOP, words)
limited = hqf_pdf.TextFlow(
handle,
10.0,
leading=14.0,
align=hqf_pdf.Align.Justify,
max_lines=7,
orphans=3,
widows=3,
marker=MARKER,
)
marked = pour(content, handle, limited, words.second, SECOND_TOP, words)
for column, line in marked:
said = words.marked.replace("{column}", str(column)).replace(
"{line}", str(line)
)
set_caption(content, handle, LEFT, SECOND_TOP - HEIGHT - 40.0, [said])
# Up the left margin, reading from the foot of the columns: the block is laid out
# upright, the height of a column wide, and turned a quarter.
note = hqf_pdf.TextFlow(handle, 9.0, turned=math.pi / 2)
margin = note.break_lines(words.margin, HEIGHT)
note.draw_with_turn(content, margin, MARGIN_X, TOP - HEIGHT, HEIGHT)
page = hqf_pdf.Page.a4()
page.set_content(content)
document.add_page(page)
written = document.write(out)
print(f"wrote {out}: {written} bytes")
broken = flow.break_lines(words.passage, COLUMN)
box_ends = [index + 1 for index in range(len(broken)) if broken.ends_box(index)]
print(f"the first passage's box breaks fall after lines {box_ends}")
if __name__ == "__main__":
main()
|