A sheet of shelf labels

A sheet of die-cut shelf labels, priced and barcoded, with cut marks in the margins.

Python write_labels.py 564 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
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
"""Draws 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",
)


# 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}


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.size * 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_datamatrix(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 · www.hqf.fr"
    )

    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 · www.hqf.fr"
    )
    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()