Skip to content

Commit 6814ce9

Browse files
DocAtPromptclaude
andcommitted
calendar: every word on the sheet comes from the definition (§ 7.12, § 7.8)
§ 7.8 settles the principle and § 7.12 applied it to half the words: month and weekday names come from the definition, because the labels belong to the language of the sheet and the tool ships no translation table (§ 3.4). Its own vocabulary stayed English, so a German calendar came out mixed — read back off the actual PDF: Seite 1: Index Year Month Notes Contents Full-year overview Half-year 1 · Jän–Jun Half-year 2 · Jul–Dez Jänner `Jänner` under an English navigation strip. Those are the tool's words on the user's sheet, which is exactly what § 7.8 rules out — the same argument that keeps a built-in "Yes/No" out of a German phone log. A `words:` block now names them: the nav strip (index, year, month, week, notes), `contents`, `full_year_overview`, `half_year` and `full_year`, plus the default label of an unnamed notes pad. Every default is the English the tool has always printed, so no existing definition moves and the whole gallery is byte-identical. The notebook's one word, the back-link on a divider, follows its `contents_title` instead of a literal. Same page, same definition, with the block filled in: Seite 1: Inhalt Jahr Monat Notizen Inhaltsverzeichnis Jahresübersicht Halbjahr 1 · Jän–Jun Halbjahr 2 · Jul–Dez This is not a locale system and there is still none to ship: the tool receives words, it does not know any. The handbook gains a section saying so, together with the limit that goes with it — the standard fonts reach Latin-1, so Polish, Czech, Hungarian, Turkish and Romanian need `font: {file: …}`. Since the commit before this one, a character the font cannot draw is refused by name rather than printed as a box. 1228 tests green, ruff clean, every committed example byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent cc33ad7 commit 6814ce9

6 files changed

Lines changed: 177 additions & 23 deletions

File tree

‎HANDBOOK.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,45 @@ Preset: `calendar-a4` (`ctrlgrid show calendar-a4`), which turns on every view;
11081108
worked example: [`12-calendar-year.yaml`](examples/12-calendar-year.yaml) — 405
11091109
pages, and the one example that ships without its PDF.
11101110

1111+
#### The language of the sheet
1112+
1113+
The tool ships no translations and never will (§3.4) — but it writes no word of
1114+
its own on your sheet either. Everything printed comes from the definition:
1115+
1116+
```yaml
1117+
months: [Jänner, Februar, März, April, Mai, Juni,
1118+
Juli, August, September, Oktober, November, Dezember]
1119+
weekdays: [Mo, Di, Mi, Do, Fr, Sa, So]
1120+
notes: [{count: 40, label: Notizen}]
1121+
words:
1122+
index: Inhalt
1123+
year: Jahr
1124+
month: Monat
1125+
week: Woche
1126+
notes: Notizen
1127+
contents: Inhaltsverzeichnis
1128+
full_year_overview: Jahresübersicht
1129+
half_year: Halbjahr
1130+
full_year: ganzes Jahr
1131+
```
1132+
1133+
`words:` names the calendar's own vocabulary — the navigation strip at the top of
1134+
every page and the headings. Leave it out and you get the English above, so no
1135+
existing definition changes. A notebook's one word is `contents_title`.
1136+
1137+
**One limit to know about**, and it is the same one the README lists first: the
1138+
standard PDF fonts reach Latin-1. German, French, Spanish, Italian, Portuguese,
1139+
Dutch and the Nordic languages are covered. Polish, Czech, Hungarian, Turkish,
1140+
Romanian and Croatian are not — `ń`, `ř`, `ő`, `ğ`, `ă`, `č` need a font of your
1141+
own:
1142+
1143+
```yaml
1144+
font: {file: "~/Library/Fonts/NotoSans-Regular.ttf", size: 9pt}
1145+
```
1146+
1147+
A character the font cannot draw is refused before the first page, naming the
1148+
character and the word it came from — it is never printed as a box.
1149+
11111150
### `notebook` — several papers in one linked document
11121151

11131152
The other document generator, and the one that composes the rest: sections, each

