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
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815 | """Writes a sheet of peel-off labels: a grid of shelf labels, each carrying an article
name, its price, and the article number a till reads, closed by a row of warehouse bin
labels.
The Python twin of the `write_labels` example in Rust: the same sheet, through the
binding rather than through the library directly.
The grid is driven by a stock description — how many labels across and down, how big
one is, and how far apart their corners sit — so a reader retargets the sheet to their
own die-cut stock by changing six numbers. Every label is outlined and the page margins
carry cut marks, so what is drawn lines up with what is cut.
Whether the codes scan is not for the page to say, and not for an eye: a scanner says.
`scripts/check_barcode.sh` points a decoder at the rendered sheet and reads back every
article number and every bin.
Every word the page draws is held in `Words`, once per language, and `HQF_PDF_LANG`
picks which one is drawn. The prices, the article numbers, the aisle and rack codes and
what a scanner reads off the sheet are the same in both.
Usage: python examples/write_labels.py [out.pdf] [font.ttf]
HQF_PDF_LANG=fr python examples/write_labels.py [out.pdf]
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import _language
import _licence
import _out
import hqf_pdf
# A4, in points.
PAGE_WIDTH = 595.276
PAGE_HEIGHT = 841.89
# The die-cut stock the sheet is laid out for: how many labels across and down, how big
# one is, and how far apart two neighbours' corners sit. The pitch is the label plus the
# gutter the cutter leaves between two of them.
COLUMNS = 3
ROWS = 5
LABEL_WIDTH = 170.0
LABEL_HEIGHT = 120.0
COLUMN_PITCH = 178.0
ROW_PITCH = 128.0
# The top edge of the first row of labels.
SHEET_TOP = 742.0
# How long a cut mark is and how far it stands off the grid, in points.
MARK_LENGTH = 11.0
MARK_OFFSET = 5.0
# The width of the narrowest bar, in points, and the side of one Data Matrix module.
# Everything else in a code is a whole number of these.
MODULE = 1.0
MATRIX_MODULE = 3.0
# The ink the prices are drawn in, the grey of the small print, and the black the names
# and the codes are drawn in.
ACCENT = hqf_pdf.Rgb(0.11, 0.33, 0.55)
MUTED = hqf_pdf.Rgb(0.42, 0.42, 0.45)
INK = hqf_pdf.Rgb(0.1, 0.1, 0.12)
@dataclass(frozen=True)
class Ean13Article:
"""Twelve digits, without their check digit, drawn as an EAN-13 barcode."""
digits: str
@dataclass(frozen=True)
class UpcAArticle:
"""Eleven digits, without their check digit, drawn as a UPC-A barcode."""
digits: str
@dataclass(frozen=True)
class Shelf:
"""A shelf label's face: the price of one pack, and the article number the till
reads.
"""
price: float
article: Ean13Article | UpcAArticle
@dataclass(frozen=True)
class Bin:
"""A bin label's face: where in the warehouse the stock stands, and the code a
picker scans on arriving there.
"""
positions: tuple[str, str, str]
code: str
# The faces the sheet prints, in reading order: the grid fills across a row before
# dropping to the next, and ``Words.names`` names them in that order.
LABELS = [
Shelf(18.90, Ean13Article("347000100013")),
Shelf(11.90, Ean13Article("347000100020")),
Shelf(2.40, Ean13Article("347000100037")),
Shelf(6.90, Ean13Article("347000100044")),
Shelf(2.45, Ean13Article("347000100051")),
Shelf(3.20, Ean13Article("347000100068")),
Shelf(8.90, Ean13Article("347000100075")),
Shelf(5.90, Ean13Article("347000100082")),
Shelf(3.90, Ean13Article("347000100099")),
Shelf(4.60, Ean13Article("347000100105")),
Shelf(1.90, Ean13Article("347000100112")),
Shelf(6.90, Ean13Article("347000100129")),
Shelf(9.90, UpcAArticle("01234567890")),
Bin(("A12", "R04", "S03"), "LOC:A12-R04-S03"),
Bin(("B07", "R11", "S02"), "LOC:B07-R11-S02"),
]
@dataclass(frozen=True)
class Words:
"""Every word the page draws, in one language.
What is not language stays out of it: the prices, the article numbers, the aisle and
rack codes and what the sheet is laid out for are drawn from constants of their own
and read the same in every language.
"""
# The words the document's title is built from, and the words set at the head of the
# sheet, over the grid.
title: str
heading: str
# Which sheet of the run this one is.
sheet_number: str
# What the labels are counted in, and what stands either side of the size of one, in
# the line under the heading.
labels: str
stock_before: str
stock_after: str
# What stands before the day the sheet was printed, and that day.
printed_label: str
printed_on: str
# What each label names, in the order ``LABELS`` prints them.
names: tuple[str, ...]
# The line under each name: what one pack holds and what it comes to by weight or by
# volume, and, on a bin label, what the code beneath it is.
subtitles: tuple[str, ...]
# What stands before each of a bin label's three position codes.
positions: tuple[str, str, str]
# The sentence at the foot of the sheet, cut where the day the prices hold until
# goes into it.
valid_before: str
valid_until: str
# The sheet in English.
ENGLISH = Words(
title="Shelf label sheet, 3 × 5 die-cut",
heading="SHELF LABELS",
sheet_number="Sheet 3 of 8",
labels="labels",
stock_before="on",
stock_after="pt die-cut stock",
printed_label="Printed",
printed_on="20 July 2026",
names=(
"Espresso beans, whole",
"Olive oil, extra virgin",
"Sea salt, coarse",
"Basmati rice",
"Dark chocolate, 70 %",
"Orange juice, pressed",
"Wildflower honey",
"Green tea, loose leaf",
"Sparkling water",
"Sunflower seed oil",
"Wholegrain pasta",
"Roasted almonds",
"Maple syrup, imported",
"Dry goods reserve",
"Chilled reserve",
),
subtitles=(
"1 kg pack · 18.90 EUR/kg",
"750 ml bottle · 15.87 EUR/l",
"1 kg pack · 2.40 EUR/kg",
"2 kg pack · 3.45 EUR/kg",
"100 g bar · 24.50 EUR/kg",
"1 l carton · 3.20 EUR/l",
"500 g jar · 17.80 EUR/kg",
"125 g tin · 47.20 EUR/kg",
"6 × 1 l · 0.65 EUR/l",
"1 l bottle · 4.60 EUR/l",
"500 g pack · 3.80 EUR/kg",
"250 g pack · 27.60 EUR/kg",
"250 ml bottle · 39.60 EUR/l",
"BIN LOCATION",
"BIN LOCATION",
),
positions=("Aisle", "Rack", "Shelf"),
valid_before="Prices valid until",
valid_until="31 August 2026",
)
# The sheet in French.
FRENCH = Words(
title="Planche d’étiquettes de rayon, prédécoupée 3 × 5",
heading="ÉTIQUETTES DE RAYON",
sheet_number="Feuille 3 sur 8",
labels="étiquettes",
stock_before="sur planche prédécoupée",
stock_after="pt",
printed_label="Imprimée le",
printed_on="20 juillet 2026",
names=(
"Café en grains, espresso",
"Huile d’olive vierge extra",
"Gros sel de mer",
"Riz basmati",
"Chocolat noir 70 %",
"Jus d’orange pressé",
"Miel toutes fleurs",
"Thé vert en vrac",
"Eau gazeuse",
"Huile de tournesol",
"Pâtes complètes",
"Amandes grillées",
"Sirop d’érable importé",
"Réserve épicerie sèche",
"Réserve froid positif",
),
subtitles=(
"sachet de 1 kg · 18.90 EUR/kg",
"bouteille de 750 ml · 15.87 EUR/l",
"sachet de 1 kg · 2.40 EUR/kg",
"sachet de 2 kg · 3.45 EUR/kg",
"tablette de 100 g · 24.50 EUR/kg",
"brique de 1 l · 3.20 EUR/l",
"pot de 500 g · 17.80 EUR/kg",
"boîte de 125 g · 47.20 EUR/kg",
"pack de 6 × 1 l · 0.65 EUR/l",
"bouteille de 1 l · 4.60 EUR/l",
"paquet de 500 g · 3.80 EUR/kg",
"sachet de 250 g · 27.60 EUR/kg",
"bouteille de 250 ml · 39.60 EUR/l",
"EMPLACEMENT",
"EMPLACEMENT",
),
positions=("Allée", "Travée", "Étagère"),
valid_before="Prix valables jusqu’au",
valid_until="31 août 2026",
)
# The sheet in Dutch.
DUTCH = Words(
title="Blad schapetiketten, 3 × 5 gestanst",
heading="SCHAPETIKETTEN",
sheet_number="Blad 3 van 8",
labels="etiketten",
stock_before="op",
stock_after="pt gestanst materiaal",
printed_label="Gedrukt",
printed_on="20 juli 2026",
names=(
"Espressobonen, heel",
"Olijfolie, extra vergine",
"Zeezout, grof",
"Basmatirijst",
"Pure chocolade, 70 %",
"Sinaasappelsap, geperst",
"Bloemenhoning",
"Groene thee, los",
"Bruiswater",
"Zonnebloemolie",
"Volkorenpasta",
"Geroosterde amandelen",
"Ahornsiroop, geïmporteerd",
"Voorraad droog",
"Voorraad gekoeld",
),
subtitles=(
"Pak van 1 kg · 18,90 EUR/kg",
"Fles van 750 ml · 15,87 EUR/l",
"Pak van 1 kg · 2,40 EUR/kg",
"Pak van 2 kg · 3,45 EUR/kg",
"Reep van 100 g · 24,50 EUR/kg",
"Pak van 1 l · 3,20 EUR/l",
"Pot van 500 g · 17,80 EUR/kg",
"Blik van 125 g · 47,20 EUR/kg",
"6 × 1 l · 0,65 EUR/l",
"Fles van 1 l · 4,60 EUR/l",
"Pak van 500 g · 3,80 EUR/kg",
"Pak van 250 g · 27,60 EUR/kg",
"Fles van 250 ml · 39,60 EUR/l",
"LOCATIE",
"LOCATIE",
),
positions=("Gang", "Stelling", "Plank"),
valid_before="Prijzen geldig tot",
valid_until="31 augustus 2026",
)
# The sheet in Portuguese.
PORTUGUESE = Words(
title="Folha de etiquetas de prateleira, cortada 3 × 5",
heading="ETIQUETAS DE PRATELEIRA",
sheet_number="Folha 3 de 8",
labels="etiquetas",
stock_before="em",
stock_after="pt de suporte pré-cortado",
printed_label="Impresso",
printed_on="20 de julho de 2026",
names=(
"Café em grão",
"Azeite virgem extra",
"Sal marinho grosso",
"Arroz basmati",
"Chocolate negro, 70 %",
"Sumo de laranja espremido",
"Mel multifloral",
"Chá verde a granel",
"Água com gás",
"Óleo de girassol",
"Massa integral",
"Amêndoas torradas",
"Xarope de ácer, importado",
"Reserva de secos",
"Reserva refrigerada",
),
subtitles=(
"Pacote de 1 kg · 18,90 EUR/kg",
"Garrafa de 750 ml · 15,87 EUR/l",
"Pacote de 1 kg · 2,40 EUR/kg",
"Pacote de 2 kg · 3,45 EUR/kg",
"Tablete de 100 g · 24,50 EUR/kg",
"Pacote de 1 l · 3,20 EUR/l",
"Frasco de 500 g · 17,80 EUR/kg",
"Lata de 125 g · 47,20 EUR/kg",
"6 × 1 l · 0,65 EUR/l",
"Garrafa de 1 l · 4,60 EUR/l",
"Pacote de 500 g · 3,80 EUR/kg",
"Pacote de 250 g · 27,60 EUR/kg",
"Garrafa de 250 ml · 39,60 EUR/l",
"LOCALIZAÇÃO",
"LOCALIZAÇÃO",
),
positions=("Corredor", "Estante", "Prateleira"),
valid_before="Preços válidos até",
valid_until="31 de agosto de 2026",
)
# The sheet in Italian.
ITALIAN = Words(
title="Foglio di etichette da scaffale, fustellato 3 × 5",
heading="ETICHETTE DA SCAFFALE",
sheet_number="Foglio 3 di 8",
labels="etichette",
stock_before="su",
stock_after="pt di supporto fustellato",
printed_label="Stampato",
printed_on="20 luglio 2026",
names=(
"Caffè in grani",
"Olio extravergine di oliva",
"Sale marino grosso",
"Riso basmati",
"Cioccolato fondente, 70 %",
"Succo d’arancia spremuto",
"Miele millefiori",
"Tè verde sfuso",
"Acqua frizzante",
"Olio di semi di girasole",
"Pasta integrale",
"Mandorle tostate",
"Sciroppo d’acero, importato",
"Scorta secchi",
"Scorta refrigerata",
),
subtitles=(
"Confezione da 1 kg · 18,90 EUR/kg",
"Bottiglia da 750 ml · 15,87 EUR/l",
"Confezione da 1 kg · 2,40 EUR/kg",
"Confezione da 2 kg · 3,45 EUR/kg",
"Tavoletta da 100 g · 24,50 EUR/kg",
"Brick da 1 l · 3,20 EUR/l",
"Vasetto da 500 g · 17,80 EUR/kg",
"Latta da 125 g · 47,20 EUR/kg",
"6 × 1 l · 0,65 EUR/l",
"Bottiglia da 1 l · 4,60 EUR/l",
"Confezione da 500 g · 3,80 EUR/kg",
"Confezione da 250 g · 27,60 EUR/kg",
"Bottiglia da 250 ml · 39,60 EUR/l",
"UBICAZIONE",
"UBICAZIONE",
),
positions=("Corsia", "Scaffale", "Ripiano"),
valid_before="Prezzi validi fino al",
valid_until="31 agosto 2026",
)
# The sheet in Spanish.
SPANISH = Words(
title="Hoja de etiquetas de estante, troquelada 3 × 5",
heading="ETIQUETAS DE ESTANTE",
sheet_number="Hoja 3 de 8",
labels="etiquetas",
stock_before="en",
stock_after="pt de soporte troquelado",
printed_label="Impreso",
printed_on="20 de julio de 2026",
names=(
"Café en grano",
"Aceite de oliva virgen extra",
"Sal marina gruesa",
"Arroz basmati",
"Chocolate negro, 70 %",
"Zumo de naranja exprimido",
"Miel de milflores",
"Té verde a granel",
"Agua con gas",
"Aceite de girasol",
"Pasta integral",
"Almendras tostadas",
"Sirope de arce, importado",
"Reserva de secos",
"Reserva refrigerada",
),
subtitles=(
"Paquete de 1 kg · 18,90 EUR/kg",
"Botella de 750 ml · 15,87 EUR/l",
"Paquete de 1 kg · 2,40 EUR/kg",
"Paquete de 2 kg · 3,45 EUR/kg",
"Tableta de 100 g · 24,50 EUR/kg",
"Brik de 1 l · 3,20 EUR/l",
"Tarro de 500 g · 17,80 EUR/kg",
"Lata de 125 g · 47,20 EUR/kg",
"6 × 1 l · 0,65 EUR/l",
"Botella de 1 l · 4,60 EUR/l",
"Paquete de 500 g · 3,80 EUR/kg",
"Paquete de 250 g · 27,60 EUR/kg",
"Botella de 250 ml · 39,60 EUR/l",
"UBICACIÓN",
"UBICACIÓN",
),
positions=("Pasillo", "Estantería", "Balda"),
valid_before="Precios válidos hasta el",
valid_until="31 de agosto de 2026",
)
# The sheet in German.
GERMAN = Words(
title="Bogen mit Regaletiketten, 3 × 5 gestanzt",
heading="REGALETIKETTEN",
sheet_number="Bogen 3 von 8",
labels="Etiketten",
stock_before="auf",
stock_after="pt Stanzmaterial",
printed_label="Gedruckt",
printed_on="20. Juli 2026",
names=(
"Espressobohnen, ganz",
"Olivenöl, nativ extra",
"Meersalz, grob",
"Basmatireis",
"Zartbitterschokolade, 70 %",
"Orangensaft, gepresst",
"Wildblütenhonig",
"Grüner Tee, lose",
"Mineralwasser mit Kohlensäure",
"Sonnenblumenöl",
"Vollkornnudeln",
"Geröstete Mandeln",
"Ahornsirup, importiert",
"Reserve Trockenwaren",
"Reserve Kühlware",
),
subtitles=(
"1-kg-Packung · 18,90 EUR/kg",
"750-ml-Flasche · 15,87 EUR/l",
"1-kg-Packung · 2,40 EUR/kg",
"2-kg-Packung · 3,45 EUR/kg",
"100-g-Tafel · 24,50 EUR/kg",
"1-l-Karton · 3,20 EUR/l",
"500-g-Glas · 17,80 EUR/kg",
"125-g-Dose · 47,20 EUR/kg",
"6 × 1 l · 0,65 EUR/l",
"1-l-Flasche · 4,60 EUR/l",
"500-g-Packung · 3,80 EUR/kg",
"250-g-Packung · 27,60 EUR/kg",
"250-ml-Flasche · 39,60 EUR/l",
"LAGERPLATZ",
"LAGERPLATZ",
),
positions=("Gang", "Regal", "Fach"),
valid_before="Preise gültig bis",
valid_until="31. August 2026",
)
# 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 sheet_left() -> float:
"""The left edge of the first column, which is what centres the grid on the page."""
return (PAGE_WIDTH - ((COLUMNS - 1) * COLUMN_PITCH + LABEL_WIDTH)) / 2.0
def sheet_right() -> float:
"""The right edge of the last column."""
return (COLUMNS - 1) * COLUMN_PITCH + sheet_left() + LABEL_WIDTH
def check_digit(digits: str) -> int:
"""The last digit of an EAN-13 or UPC-A number: the one that makes the weighted sum
of the whole a multiple of ten. Read from the right, the digit just before it weighs
three and the weights alternate from there.
"""
weighted = sum(
int(digit) * 3 if index % 2 == 0 else int(digit)
for index, digit in enumerate(reversed(digits))
if digit.isdigit()
)
return (10 - weighted % 10) % 10
def full_number(digits: str) -> str:
"""The number as it is printed under the bars: the digits given, followed by the
check digit they imply.
"""
return f"{digits}{check_digit(digits)}"
def price_text(value: float) -> str:
"""A price, as a shelf label writes it: "18.90 EUR"."""
return f"{value:.2f} EUR"
def text(
content: hqf_pdf.Content,
font: hqf_pdf.FontHandle,
size: float,
x: float,
y: float,
color: hqf_pdf.Rgb,
s: str,
) -> None:
"""Draws a line of text, left-aligned, in a colour of its own."""
content.set_fill(color)
content.draw_text(font, size, x, y, s)
def text_right(
content: hqf_pdf.Content,
font: hqf_pdf.FontHandle,
size: float,
right: float,
y: float,
color: hqf_pdf.Rgb,
s: str,
) -> None:
"""Draws a line of text whose right edge sits at ``right``."""
text(content, font, size, right - font.measure(s, size), y, color, s)
def text_centred(
content: hqf_pdf.Content,
font: hqf_pdf.FontHandle,
size: float,
centre: float,
y: float,
color: hqf_pdf.Rgb,
s: str,
) -> None:
"""Draws a line of text centred on ``centre``."""
text(content, font, size, centre - font.measure(s, size) / 2.0, y, color, s)
def line(
content: hqf_pdf.Content,
start: tuple[float, float],
end: tuple[float, float],
width: float,
color: hqf_pdf.Rgb,
) -> None:
"""A straight line between two points, in the given ink and thickness."""
content.set_stroke(color)
content.set_line_width(width)
content.move_to(start[0], start[1])
content.line_to(end[0], end[1])
content.stroke()
def cut_marks(content: hqf_pdf.Content) -> None:
"""Draws the cut marks: a tick in the margin against every column edge and every row
edge, which is what a guillotine or a die is registered against.
"""
left = sheet_left()
grid_bottom = SHEET_TOP - ((ROWS - 1) * ROW_PITCH + LABEL_HEIGHT)
grid_right = sheet_right()
ink = hqf_pdf.Rgb.gray(0.45)
for column in range(COLUMNS):
label_left = column * COLUMN_PITCH + left
for x in (label_left, label_left + LABEL_WIDTH):
above = SHEET_TOP + MARK_OFFSET
below = grid_bottom - MARK_OFFSET
line(content, (x, above), (x, above + MARK_LENGTH), 0.5, ink)
line(content, (x, below), (x, below - MARK_LENGTH), 0.5, ink)
for row in range(ROWS):
label_top = -row * ROW_PITCH + SHEET_TOP
for y in (label_top, label_top - LABEL_HEIGHT):
outer = left - MARK_OFFSET
far = grid_right + MARK_OFFSET
line(content, (outer, y), (outer - MARK_LENGTH, y), 0.5, ink)
line(content, (far, y), (far + MARK_LENGTH, y), 0.5, ink)
def outline(content: hqf_pdf.Content, x: float, bottom: float) -> None:
"""Draws one label's die-cut outline, from its lower-left corner."""
content.set_stroke(hqf_pdf.Rgb.gray(0.74))
content.set_line_width(0.4)
content.rect(x, bottom, LABEL_WIDTH, LABEL_HEIGHT)
content.stroke()
def shelf_face(
content: hqf_pdf.Content,
font: hqf_pdf.FontHandle,
x: float,
bottom: float,
subtitle: str,
shelf: Shelf,
) -> None:
"""Draws a shelf label's face: the price, the price per unit, and the article number
as bars with its digits under them.
"""
text(content, font, 7.5, x + 12.0, bottom + 92.0, MUTED, subtitle)
text(content, font, 18.0, x + 12.0, bottom + 68.0, ACCENT, price_text(shelf.price))
printed = full_number(shelf.article.digits)
if isinstance(shelf.article, Ean13Article):
code: hqf_pdf.Ean13 | hqf_pdf.UpcA = hqf_pdf.Ean13(printed)
else:
code = hqf_pdf.UpcA(printed)
# A barcode whose margin a neighbouring label eats does not scan. The bars are
# centred in the label, and what the centring leaves either side is held against the
# quiet zone the symbology asks for.
bars_width = code.module_count * MODULE
side_margin = (LABEL_WIDTH - bars_width) / 2.0
quiet = hqf_pdf.Ean13.quiet_zone() * MODULE
if side_margin < quiet:
raise ValueError("the label is narrower than the barcode and its quiet zone")
# Bars are read by the contrast between them and the paper: they are drawn in black
# whatever ink the price above them was set in.
bars_left = x + side_margin
content.set_fill(hqf_pdf.Rgb.gray(0.0))
if isinstance(code, hqf_pdf.Ean13):
content.draw_ean13(code, bars_left, bottom + 22.0, bars_width, 30.0)
else:
content.draw_upc_a(code, bars_left, bottom + 22.0, bars_width, 30.0)
text_centred(content, font, 7.5, x + LABEL_WIDTH / 2.0, bottom + 11.0, INK, printed)
def bin_face(
content: hqf_pdf.Content,
font: hqf_pdf.FontHandle,
corner: tuple[float, float],
words: Words,
subtitle: str,
bin_label: Bin,
) -> None:
"""Draws a bin label's face: the Data Matrix code a picker scans, and beside it the
aisle, rack and shelf a human reads.
"""
x, bottom = corner
text(content, font, 7.5, x + 12.0, bottom + 92.0, MUTED, subtitle)
code = hqf_pdf.DataMatrix(bin_label.code)
content.set_fill(hqf_pdf.Rgb.gray(0.0))
side = code.width * MATRIX_MODULE
quiet = hqf_pdf.DataMatrix.quiet_zone() * MATRIX_MODULE
# The quiet zone is the caller's: the code is drawn that far inside the label's left
# margin, and the placement lines start clear of it.
content.draw_data_matrix(code, x + 12.0 + quiet, bottom + 26.0, side)
lines_left = x + 12.0 + (quiet * 2.0 + side) + 12.0
for index, (label, placed) in enumerate(zip(words.positions, bin_label.positions)):
text(
content,
font,
9.0,
lines_left,
-index * 13.0 + bottom + 70.0,
INK,
f"{label} {placed}",
)
text(content, font, 7.0, x + 12.0, bottom + 11.0, INK, bin_label.code)
def header(content: hqf_pdf.Content, font: hqf_pdf.FontHandle, words: Words) -> None:
"""Draws the head of the sheet: who printed it, what stock it is laid out for, and
how many labels it carries.
"""
left = sheet_left()
right = sheet_right()
text(content, font, 13.0, left, 800.0, INK, "HQF Development")
text(
content,
font,
7.5,
left,
788.0,
MUTED,
"High Quality Foundations · contact@hqf-pdf.com",
)
text_right(content, font, 16.0, right, 799.0, ACCENT, words.heading)
text_right(
content,
font,
8.0,
right,
785.0,
MUTED,
f"{words.sheet_number} · {len(LABELS)} {words.labels} · "
f"{COLUMNS} × {ROWS} {words.stock_before} "
f"{LABEL_WIDTH:.0f} × {LABEL_HEIGHT:.0f} {words.stock_after}",
)
text_right(
content,
font,
8.0,
right,
773.0,
MUTED,
f"{words.printed_label} {words.printed_on}",
)
line(content, (left, 762.0), (right, 762.0), 1.0, ACCENT)
def footer(content: hqf_pdf.Content, font: hqf_pdf.FontHandle, words: Words) -> None:
"""Draws the foot of the sheet: how long the prices hold, and who to send the sheet
back to.
"""
left = sheet_left()
right = sheet_right()
line(content, (left, 78.0), (right, 78.0), 0.5, hqf_pdf.Rgb.gray(0.8))
mentions = (
f"{words.valid_before} {words.valid_until} · HQF Development · "
"42 lot les Genêts, 13480 Calas, France · contact@hqf-pdf.com"
)
text_centred(content, font, 7.0, (left + right) / 2.0, 66.0, MUTED, mentions)
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("labels.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()
header(content, font, words)
cut_marks(content)
# The grid fills across a row before dropping to the next, so a label's place on the
# page follows from its place in the list and the two pitches.
left = sheet_left()
for index, face in enumerate(LABELS):
x = index % COLUMNS * COLUMN_PITCH + left
bottom = -(index // COLUMNS) * ROW_PITCH + SHEET_TOP - LABEL_HEIGHT
subtitle = words.subtitles[index]
outline(content, x, bottom)
text(content, font, 10.5, x + 12.0, bottom + 104.0, INK, words.names[index])
if isinstance(face, Shelf):
shelf_face(content, font, x, bottom, subtitle, face)
else:
bin_face(content, font, (x, bottom), words, subtitle, face)
footer(content, font, words)
page = hqf_pdf.Page(PAGE_WIDTH, PAGE_HEIGHT)
page.set_content(content)
document.add_page(page)
written = document.write(out)
print(f"wrote {out}: {written} bytes, {len(LABELS)} labels")
if __name__ == "__main__":
main()
|