A font behind the font

One line of statuses set twice: in a house font alone, where the marks it has no glyph for come out as empty boxes, then with a second font behind it that draws them.

Python write_font_fallback.py 254 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
"""Sets one line of statuses twice: above in a house font alone, below with a
second font behind it, so the characters the house font has no glyph for can be
seen appearing.

The Python twin of the `write_font_fallback` example in Rust. The house font
here is IBM Plex Sans, which draws a tick but neither a star, an umbrella nor a
cross. Set alone, those three come out as the empty box every font carries for a
character it does not have. Set through a chain, each is handed to DejaVu Sans,
which does have it, and the line is drawn in as many fonts as it takes.

The closing note counts the stretches the chain cuts the line into, and names the
two fonts as the files themselves name them.

The words are held in `Words`, once per language, and `HQF_PDF_LANG` picks which set
is drawn. The four symbols are not language: they stand the same in every one.

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

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import _language
import _licence
import _out

import hqf_pdf

# Where the committed fonts sit.
FONT_DIR = _out.ROOT / "crates" / "hqf-pdf" / "tests" / "fonts"

# The four symbols the statuses are marked with, in the order they are drawn.
#
# The tick is in both fonts; the star, the umbrella and the cross are in the second
# alone, which is what the two settings show.
MARKS = ("✓", "★", "☂", "✗")

# What stands between two statuses on the line. A run of spaces would be set as one, so
# the statuses are held apart by a character.
GAP = " · "


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

    What is not language stays out of it: the four symbols are marks, and the two font
    names are what the files call themselves.
    """

    # The line across the top of the page.
    title: str
    # Why a document needs a font behind its own, in two sentences.
    why: str
    # The heading over the setting in the house font alone.
    alone: str
    # The heading over the setting that falls back.
    chained: str
    # The four statuses, each drawn after its mark.
    statuses: tuple[str, str, str, str]
    # The closing note, cut where the count and the two font names go into it.
    note_before: str
    note_between: str
    note_joiner: str
    note_after: str
    # The three headings over the list of marks.
    head_mark: str
    head_status: str
    head_font: str

    def line(self) -> str:
        """Return the line of statuses, each after its mark."""
        return GAP.join(f"{mark} {status}" for mark, status in zip(MARKS, self.statuses))

    def note(self, stretches: int, house: str, behind: str) -> str:
        """Return the closing note, with the count and the two font names in it."""
        return (
            f"{self.note_before}{stretches}{self.note_between}"
            f"{house}{self.note_joiner}{behind}{self.note_after}"
        )


# The page in English.
ENGLISH = Words(
    title="A font behind the font",
    why=(
        "A house font rarely covers every character a document has to draw. "
        "Without a second font behind it, a character it has no glyph for comes "
        "out as the empty box below, and a reader is left guessing.\n"
        "A chain hands that character to the next font instead, so the line is "
        "set in as many fonts as it takes and every mark is drawn."
    ),
    alone="The house font alone: what it has no glyph for, it cannot draw.",
    chained="The house font, with a second one behind it.",
    statuses=("Paid", "Priority", "Weather hold", "Refused"),
    note_before="The line above is cut into ",
    note_between=" stretches, drawn by two fonts: ",
    note_joiner=", then ",
    note_after=(
        ". The words are in the first; the marks it lacks are in the second."
    ),
    head_mark="Mark",
    head_status="Status",
    head_font="Drawn by",
)

# The page in French.
FRENCH = Words(
    title="Une police derrière la police",
    why=(
        "Une police de marque couvre rarement tous les caractères qu'un document "
        "doit dessiner. Sans une seconde police derrière elle, un caractère "
        "qu'elle ne connaît pas sort en case vide, comme ci-dessous, et le "
        "lecteur reste devant une devinette.\n"
        "Une chaîne confie plutôt ce caractère à la police suivante : la ligne "
        "est composée dans autant de polices qu'il le faut, et chaque marque est "
        "dessinée."
    ),
    alone="La police de marque seule : ce qu'elle ne connaît pas, elle ne le dessine pas.",
    chained="La police de marque, avec une seconde police derrière elle.",
    statuses=("Payé", "Prioritaire", "Attente météo", "Refusé"),
    note_before="La ligne du dessus est découpée en ",
    note_between=" morceaux, dessinés par deux polices : ",
    note_joiner=", puis ",
    note_after=(
        ". Les mots viennent de la première, les marques qui lui manquent "
        "de la seconde."
    ),
    head_mark="Marque",
    head_status="État",
    head_font="Dessinée par",
)