‎ctrlgrid/generators/calendar.py‎

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,14 @@ def _inline_date(item) -> datetime.date | None:
5757
return None
5858

5959

60-
def notes_name(pads, index: int) -> str:
60+
def notes_name(pads, index: int, word: str = "Notes") -> str:
6161
"""What a pad is called (§ 7). Its own label, else `Notes` when it is the
6262
only one and a numbered `Notes n` when it is not — two pads must never
6363
answer to the same name in the contents."""
6464
label = pads[index].label
6565
if label:
6666
return label
67-
return "Notes" if len(pads) == 1 else f"Notes {index + 1}"
67+
return word if len(pads) == 1 else f"{word} {index + 1}"
6868

6969

7070
def notes_index_dest(pad: int, page_no: int = 1) -> str:
@@ -241,13 +241,39 @@ class NotesSpec(Section):
241241
label: str | None = None
242242

243243

244+
class Words(Section):
245+
"""The calendar's own words on the sheet (§ 7.12, § 7.8).
246+
247+
§ 7.12 already took month and weekday names from the definition, on § 7.8's
248+
rule: the labels belong to the language of the sheet, so the tool receives
249+
them and never invents them. The rest of its vocabulary — the navigation
250+
strip, the headings — stayed English, so a German calendar came out with
251+
`Jänner` under `Index Year Month Notes`.
252+
253+
These are the same rule applied to the same words. The defaults are the
254+
English the tool has always printed, so an existing definition is unmoved;
255+
this is not a locale table and there is none to ship (§ 3.4).
256+
"""
257+
258+
index: str = "Index"
259+
year: str = "Year"
260+
month: str = "Month"
261+
week: str = "Week"
262+
notes: str = "Notes"
263+
contents: str = "Contents"
264+
full_year_overview: str = "Full-year overview"
265+
half_year: str = "Half-year"
266+
full_year: str = "full year"
267+
268+
244269
class CalendarConfig(BaseModel):
245270
"""The definition section for the calendar (§ 3.6, seam 2)."""
246271

247272
model_config = ConfigDict(extra="forbid", frozen=True)
248273

249274
year: int = Field(ge=1, le=9999)
250275
week_start: Literal["monday", "sunday"] = "monday"
276+
words: Words = Words()
251277
months: tuple[str, ...] | None = None
252278
weekdays: tuple[str, ...] | None = None
253279
holidays: tuple[Holiday, ...] = ()
@@ -446,7 +472,8 @@ def describe(self, cfg: CalendarConfig) -> list[str]:
446472
lines.append(f"{len(cfg.holidays)} holidays")
447473
for index, pad in enumerate(cfg.notes):
448474
lines.append(
449-
f"{pad.count} {notes_name(cfg.notes, index)!r} pages, {pad.surface}"
475+
f"{pad.count} {notes_name(cfg.notes, index, cfg.words.notes)!r} pages, "
476+
f"{pad.surface}"
450477
)
451478
return lines
452479

