A month's report, drawn in curves

A pie of where the hours went, a ring gauge of how far into the year the work is, a card with eased corners, and arcs told where to go rather than how far to turn.

Python write_shapes.py 383 lines
  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
"""Draws a month's activity report out of the shapes a page has no operator for.

The Python twin of the `write_shapes` example in Rust. PDF states a path with straight
segments and cubic curves and nothing else, so a circle is four curves and a slice of a
pie is an arc with two straight sides. Everything on this page is asked for by name — a
centre and a radius, two angles, three points — and the curves are worked out for it.

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

Usage: python examples/write_shapes.py [out.pdf] [font.ttf]
       HQF_PDF_LANG=fr python examples/write_shapes.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 page's title.
    title: str
    # What the page is about.
    lead: str
    # The heading over the pie.
    pie: str
    # What the four slices of the pie are called.
    slices: tuple[str, str, str, str]
    # The heading over the ring gauge.
    gauge: str
    # What the ring gauge counts.
    gauge_label: str
    # The heading over the card.
    card: str
    # What the line inside the card shows.
    card_label: str
    # The heading over the two arcs.
    arcs: str
    # What the arc through three points is called.
    through: str
    # What the four arcs of one ellipse are called.
    four: str
    # What a curve costs, and what it does not.
    caveat: str


# The page in English.
ENGLISH = Words(
    title="The month, drawn in curves",
    lead=(
        "A page states a path with straight segments and cubic curves, and with "
        "nothing else: there is no circle operator and no arc operator. Every shape "
        "below was asked for by name — a centre and a radius, two angles, three "
        "points to pass through — and the curves were worked out for it. No piece "
        "bends further than a quarter turn, so a circle is four curves and the "
        "outline is out by a fortieth of a point on a circle an inch across."
    ),
    pie="Where the hours went",
    slices=("Drawing office", "Plates and proofs", "Delivery", "Storage"),
    gauge="How far into the year the work is",
    gauge_label="of the year's work is behind us",
    card="A card whose corners are eased off",
    card_label="Orders taken, week by week",
    arcs="Arcs told where to go, not how far to turn",
    through="One arc, through three points",
    four="Four arcs, between the same two points",
    caveat=(
        "A curve costs six numbers and a straight segment costs two, so a circle is "
        "five lines of a page rather than one. What it does not cost is a picture: "
        "the shapes here are drawn by the reader at whatever size the page is shown, "
        "and they stay sharp at any of them."
    ),
)

# The page in French.
FRENCH = Words(
    title="Le mois, dessiné en courbes",
    lead=(
        "Une page décrit un tracé avec des segments droits et des courbes cubiques, "
        "et avec rien d'autre : il n'y a ni opérateur de cercle ni opérateur d'arc. "
        "Chaque forme ci-dessous a été demandée par son nom — un centre et un rayon, "
        "deux angles, trois points par où passer — et les courbes ont été calculées "
        "pour elle. Aucun morceau ne tourne de plus d'un quart de tour : un cercle "
        "fait donc quatre courbes, et le contour s'écarte d'un quarantième de point "
        "sur un cercle de deux centimètres et demi."
    ),
    pie="Où sont passées les heures",
    slices=("Bureau d'études", "Plaques et épreuves", "Livraison", "Stockage"),
    gauge="Où en est l'année de travail",
    gauge_label="du travail de l'année est derrière nous",
    card="Une carte aux coins adoucis",
    card_label="Commandes prises, semaine par semaine",
    arcs="Des arcs à qui l'on dit où aller, pas de combien tourner",
    through="Un arc, par trois points",
    four="Quatre arcs, entre les deux mêmes points",
    caveat=(
        "Une courbe coûte six nombres et un segment droit en coûte deux : un cercle "
        "fait donc cinq lignes de page au lieu d'une. Ce qu'il ne coûte pas, c'est "
        "une image : les formes d'ici sont tracées par le lecteur à la taille où la "
        "page est affichée, et elles restent nettes à toutes."
    ),
)

# Every language the example is written in.
WORDS = {_language.ENGLISH: ENGLISH, _language.FRENCH: FRENCH}

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

# How wide a block of text, and the card, are.
WIDTH = 451.0

# The hours the four slices of the pie stand for.
HOURS = (64, 41, 27, 18)

# The orders taken in each of seven weeks, which the line in the card draws.
ORDERS = (26, 38, 31, 52, 44, 61, 57)

# The most orders the line in the card leaves room for.
CEILING = 70.0

# How far round the year the gauge has come.
DONE = 0.62


def palette() -> list[hqf_pdf.Rgb]:
    """The four colours the slices and the line are drawn in."""
    return [
        hqf_pdf.Rgb(0.16, 0.33, 0.62),
        hqf_pdf.Rgb(0.30, 0.57, 0.75),
        hqf_pdf.Rgb(0.55, 0.73, 0.82),
        hqf_pdf.Rgb(0.79, 0.86, 0.90),
    ]


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 label(
    content: hqf_pdf.Content,
    handle: hqf_pdf.FontHandle,
    size: float,
    x: float,
    y: float,
    text: str,
    room: float,
) -> None:
    """Sets one line of words at `(x, y)`."""
    flow = hqf_pdf.TextFlow(handle, size)
    flow.draw(content, flow.break_lines(text, room), x, y, room)


def shares() -> list[float]:
    """What share of the hours each slice of the pie stands for."""
    total = 0
    for hours in HOURS:
        total += hours
    return [hours / total for hours in HOURS]


def pie(
    content: hqf_pdf.Content,
    handle: hqf_pdf.FontHandle,
    words: Words,
    cx: float,
    cy: float,
) -> None:
    """Draws the pie about `(cx, cy)`, and names each slice beside it.

    A slice is one arc with two straight sides: the arc writes where it begins, a
    segment runs back to the centre, and closing the subpath draws the other side.
    """
    radius = 68.0
    start = math.pi / 2.0
    for share, colour in zip(shares(), palette()):
        sweep = math.tau * share
        content.save_state()
        content.set_fill(colour)
        content.arc(cx, cy, radius, start, -sweep)
        content.line_to(cx, cy)
        content.close_path()
        content.fill()
        content.restore_state()
        start -= sweep

    # The names sit to the right of the pie, one under the other, each behind a swatch
    # of its own colour.
    y = cy + 46.0
    for colour, name in zip(palette(), words.slices):
        content.save_state()
        content.set_fill(colour)
        content.rounded_rect(cx + 96.0, y, 12.0, 12.0, 3.0)
        content.fill()
        content.restore_state()
        label(content, handle, 9.5, cx + 116.0, y + 3.0, name, 180.0)
        y -= 26.0


def gauge(content: hqf_pdf.Content, cx: float, cy: float) -> None:
    """Draws the ring gauge about `(cx, cy)`, the part that is done in the darkest
    colour of the palette.

    The pale track is one circle laid inside another and filled by the even-odd rule,
    which leaves the ring between them. The part that is done is an arc drawn with a
    thick pen over that ring.
    """
    content.save_state()
    content.set_fill(hqf_pdf.Rgb(0.90, 0.92, 0.95))
    content.circle(cx, cy, 46.0)
    content.circle(cx, cy, 30.0)
    content.fill_even_odd()
    content.restore_state()

    content.save_state()
    content.set_line_width(16.0)
    content.set_stroke(palette()[0])
    content.arc(cx, cy, 38.0, math.pi / 2.0, -(math.tau * DONE))
    content.stroke()
    content.restore_state()


def card(
    content: hqf_pdf.Content,
    handle: hqf_pdf.FontHandle,
    words: Words,
    top: float,
) -> float:
    """Draws the card and the line of orders inside it, and hands back the ordinate the
    card ends at."""
    height = 96.0
    bottom = top - height

    content.save_state()
    content.set_fill(hqf_pdf.Rgb(0.96, 0.97, 0.98))
    content.set_stroke(hqf_pdf.Rgb.gray(0.72))
    content.set_line_width(0.8)
    content.rounded_rect(LEFT, bottom, WIDTH, height, 14.0)
    content.fill_and_stroke()
    content.restore_state()

    label(content, handle, 9.0, LEFT + 18.0, top - 20.0, words.card_label, 300.0)

    # The seven weeks are spaced evenly across the card, and each order count is
    # measured up from the floor of the plot.
    floor = bottom + 18.0
    plot = 52.0
    step = (WIDTH - 36.0) / 6.0
    points = []
    x = LEFT + 18.0
    for orders in ORDERS:
        share = orders / CEILING
        points.append((x, floor + plot * share))
        x += step

    content.save_state()
    content.set_line_width(2.2)
    content.set_stroke(palette()[1])
    content.rounded_polyline(points, 14.0)
    content.stroke()
    content.restore_state()
    return bottom


def curves(
    content: hqf_pdf.Content,
    handle: hqf_pdf.FontHandle,
    words: Words,
    top: float,
) -> None:
    """Draws the arc through three points and the four arcs of one ellipse, and names
    both."""
    # The two figures bulge above and below the line they are drawn on, so both are
    # named over their own drawing rather than under it.
    base = top - 74.0

    label(content, handle, 8.5, LEFT, top - 12.0, words.through, 220.0)
    content.save_state()
    content.set_line_width(1.6)
    content.set_stroke(palette()[0])
    content.move_to(LEFT, base)
    content.arc_through(LEFT, base, LEFT + 100.0, base + 26.0, LEFT + 200.0, base)
    content.stroke()
    content.restore_state()

    # The same two ends, the same two radii, and the four arcs that answer them: the
    # long way round and the short, each walked either way.
    from_x = LEFT + 300.0
    to_x = LEFT + 400.0
    shapes = [
        hqf_pdf.EllipticalArc(),
        hqf_pdf.EllipticalArc(clockwise=True),
        hqf_pdf.EllipticalArc(large=True),
        hqf_pdf.EllipticalArc(large=True, clockwise=True),
    ]
    label(content, handle, 8.5, from_x, top - 12.0, words.four, 220.0)
    for shape, colour in zip(shapes, palette()):
        content.save_state()
        content.set_line_width(1.6)
        content.set_stroke(colour)
        content.move_to(from_x, base)
        content.elliptical_arc(from_x, base, to_x, base, 62.0, 18.0, shape)
        content.stroke()
        content.restore_state()


def build(words: Words, font: Path) -> bytes:
    """Draws the whole page."""
    doc = hqf_pdf.Document()
    doc.set_license(_licence.licensed())
    text = doc.add_font(hqf_pdf.Font.from_path(font))

    content = hqf_pdf.Content()
    top = 790.0
    top = block(content, text, 17.0, top, words.title) - 12.0
    top = block(content, text, 9.5, top, words.lead) - 22.0

    top = block(content, text, 11.0, top, words.pie) - 12.0
    pie(content, text, words, LEFT + 74.0, top - 74.0)
    top -= 152.0

    top = block(content, text, 11.0, top, words.gauge) - 12.0
    gauge(content, LEFT + 52.0, top - 50.0)
    label(content, text, 9.5, LEFT + 118.0, top - 46.0, words.gauge_label, 280.0)
    top -= 100.0

    top = block(content, text, 11.0, top, words.card) - 12.0
    top = card(content, text, words, top) - 24.0

    top = block(content, text, 11.0, top, words.arcs) - 12.0
    curves(content, text, words, top)
    top -= 118.0

    content.set_fill(hqf_pdf.Rgb.gray(0.35))
    block(content, text, 8.5, top, words.caveat)

    page = hqf_pdf.Page.a4()
    page.set_content(content)
    doc.add_page(page)
    return doc.to_bytes()


def main() -> None:
    """Writes the page."""
    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("shapes.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()