# 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 left edge of everything on the page.
X = 60.0

# The width every block is broken to.
ROOM = 475.0

# The baseline the title sits on.
TITLE_TOP = 780.0

# The top of the block that says why the page matters.
WHY_TOP = 750.0

# The top of the heading over the setting in the house font alone.
ALONE_TOP = 650.0

# The top of the heading over the setting that falls back.
CHAINED_TOP = 560.0

# The top of the closing note.
NOTE_TOP = 470.0

# How far a setting sits below the heading over it.
UNDER_HEADING = 34.0

# The size the statuses are set in.
LINE_SIZE = 15.0

# The top of the list that names the font behind each mark.
LIST_TOP = 400.0

# How far apart two rows of that list sit.
ROW_DROP = 18.0

# Where the three columns of that list begin.
COLUMNS = (X, X + 46.0, X + 176.0)


def main() -> None:
    language = _language.from_environment()
    words = _language.words_of(WORDS, language)
    line = words.line()

    out = _out.output_path(Path(_language.file_name("font_fallback.pdf", language)).stem)

    house = hqf_pdf.Font.from_path(FONT_DIR / "IBMPlexSans-Regular.otf")
    behind = hqf_pdf.Font.from_path(FONT_DIR / "DejaVuSans.ttf")
    house_name = house.postscript_name
    behind_name = behind.postscript_name

    document = hqf_pdf.Document()
    document.set_license(_licence.licensed())
    plain = document.add_font(house)
    spare = document.add_font(behind)
    chain = plain.falling_back_to(spare)

    content = hqf_pdf.Content()

    title = hqf_pdf.TextFlow(plain, 18.0)
    title.draw(content, title.break_lines(words.title, ROOM), X, TITLE_TOP, ROOM)

    # The block that says why the page matters is set through the chain: it is ordinary
    # prose, and the chain draws ordinary prose exactly as the house font alone would.
    why = hqf_pdf.TextFlow(chain, 10.0, leading=14.0)
    why.draw(content, why.break_lines(words.why, ROOM), X, WHY_TOP, ROOM)

    # The same line twice: once in the house font on its own, once through the chain.
    # Only the font differs.
    for top, heading, handle in (
        (ALONE_TOP, words.alone, plain),
        (CHAINED_TOP, words.chained, chain),
    ):
        label = hqf_pdf.TextFlow(plain, 9.0)
        label.draw(content, label.break_lines(heading, ROOM), X, top, ROOM)

        statuses = hqf_pdf.TextFlow(handle, LINE_SIZE)
        statuses.draw(
            content,
            statuses.break_lines(line, ROOM),
            X,
            top - UNDER_HEADING,
            ROOM,
        )

    # What the chain made of the line, counted off the chain itself rather than written
    # down: the note says what the page above it holds.
    note = words.note(len(chain.runs(line)), house_name, behind_name)
    closing = hqf_pdf.TextFlow(chain, 10.0, leading=14.0)
    closing.draw(content, closing.break_lines(note, ROOM), X, NOTE_TOP, ROOM)

    # Which font drew which mark, read off the chain: a mark the house font has is drawn
    # by it, and the rest by the font behind it.
    head_row = hqf_pdf.TextFlow(plain, 9.0)
    for column, head in zip(COLUMNS, (words.head_mark, words.head_status, words.head_font)):
        head_row.draw(content, head_row.break_lines(head, ROOM), column, LIST_TOP, ROOM)

    for row, (mark, status) in enumerate(zip(MARKS, words.statuses)):
        runs = chain.runs(mark)
        drawn_by = house_name if runs and runs[0].font == plain.name else behind_name
        top = LIST_TOP - ROW_DROP * float(row + 1)
        for column, cell in zip(COLUMNS, (mark, status, drawn_by)):
            flow = hqf_pdf.TextFlow(chain, 10.0)
            flow.draw(content, flow.break_lines(cell, ROOM), column, top, ROOM)

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