@@ -544,7 +571,7 @@ def _notes_index_toc(self, cfg, layout, area) -> list[tuple[str, str]]:
544571
index pages already lead to each other.
545572
"""
546573
return [
547-
(notes_name(cfg.notes, index), notes_index_dest(index))
574+
(notes_name(cfg.notes, index, cfg.words.notes), notes_index_dest(index))
548575
for index in range(len(cfg.notes))
549576
]
550577

@@ -571,7 +598,7 @@ def make() -> layout.Page:
571598
def nav(month: int = 1, week_no: int = 1) -> layout.Nav:
572599
return layout.Nav(
573600
month=f"month-{month:02d}", week=f"week-{week_no:02d}",
574-
has_week=has_week, has_notes=has_notes,
601+
has_week=has_week, has_notes=has_notes, words=cfg.words,
575602
)
576603

577604
def wk(date: datetime.date) -> int:
@@ -618,7 +645,8 @@ def _notes_pages(self, cfg, layout, page, nav) -> Iterator[DocumentPage]:
618645
# Every pad's index links to every other one, so the pads are reachable
619646
# from each other and not only through the contents (§ 7).
620647
others = [
621-
(notes_name(cfg.notes, i), notes_index_dest(i)) for i in range(len(cfg.notes))
648+
(notes_name(cfg.notes, i, cfg.words.notes), notes_index_dest(i))
649+
for i in range(len(cfg.notes))
622650
]
623651
for pad_no, pad in enumerate(cfg.notes):
624652
count = pad.count
@@ -628,15 +656,15 @@ def _notes_pages(self, cfg, layout, page, nav) -> Iterator[DocumentPage]:
628656
for page_no, numbers in enumerate(chunks, start=1):
629657
yield layout.notes_index_page(
630658
page(), cfg, nav, pad=pad, pad_no=pad_no,
631-
name=notes_name(cfg.notes, pad_no), others=others,
659+
name=notes_name(cfg.notes, pad_no, cfg.words.notes), others=others,
632660
page_no=page_no, page_count=len(chunks), numbers=numbers,
633661
prev=notes_index_dest(pad_no, page_no - 1) if page_no > 1 else None,
634662
nxt=notes_index_dest(pad_no, page_no + 1) if page_no < len(chunks) else None,
635663
)
636664
for i in range(1, count + 1):
637665
yield layout.note_page(
638666
page(), cfg, nav, pad=pad, pad_no=pad_no,
639-
name=notes_name(cfg.notes, pad_no), num=i,
667+
name=notes_name(cfg.notes, pad_no, cfg.words.notes), num=i,
640668
prev=note_dest(pad_no, i - 1, count) if i > 1 else None,
641669
nxt=note_dest(pad_no, i + 1, count) if i < count else None,
642670
)

‎ctrlgrid/generators/calendar_layout.py‎

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ class Nav:
104104
week: str
105105
has_week: bool = False
106106
has_notes: bool = False
107+
words: object | None = None
108+
"""The calendar's own vocabulary (§ 7.12). `None` keeps the English the
109+
tool has always printed, which is what a caller without a config wants."""
110+
111+
@property
112+
def w(self):
113+
from ctrlgrid.generators.calendar import Words
114+
115+
return self.words or Words()
107116

108117

109118
def nav(page: Page, n: Nav) -> None:
@@ -115,11 +124,12 @@ def nav(page: Page, n: Nav) -> None:
115124
title owns the left, and the two no longer compete for the same corner.
116125
"""
117126
size = pt(9)
118-
entries = [("Index", "index"), ("Year", "year"), ("Month", n.month)]
127+
words = n.w
128+
entries = [(words.index, "index"), (words.year, "year"), (words.month, n.month)]
119129
if n.has_week:
120-
entries.append(("Week", n.week))
130+
entries.append((words.week, n.week))
121131
if n.has_notes:
122-
entries.append(("Notes", "notes-1"))
132+
entries.append((words.notes, "notes-1"))
123133
gap = pt(14)
124134
widths = [page.q.text_width(label, family="sans", size=size) for label, _ in entries]
125135
# Ranged against the right edge: on the left it shared the corner with the
@@ -216,9 +226,9 @@ def contents_page(page: Page, cfg, n: Nav, months, notes_index) -> DocumentPage:
216226
nav(page, n)
217227
groups = [
218228
[
219-
("Full-year overview", "year"),
220-
(f"Half-year 1 · {months[0][:3]}–{months[5][:3]}", "half-1"),
221-
(f"Half-year 2 · {months[6][:3]}–{months[11][:3]}", "half-2"),
229+
(cfg.words.full_year_overview, "year"),
230+
(f"{cfg.words.half_year} 1 · {months[0][:3]}–{months[5][:3]}", "half-1"),
231+
(f"{cfg.words.half_year} 2 · {months[6][:3]}–{months[11][:3]}", "half-2"),
222232
],
223233
[(months[i], f"month-{i + 1:02d}") for i in range(12)],
224234
list(notes_index),
@@ -248,7 +258,7 @@ def contents_page(page: Page, cfg, n: Nav, months, notes_index) -> DocumentPage:
248258
block = contents_height(entries, len(groups) + (1 if legend else 0))
249259
top = CONTENTS_NAV + max(0, round((page.H - CONTENTS_NAV - block) / 2))
250260

251-
page.text(round(page.W / 2), top, "Contents", pt(20), INK, "center")
261+
page.text(round(page.W / 2), top, cfg.words.contents, pt(20), INK, "center")
252262
top += _CONTENTS_TITLE_GAP
253263
for index, group in enumerate(groups):
254264
if index:
@@ -266,7 +276,7 @@ def contents_page(page: Page, cfg, n: Nav, months, notes_index) -> DocumentPage:
266276
0.2, GUIDE, entry.color)
267277
page.text(left + _KEY_PATCH + _KEY_GAP, top, entry.label, size, INK)
268278
top += _CONTENTS_LINE
269-
return page.done("index", "index", "Contents")
279+
return page.done("index", "index", cfg.words.contents)
270280

271281

272282
def year_overview_page(page: Page, cfg, n: Nav, months, weekdays) -> DocumentPage:
@@ -278,7 +288,7 @@ def year_overview_page(page: Page, cfg, n: Nav, months, weekdays) -> DocumentPag
278288
zooms (§ 8.2)."""
279289
nav(page, n)
280290
top = pt(16)
281-
page.text(0, top, f"{cfg.year} · full year", pt(15), INK)
291+
page.text(0, top, f"{cfg.year} · {cfg.words.full_year}", pt(15), INK)
282292
top += pt(22)
283293
cols, rows = 3, 4
284294
gutter = _YEAR_GUTTER
@@ -290,7 +300,7 @@ def year_overview_page(page: Page, cfg, n: Nav, months, weekdays) -> DocumentPag
290300
_mini_month(page, cfg, months, weekdays, m + 1,
291301
c * (cell_w + gutter), top + r * (cell_h + gutter), cell_w, cell_h,
292302
n.has_week, marked)
293-
return page.done("year", "year", f"{cfg.year} full year")
303+
return page.done("year", "year", f"{cfg.year} {cfg.words.full_year}")
294304

295305

296306
#: The air between two day columns. The mini-month is built from its content
@@ -417,9 +427,10 @@ def half_year_page(page: Page, cfg, n: Nav, months, half: int) -> DocumentPage:
417427
span = f"{months[first_month - 1][:3]}–{months[first_month + 4][:3]}"
418428
prev = "half-1" if half == 2 else None
419429
nxt = "half-2" if half == 1 else None
420-
top = crumb(page, title=f"Half-year {half} · {span}", prev=prev, nxt=nxt, up="year")
430+
title = f"{cfg.words.half_year} {half} · {span}"
431+
top = crumb(page, title=title, prev=prev, nxt=nxt, up="year")
421432
_half_table(page, cfg, months, top, page.H - top - pt(2), first_month=first_month)
422-
return page.done(f"half-{half}", "half", f"Half-year {half}")
433+
return page.done(f"half-{half}", "half", f"{cfg.words.half_year} {half}")
423434

424435

425436
def _half_table(page: Page, cfg, months, top, h, *, first_month: int) -> None:

‎ctrlgrid/generators/notebook.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ def pages(
209209
if section.divider:
210210
yield layout.divider_page(
211211
name, section, area, dest=_section_dest(index), q=q,
212-
placeholders=placeholders,
212+
placeholders=placeholders, contents_title=cfg.contents_title,
213213
)
214214
for number in range(1, section.pages + 1):
215215
dest = (

‎ctrlgrid/generators/notebook_layout.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ def contents_page(cfg, starts: list[int], area: Area, *, q: WriterQuery) -> Docu
9595

9696

9797
def divider_page(
98-
name: str, section, area: Area, *, dest: str, q: WriterQuery, placeholders
98+
name: str, section, area: Area, *, dest: str, q: WriterQuery, placeholders,
99+
contents_title: str = "Contents",
99100
) -> DocumentPage:
100101
"""A sheet before a section: its name, a rule to write a subtitle under, and
101102
the way back to the contents."""
@@ -106,7 +107,7 @@ def divider_page(
106107
page.hline(0, area.width, top + pt(26) + pt(52), 0.25, GUIDE)
107108
# The way back, at the top right — where the calendar's nav strip sits, so
108109
# the two documents are read the same way.
109-
back = "Contents"
110+
back = contents_title
110111
width = q.text_width(back, family="sans", size=pt(10))
111112
page.link_text(area.width - width, TOP_MARGIN, back, CONTENTS_DEST, pt(10))
112113
return DocumentPage(

‎tests/test_calendar.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,3 +816,78 @@ def test_unlabelled_pads_are_still_told_apart(self) -> None:
816816
def test_no_link_dangles_across_the_pads(self) -> None:
817817
_pages, dests_, edges = _graph(cfg(notes=self.PADS))
818818
assert sorted(t for _s, t in edges if t not in dests_) == []
819+
820+
821+
class TestEveryWordOnThePageComesFromTheDefinition:
822+
"""§ 7.12 takes month and weekday names from the definition and ships no
823+
translation table (§ 3.4, § 7.8). It did that for the *names* and not for the
824+
rest, so a German calendar came out mixed: `Jänner` under a nav strip reading
825+
`Index Year Month Notes`, beside `Contents`, `Full-year overview` and
826+
`Half-year 1 · Jän–Jun`.
827+
828+
Those are the tool's words on the user's sheet, which is the thing § 7.8
829+
rules out. They are configurable now, with today's English as the default so
830+
no existing definition moves.
831+
"""
832+
833+
GERMAN = (
834+
"version: 1\n"
835+
"page: {format: a4, margin: 12mm}\n"
836+
"generator: calendar\n"
837+
"year: 2027\n"
838+
"months: [Jänner, Februar, März, April, Mai, Juni, Juli, August,\n"
839+
" September, Oktober, November, Dezember]\n"
840+
"weekdays: [Mo, Di, Mi, Do, Fr, Sa, So]\n"
841+
"notes: [{count: 2, label: Notizen}]\n"
842+
"words:\n"
843+
" index: Inhalt\n"
844+
" year: Jahr\n"
845+
" month: Monat\n"
846+
" notes: Notizen\n"
847+
" contents: Inhaltsverzeichnis\n"
848+
" full_year_overview: Jahresübersicht\n"
849+
" half_year: Halbjahr\n"
850+
" full_year: ganzes Jahr\n"
851+
)
852+
853+
def test_no_english_word_survives_on_the_first_pages(self, tmp_path: Path) -> None:
854+
from pdfread import text_on
855+
856+
from ctrlgrid.loader import loads
857+
from ctrlgrid.pages import build
858+
from ctrlgrid.writers.pdf import PdfWriter
859+
860+
path = tmp_path / "de.pdf"
861+
build(loads(self.GERMAN, source="test"), PdfWriter(path))
862+
pages = " ".join(text_on(path, page) for page in range(4))
863+
for english in ("Index", "Year", "Month", "Notes", "Contents",
864+
"Full-year", "Half-year", "full year"):
865+
assert english not in pages, english
866+
867+
def test_and_the_german_words_are_there_instead(self, tmp_path: Path) -> None:
868+
from pdfread import text_on
869+
870+
from ctrlgrid.loader import loads
871+
from ctrlgrid.pages import build
872+
from ctrlgrid.writers.pdf import PdfWriter
873+
874+
path = tmp_path / "de2.pdf"
875+
build(loads(self.GERMAN, source="test"), PdfWriter(path))
876+
pages = " ".join(text_on(path, page) for page in range(4))
877+
for german in ("Inhalt", "Jahr", "Monat", "Inhaltsverzeichnis",
878+
"Jahresübersicht", "Halbjahr", "ganzes Jahr"):
879+
assert german in pages, german
880+
881+
def test_the_english_default_is_unchanged(self, tmp_path: Path) -> None:
882+
# No `words:` block at all: every existing definition must be untouched.
883+
from pdfread import text_on
884+
885+
from ctrlgrid.loader import loads
886+
from ctrlgrid.pages import build
887+
from ctrlgrid.writers.pdf import PdfWriter
888+
889+
text = self.GERMAN.split("words:")[0]
890+
path = tmp_path / "en.pdf"
891+
build(loads(text, source="test"), PdfWriter(path))
892+
pages = " ".join(text_on(path, page) for page in range(4))
893+
assert "Contents" in pages and "Full-year overview" in pages

0 commit comments

Comments
 (0)