From e02a3dfb29c7e78e4869c09cb45b3909752d88e7 Mon Sep 17 00:00:00 2001 From: 084intheImpala Date: Wed, 30 Sep 2026 14:57:57 +0100 Subject: [PATCH 1/7] bug fix --- modules/naboj/builder/venue.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/naboj/builder/venue.py b/modules/naboj/builder/venue.py index 730d2059..e739dd34 100644 --- a/modules/naboj/builder/venue.py +++ b/modules/naboj/builder/venue.py @@ -33,12 +33,15 @@ def language_path(self) -> tuple: def build_templates(self, *, new_name: str | None = None) -> None: super().build_templates(new_name=new_name) - language_renderer = jinja.StaticRenderer(Path('/home/kvik/dgs/source/naboj') / Path(*self.language_path())) + language_root = Path(self.launch_directory, 'source', 'naboj', *self.language_path()) + language_renderer = jinja.StaticRenderer(language_root) for template in self.language_templates: - outfile = open(self.output_directory / Path(template).with_suffix('.tex'), 'w') - infile = Path('source/naboj') / Path(*self.language_path()) / template - language_renderer.render(infile, self.context.data, outfile=outfile) + if not (language_root / template).exists(): + continue + rendered = language_renderer.render(Path(template), self.context.data) + with open(self.output_directory / Path(template).with_suffix('.tex'), 'w') as outfile: + print(rendered, file=outfile) BuilderNabojVenue().build_templates() From f52aa4444185de86dd5070929d2e409b22cccec4 Mon Sep 17 00:00:00 2001 From: 084intheImpala Date: Wed, 7 Oct 2026 19:09:51 +0100 Subject: [PATCH 2/7] fixing formatting issues --- core/i18n/__init__.py | 1 + core/i18n/pt.yaml | 4 ++++ core/templates/override.jtex | 3 +++ modules/naboj/builder/venue.py | 11 +++++++---- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/core/i18n/__init__.py b/core/i18n/__init__.py index c7a99b9f..cbf96e80 100644 --- a/core/i18n/__init__.py +++ b/core/i18n/__init__.py @@ -90,6 +90,7 @@ class Locale: # at explicit hyphens in compound words like segunda-feira. Optional('latex'): { Optional('exhyphenpenalty'): int, + Optional('nonfrenchspacing'): bool, }, 'siunitx': { 'list_pair_separator': str, diff --git a/core/i18n/pt.yaml b/core/i18n/pt.yaml index c9a65123..37479062 100644 --- a/core/i18n/pt.yaml +++ b/core/i18n/pt.yaml @@ -15,6 +15,7 @@ latex: # separately: `\rephyphen`'s discretionary carries an empty pre-break list precisely so that # only this parameter decides it. exhyphenpenalty: 50 + nonfrenchspacing: true typography: # A word breaking at its hyphen repeats it on the next line -- `anti-` then `-inflamatório`. # Portuguese needs this more than the other two, because enclitic pronouns put a hyphen inside @@ -43,6 +44,9 @@ siunitx: output_decimal_marker: ',' list_pair_separator: 'e' list_final_separator: 'e' + units: + litre: 'L' + liter: 'L' # Words inside maths. These are what the sources already use, or elementary # enough not to be a judgement call. **Nothing falls back to English**: a word this file has not diff --git a/core/templates/override.jtex b/core/templates/override.jtex index 680884f8..56a833cf 100644 --- a/core/templates/override.jtex +++ b/core/templates/override.jtex @@ -2,6 +2,9 @@ (@ if i18n.latex is defined and i18n.latex.exhyphenpenalty is defined @) \exhyphenpenalty=(* i18n.latex.exhyphenpenalty *) (@ endif @) +(@ if i18n.latex is defined and i18n.latex.nonfrenchspacing @) +\nonfrenchspacing +(@ endif @) \setquotestyle[(* i18n.quotes.extra|default('') *)]{(* i18n.quotes.babel_id *)} \sisetup{ diff --git a/modules/naboj/builder/venue.py b/modules/naboj/builder/venue.py index 730d2059..39e392e5 100644 --- a/modules/naboj/builder/venue.py +++ b/modules/naboj/builder/venue.py @@ -33,12 +33,15 @@ def language_path(self) -> tuple: def build_templates(self, *, new_name: str | None = None) -> None: super().build_templates(new_name=new_name) - language_renderer = jinja.StaticRenderer(Path('/home/kvik/dgs/source/naboj') / Path(*self.language_path())) + language_root = Path(self.launch_directory, *self.language_path()) + language_renderer = jinja.StaticRenderer(language_root) for template in self.language_templates: - outfile = open(self.output_directory / Path(template).with_suffix('.tex'), 'w') - infile = Path('source/naboj') / Path(*self.language_path()) / template - language_renderer.render(infile, self.context.data, outfile=outfile) + if not (language_root / template).exists(): + continue + rendered = language_renderer.render(Path(template), self.context.data) + with open(self.output_directory / Path(template).with_suffix('.tex'), 'w') as outfile: + print(rendered, file=outfile) BuilderNabojVenue().build_templates() From 67c3963a55c6625a81c63021be879849a3f31644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Thu, 8 Oct 2026 10:18:12 +0000 Subject: [PATCH 3/7] =?UTF-8?q?`pictures:`=20=E2=80=94=20a=20figure=20decl?= =?UTF-8?q?ared=20once,=20with=20the=20key=20as=20its=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism, with nothing converted yet. A figure's file and size move into the meta and the call site keeps only the caption, which is prose and translated, and the position, which a translator may move: pictures: hanging: file: hanging.png height: 40mm (§ pictures.hanging §) (§ pictures.hanging('Lano') §) (§ pictures.hanging(height='55mm') §) -- hu needs the room, visibly **The key is the label**, which is the whole bargain and is worth more here than on the equation side: 440 of the repository's 869 hand-written figure labels do not follow `fig::`, one does not even match its own problem id, and a wrong figure label is silent until a crossref resolves to nothing. **Whether a figure is captioned is the file's decision**, declared by the module in both directions as `equation_numbering` is, because a caption is what makes pandoc emit a `figure` at all -- and so what gives the drawing a number and a `\label`. Those are one decision, not two. A statement never captions its figures, and nothing is lost: of the 169 figure labels written into statements, not one is referenced from anywhere. A solution always does, empty caption included, which is what stops a figure being referenceable in one language and not another -- `18/firework-bee/en` prints `??` today for exactly that reason. Three things the end-to-end run turned up that reading could not: - **`![]` and `![ ]` do not make a figure.** Pandoc needs the alt text to have content, so both come out as a bare `\includegraphics` with no number and no label. `![]` is a comment to the parser and nothing on the page, and is what an empty caption is written as. - **`convertor.py` deleted `\caption{}` and its `\label` with it.** That rule put the figure straight back to unnumbered, which is the defect this exists to end, so it is gone -- inert for everything that exists, since no source writes an empty alt and nothing under `build/` carried a `\caption{}`. - **The tag is block material and has to own its line.** `(§ pictures.x §)` mid sentence makes an *inline* image, losing the figure, its caption and its number with no error anywhere, so `RE_DISPLAY_TAG` now matches it and `settle_display_tags` splits the line as it does for `|disp`. `label_prefix` is a module hook rather than `meta_path.parent.name` outright, against seminar, whose problems are numbered `01`, `02` per round: its labels are hand-written in four incompatible spellings today -- 228 of 601 off the house rule -- with seven names defined in more than one problem directory. naboj takes the default and nothing moves. 11 new tests, both halves each. Also fixes `test_the_version_follows_the_files`, which assumed `app.js` was the newest of the three cache-busted assets and so failed on any tree where `highlight.js` had been touched more recently. Co-Authored-By: Claude Opus 5 (1M context) --- core/audit/checks.py | 3 +- core/builder/context/context.py | 2 +- core/builder/context/quantities/picture.py | 95 ++++++++++++++++ core/builder/convertor.py | 12 +- core/builder/reference.py | 34 ++++++ core/builder/renderer.py | 89 ++++++++++++++- core/tests/test_renderer.py | 125 +++++++++++++++++++++ docs/filters.md | 36 ++++++ modules/naboj/builder/renderer.py | 22 ++++ tools/editor/app.py | 3 +- tools/editor/static/highlight.js | 3 +- 11 files changed, 413 insertions(+), 11 deletions(-) create mode 100644 core/builder/context/quantities/picture.py diff --git a/core/audit/checks.py b/core/audit/checks.py index 61bb5735..68c50d5c 100644 --- a/core/audit/checks.py +++ b/core/audit/checks.py @@ -342,7 +342,8 @@ def tag_unknown(sources): #: Markdown wants a blank line before each of these however the sentence is going, so a break #: after a comma is correct here and must not be reported. -RE_NEEDS_OWN_PARAGRAPH = re.compile(r'^[ \t]*(?:[-*+][ \t]|\d+[.)][ \t]|!\[|#|\$\$|\(§\s*eq\.)') +RE_NEEDS_OWN_PARAGRAPH = re.compile( + r'^[ \t]*(?:[-*+][ \t]|\d+[.)][ \t]|!\[|#|\$\$|\(§\s*(?:eq|pictures)\.)') #: A line that is nothing but `\end{…}`. `display_blocks` steps over these to find the stop. diff --git a/core/builder/context/context.py b/core/builder/context/context.py index 19303933..3f0d0d44 100644 --- a/core/builder/context/context.py +++ b/core/builder/context/context.py @@ -25,7 +25,7 @@ #: `w` was reserved here for one commit and broke eight problems that use it for width, work or #: weight -- `22/seychelles`, `24/counting-stars`, `chem/04/maliari` among them. A single letter is #: the worst possible choice of reserved name in a physics repository. -RESERVED_NAMES = frozenset({'blocks', 'const', 'eq', 'i18n', 'words'}) +RESERVED_NAMES = frozenset({'blocks', 'const', 'eq', 'i18n', 'pictures', 'words'}) class DuplicateKeyError(Exception): diff --git a/core/builder/context/quantities/picture.py b/core/builder/context/quantities/picture.py new file mode 100644 index 00000000..09f49cdf --- /dev/null +++ b/core/builder/context/quantities/picture.py @@ -0,0 +1,95 @@ +class Picture: + r""" + A figure declared once in `pictures:` and included as `(§ pictures. §)`. + + The three parts of a Markdown include that are facts about the *drawing* -- which file, how + big, and what it is called -- live in the meta, so they are written once instead of once per + language. What stays at the call site is the caption, which is prose and must be translated, + and the position, which a translator may legitimately move. + + This is the figure half of what `MathObject` does for an equation, and the argument is the + same one: **the key is the label**. Nobody types `{#fig::}`, so nobody can misspell + it -- 440 of the repository's 869 hand-written figure labels did not follow the convention, + against about 1 % on the equation side, and a wrong figure label is silent until a crossref + resolves to nothing. + """ + def __init__(self, + ident: str, + file: str, + *, + captioned: bool = True, + caption: str | None = None, + attributes: dict | None = None): + self.id = ident + self.file = file + #: Whether this file's figures carry a caption, and therefore a number and a label. + #: + #: Set by the renderer from the file being rendered against the module's own + #: `figure_captions`, never by the author at the call site -- the same decision, made in + #: the same place and for the same reason, as `MathObject.labelled`. + #: + #: **A problem statement never captions its figures**, so they are never numbered. The + #: number would be a cross-reference a contestant cannot follow, and the measurement says + #: nobody wanted one anyway: 169 distinct labels are defined in statements across the + #: repository and not one of them is referenced from anywhere. + #: + #: **A solution always does**, even where the caption is empty, because that is what makes + #: the figure numbered -- and a figure that is numbered in one language and not in another + #: is how `18/firework-bee` came to print `??` in the English booklet alone. + self.captioned = captioned + self.caption = caption + self.attributes = dict(attributes or {}) + + #: What goes between `![` and `]` when a solution's figure has no caption of its own. + #: + #: Not the empty string and not a space: pandoc makes a `figure` environment only when the alt + #: text has content, so `![]` and `![ ]` both come out as a bare `\includegraphics` with no + #: number and no `\label` -- which is exactly the state this rule exists to end. An HTML + #: comment is content to the parser and nothing to the page, so the figure is numbered and the + #: caption prints empty. + EMPTY_CAPTION = '' + + def __call__(self, caption: str | None = None, **attributes) -> 'Picture': + """ + The same picture with a caption, or with an attribute overridden at this call site. + + An override is meant to be rare and visible. The size lives in the meta because 166 of the + 386 multilingual figures in the repository disagree about it by accident -- the same + drawing at 14 mm, 35 mm and 55 mm in three booklets -- but it cannot be forced, because a + tear-off has a fixed height and translations do not have equal line counts. So the meta + carries what the languages agree on and `(§ pictures.x(height='55mm') §)` says, in one + place a reader can see, that this one is different on purpose. + """ + return Picture(self.id, self.file, + captioned=self.captioned, + caption=self.caption if caption is None else caption, + attributes=self.attributes | attributes) + + @property + def _label(self) -> str: + """ + The label, where the figure is captioned, and nothing where it is not. + + Nothing rather than a label is not a simplification. An uncaptioned image is not a + `figure` at all, so pandoc emits no `\\label` for it whatever the attribute block says -- + 553 of the repository's hand-written labels sit on uncaptioned images and reach the page + as nothing. Emitting one here would be writing markup that is known to be discarded. + """ + return f"#fig:{self.id}" if self.captioned else "" + + def _attribute_text(self) -> str: + return ' '.join(f"{key}={value}" for key, value in self.attributes.items()) + + def __str__(self) -> str: + """The Markdown include, which the second render pass expands any tags inside.""" + alt = '' + if self.captioned: + alt = self.caption if self.caption else self.EMPTY_CAPTION + attributes = ' '.join(part for part in (self._label, self._attribute_text()) if part) + # The attribute block is never omitted even when it is empty: `convertor.py` refuses an + # image that reaches LaTeX without one -- `RegexFailure(r'\\pandocbounded')` -- because an + # unsized figure is set at its natural size and walks off the page. + return f"![{alt}]({self.file}){{{attributes}}}" + + def __repr__(self) -> str: + return repr(self.__str__()) diff --git a/core/builder/convertor.py b/core/builder/convertor.py index 6112537f..166e2732 100644 --- a/core/builder/convertor.py +++ b/core/builder/convertor.py @@ -40,8 +40,16 @@ class Convertor: RegexReplacement(r"\\includegraphics(?P\[.*\])?{(?P.*)\.(?Ppng|jpg|pdf)}", r"\\insertPicture\g{\g.\g}", purpose=r"Change \includegraphics to protected \insertPicture"), - # Remove empty labels and captions - RegexReplacement(r"^\\caption{}(\\label{.*})?\n", "", purpose="Remove empty captions and labels"), + # An empty caption used to be deleted here, and its `\label` with it. That is the + # one thing it must not do now: **a caption is what makes pandoc emit a `figure`**, + # and so what gives a drawing its number and its label, which is why + # `figure_captions` gives every solution figure one whether the author wrote text or + # not. Stripping it put the figure straight back to where it started -- unnumbered, + # and with a `\label` that no `[-@fig:…]` could reach, which is the defect + # `18/firework-bee/en` prints as `??`. + # + # Removing the rule is inert for everything that exists: no source writes an empty + # alt text and no file under `build/` carried a `\caption{}` before this. ], 'html': [ # Prepend "obrazky/" diff --git a/core/builder/reference.py b/core/builder/reference.py index 8aae9234..67ee8166 100644 --- a/core/builder/reference.py +++ b/core/builder/reference.py @@ -131,6 +131,7 @@ def example_context() -> dict: QuantityProduct, ) from core.builder.context.quantities.math import MathObject + from core.builder.context.quantities.picture import Picture build = PhysicsQuantity.construct return { @@ -164,6 +165,8 @@ def example_context() -> dict: }, 'words': {'air': 'vzduch'}, 'blocks': {'setup': 'set xlabel "t"\nset ylabel "v"'}, + 'pictures': {'hanging': Picture('demo:hanging', 'hanging.png', + attributes={'height': '40mm'})}, } @@ -878,6 +881,21 @@ def _render_static(template: str) -> str: 'form and still works. **There is no fallback to English and never was** -- a word ' 'a language has not got is boxed in red, reported at the end of the render, and the ' 'render then exits nonzero.'), + Entry('pictures', 'namespace', section='Namespaces the context adds', + signature="pictures. · pictures.('caption') · " + "pictures.(height='55mm')", + summary='The figures declared in this problem\'s `pictures:`. Emits the whole Markdown ' + 'include -- file, size and label -- so the only thing left at the call site is ' + 'the caption and where the figure sits.', + note='**The key is the label.** 440 of the 869 hand-written figure labels in the ' + 'repository did not follow the `fig::` convention, and one did not even ' + 'match its own problem id; a wrong figure label is silent until a crossref ' + 'resolves to nothing. A caption is what makes pandoc emit a `figure`, so it ' + 'decides the number and the label together -- which is why a label written onto an ' + 'uncaptioned image reaches the page as nothing at all.', + example="(§ pictures.hanging §) (§ pictures.hanging('Lano') §)", + expect='![](hanging.png){#fig:demo:hanging height=40mm} ' + '![Lano](hanging.png){#fig:demo:hanging height=40mm}'), ) @@ -929,6 +947,20 @@ def _render_static(template: str) -> str: 'never `>`** -- a folded scalar collapses newlines into spaces and gnuplot wants ' 'one directive per line. Tags inside a block are expanded by the second pass, so a ' 'preamble can write `tcold = (§ tcold.mag §)` and stay in step.'), + Entry('pictures', 'meta-key', env=META, section='`meta.yaml`: a problem', + signature='pictures: {name: {file: , height: }}', + summary='A figure, hoisted out of the per-language files the way `eq:` is. Reached as ' + '`(§ pictures.name §)`; **the key becomes the label**, `{#fig::}`, ' + 'and nobody types one.', + note='Both keys are required and there are no others. The caption is **not** here: it ' + 'is prose, it is translated, and it goes at the call site -- ' + "`(§ pictures.name('Lano cez kladku') §)`. The size is a default rather than a " + 'rule, because a tear-off has a fixed height and translations do not have equal ' + "line counts, so a language that needs the room writes " + "`(§ pictures.name(height='55mm') §)` and says so in one visible place. Whether a " + 'figure is captioned -- and so numbered, and so referenceable -- is decided by the ' + 'file being rendered, not at the call site: a statement never captions its ' + 'figures and a solution always does, including where the caption is empty.'), Entry('authors', 'meta-key', env=META, section='`meta.yaml`: a problem', signature='authors: {idea: [...], problem: [...], solution: [...]}', summary='**Required.** Who did which of the three jobs. Each role is a list of names, ' @@ -1206,6 +1238,8 @@ def _context_table() -> str: ('`const.g`', r'`\qty{9.80665}{\metre\per\second\squared}`', 'measured, `digits: 2` -- so `.approx` is `9.8`'), ('`words.air`', '`vzduch`', 'a `words:` entry'), + ('`blocks.setup`', '`set xlabel "t"` …', 'a `blocks:` entry'), + ('`pictures.hanging`', '`hanging.png`, `height: 40mm`', 'a `pictures:` entry'), ('`n`, `team`, `people`, `date`', '`3`, three names, one author, 2025-10-02', 'for the `.jtex` examples'), ] diff --git a/core/builder/renderer.py b/core/builder/renderer.py index 2fec84fa..7fb937bc 100755 --- a/core/builder/renderer.py +++ b/core/builder/renderer.py @@ -16,6 +16,7 @@ from core.builder.context.file import FileContext from core.builder.context.quantities import PhysicsConstant from core.builder.context.quantities.math import MathObject +from core.builder.context.quantities.picture import Picture from core.builder.jinja import MarkdownJinjaRenderer, PictureJinjaRenderer from core.utilities import colour as c @@ -34,10 +35,20 @@ #: A Markdown list marker, whose width a continuation line has to match. RE_LIST_MARKER = re.compile(r'[ \t]*(?:[-*+]|\d+[.)])[ \t]+') -#: A Jinja tag whose filter chain reaches one of those. The `|` is required, so an argument that -#: merely contains the word -- `default('disp')` -- is not mistaken for a filter. +#: A Jinja tag that emits block material, in two shapes. +#: +#: One is a filter chain reaching `disp`, `align` or `arr`. The `|` is required, so an argument +#: that merely contains the word -- `default('disp')` -- is not mistaken for a filter. +#: +#: The other is a figure, `(§ pictures.x §)`, which carries no filter at all: the include it +#: splices in is block material for the same reason a display is, and sharing a line with prose +#: costs it the same thing. Pandoc reads `![…](f){height=40mm}` mid-sentence as an *inline* image, +#: so the figure loses its own paragraph, its caption and its number without any error anywhere. RE_DISPLAY_TAG = re.compile( - r'\(§(?:(?!§\)).)*\|[ \t]*(?:' + '|'.join(DISPLAY_FILTERS) + r')[a-z]*\b(?:(?!§\)).)*§\)', + r'\(§(?:' + r'(?:(?!§\)).)*\|[ \t]*(?:' + '|'.join(DISPLAY_FILTERS) + r')[a-z]*\b(?:(?!§\)).)*' + r'|[ \t]*pictures\.(?:(?!§\)).)*' + r')§\)', re.DOTALL) @@ -408,6 +419,20 @@ def __init__(self, new_id: str, path: Path, **defaults): #: rather than surfacing as a `TypeError: unexpected keyword argument` from inside the #: constructor, or, for a misspelt `magnitude`, as a bare `KeyError` with nothing in it to say #: which entry of which file was wrong. +#: What one `pictures:` entry may hold. +#: +#: Both keys are required and there are no others, because both are written by every one of the +#: repository's 2003 includes and nothing else ever is -- `height` is the only pandoc attribute +#: any of them uses. A figure wanting `width` instead is a deliberate addition here rather than +#: something an author can invent at a call site, which is the point: a typo in an attribute name +#: is silent, and an unsized figure is refused much later by `convertor.py` with a message about +#: `\pandocbounded`. +PICTURE_ENTRY = { + 'file': str, + 'height': str, +} + + VALUE_ENTRY = { # **Never a string.** YAML 1.1 wants both a decimal point and a sign before it reads an # exponent as a number, so `1e15`, `1e+15` and `1.0e15` all parse as bare strings -- and the @@ -469,6 +494,11 @@ class StandaloneContext(FileContext): # text, reached as `(§ words.air §)`. The recurring ones (`and`, `or`) live in `core/i18n` # instead; these are the one-offs, which is the large majority of them. Opt('words'): {Opt(ValidIdentifier): {Opt(str): str}}, + # A figure: which file, how big, and -- through the key -- what it is called. Reached as + # `(§ pictures.hanging §)`. The key becomes the `{#fig::}` label, so nobody + # writes one, which is the same bargain `eq:` makes and matters more here: 440 of the 869 + # hand-written figure labels in the repository did not follow the convention. + Opt('pictures'): {Opt(ValidIdentifier): PICTURE_ENTRY}, }) @@ -486,6 +516,7 @@ def _reject_name_collisions(block: dict, block_name: str, *, taken: set[str] = f def build_render_context(meta: Context, locale: str, *, root: Path, labelled: bool = True, + captioned: bool = True, missing_words: 'MissingWordRegistry | None' = None) -> Context: """ Everything a template may reach, built from one already-validated problem meta. @@ -597,6 +628,22 @@ def build_render_context(meta: Context, locale: str, *, root: Path, labelled: bo labelled=labelled) ctx.add(eq=meta.data['eq']) + # Figures, namespaced under `pictures` for the reason `blocks` and `words` are: a picture + # reached as `(§ pictures.hanging §)` shadows nothing, so it may be called anything. + # + # `captioned` comes from the caller for the same reason `labelled` does, and is the same + # decision wearing a different hat -- a caption is what makes a figure a `figure`, and so + # what gives it a number and a label. A statement's figures are never captioned and a + # solution's always are, which is what stops one being referenceable in English and not in + # Slovak. + if 'pictures' in meta.data: + ctx.add(pictures={ + key: Picture(f"{meta.data['id']}:{key}", entry['file'], + captioned=captioned, + attributes={k: v for k, v in entry.items() if k != 'file'}) + for key, entry in meta.data['pictures'].items() + }) + return ctx @@ -618,6 +665,21 @@ class CLIInterface(cli.CLIInterface, ABC): #: a module that says nothing is unaffected, and a file type added to a module later is #: numbered until somebody decides otherwise rather than silently losing its labels. equation_numbering: dict[str, bool] = {} + + #: Per file, whether its figures carry a caption, and so a number and a `{#fig:…}` label. + #: + #: The figure half of `equation_numbering`, declared the same way and empty here for the same + #: reason -- which of a module's files is a statement is the module's own taxonomy. A file this + #: does not mention keeps what it had, which is "captioned if the author wrote a caption". + #: + #: Two things make this worth stating rather than leaving to the call site. A caption is what + #: makes pandoc emit a `figure` at all, so it decides the number and the label together: a + #: label on an uncaptioned image reaches the page as nothing, which 553 of the repository's + #: hand-written labels do. And it has already gone wrong per language -- `18/firework-bee` + #: references its figure in English only, and the English booklet prints `??` because no + #: language captioned it. + figure_captions: dict[str, bool] = {} + """ Jinja standalone convertor CLI interface """ @@ -649,13 +711,30 @@ def build_context(self) -> Context: about it -- which is the one thing a live preview must not do. """ meta_path = Path(self.args.context.name) - meta = self.context_cls(self.args.context.name, meta_path).add(id=meta_path.parent.name) + meta = self.context_cls(self.args.context.name, meta_path).add( + id=self.label_prefix(meta_path)) meta.validate() + infile = Path(self.args.infile.name).name return build_render_context( meta, self.args.locale, root=meta_path.parent, - labelled=self.equation_numbering.get(Path(self.args.infile.name).name, True), + labelled=self.equation_numbering.get(infile, True), + captioned=self.figure_captions.get(infile, True), missing_words=self.missing_words) + @staticmethod + def label_prefix(meta_path: Path) -> str: + """ + What every `{#eq:…}` and `{#fig:…}` in this problem is named after. + + The problem's own directory, which is right wherever that directory name is unique -- + Náboj's problems are slugs, so `hilltop-gun` names only one problem in the repository. + It is overridable because seminar's are not: its problems are numbered `01`, `02`, … per + round, so its house rule is the composite `{#fig:421101:key}` and its labels are currently + written by hand in four incompatible spellings, with seven names defined in more than one + problem directory. + """ + return meta_path.parent.name + def build_convertor(self, args, **kwargs): return JinjaConvertor(self.args.infile, self.build_context(), diff --git a/core/tests/test_renderer.py b/core/tests/test_renderer.py index 988b78f6..aaa95c85 100644 --- a/core/tests/test_renderer.py +++ b/core/tests/test_renderer.py @@ -467,6 +467,131 @@ def test_a_block_may_share_a_name_with_a_value(self, tmp_path): assert '42 then a block' in out +class TestPictures: + r""" + `pictures:` holds a figure -- which file, how big -- and the key becomes its `{#fig:…}` label. + + The figure half of what `eq:` does for an equation, and for a sharper reason: 440 of the + repository's 869 hand-written figure labels did not follow the `fig::` convention, + against about 1 % of the equation labels, and a wrong figure label is silent until a crossref + resolves to nothing. + + Whether a figure is captioned is decided by the file being rendered, exactly as `labelled` is, + because a caption is what makes pandoc emit a `figure` at all -- and so what gives the drawing + a number and a `\label`. That is one decision, not two: a label on an uncaptioned image + reaches the page as nothing. + """ + + render_named = staticmethod(TestTranslatedWords.render_named) + + META = ("authors:\n idea: []\n problem: []\n solution: []\n" + "tags: ['kinematics']\n" + "pictures:\n" + " hanging:\n file: hanging.png\n height: 40mm\n") + + def test_a_statement_figure_is_neither_captioned_nor_labelled(self, tmp_path): + """ + A statement never numbers its figures, and nobody wanted it to: of the 169 figure labels + written into statements across the repository, not one is referenced from anywhere. + """ + out = self.render_named(tmp_path, 'sk', self.META, '(§ pictures.hanging §)\n', 'problem.md') + assert out.strip() == '![](hanging.png){height=40mm}' + + def test_a_solution_figure_is_both(self, tmp_path): + out = self.render_named(tmp_path, 'sk', self.META, '(§ pictures.hanging §)\n', 'solution.md') + # the prefix is the problem directory's name, which under pytest is the tmp directory + assert out.strip().startswith('![](hanging.png){#fig:') + assert out.strip().endswith(':hanging height=40mm}') + + def test_an_empty_caption_is_written_as_a_comment(self, tmp_path): + """ + Not `![]` and not `![ ]`, which is the whole of why this spelling exists: pandoc makes a + `figure` only when the alt text has content, so both of those come out as a bare + `\includegraphics` with no number and no label -- the state the rule exists to end. An + HTML comment is content to the parser and nothing on the page. + """ + out = self.render_named(tmp_path, 'sk', self.META, '(§ pictures.hanging §)\n', 'solution.md') + assert '![]' in out + + def test_a_caption_given_at_the_call_site_is_used(self, tmp_path): + """The caption stays per-language because it is prose; only 5 of 386 are the same in every + language, so it is the one part of an include that must not be hoisted.""" + out = self.render_named(tmp_path, 'sk', self.META, + "(§ pictures.hanging('Lano cez kladku') §)\n", 'solution.md') + assert '![Lano cez kladku](hanging.png){#fig:' in out + assert ':hanging height=40mm}' in out + + def test_a_size_override_beats_the_meta(self, tmp_path): + """ + The default is in the meta because 166 of 386 multilingual figures disagree about their + size by accident. It is only a default because a tear-off has a fixed height and + translations do not have equal line counts, so a language that needs the room says so -- + once, visibly, at the call site. + """ + out = self.render_named(tmp_path, 'sk', self.META, + "(§ pictures.hanging(height='55mm') §)\n", 'solution.md') + assert 'height=55mm' in out and '40mm' not in out + + def test_the_same_picture_may_be_included_twice_in_a_statement(self, tmp_path): + """Where nothing is labelled there is nothing to collide. In a solution the same two + includes would be a duplicate label, which pandoc-crossref refuses -- as it does for an + `eq:` entry displayed twice, and for the same reason.""" + out = self.render_named(tmp_path, 'sk', self.META, + '(§ pictures.hanging §)\n\n(§ pictures.hanging §)\n', 'problem.md') + assert out.count('![](hanging.png)') == 2 + + def test_core_itself_has_no_opinion(self): + """Which file is a statement is the module's taxonomy, as it is for equation numbering.""" + from core.builder.renderer import CLIInterface as CoreCLI + from modules.naboj.builder.renderer import CLIInterface as NabojCLI + assert CoreCLI.figure_captions == {} + assert NabojCLI.figure_captions['problem.md'] is False + assert NabojCLI.figure_captions['solution.md'] is True + + def test_naboj_declares_every_file_it_may_hold(self): + """Both directions and no gaps, pinned the way `equation_numbering` is.""" + from core.audit.sources import SHARED_FILES, TRANSLATED_FILES + from modules.naboj.builder.renderer import CLIInterface as NabojCLI + assert set(NabojCLI.figure_captions) == set(TRANSLATED_FILES) | set(SHARED_FILES) + + def test_an_undeclared_file_captions_its_figures(self, tmp_path): + """The conservative default, as for equations: a module that says nothing is unaffected.""" + out = self.render_named(tmp_path, 'sk', self.META, + '(§ pictures.hanging §)\n', 'whatever.md') + assert '{#fig:' in out + + def test_pictures_is_a_reserved_name(self): + """ + It is a namespace the context holds, so a `values:` entry may not claim it -- the same + rule that `blocks`, `eq` and `words` live under. Checked against every meta under + `source/` before it was reserved, because reserving `w` once broke eight problems. + """ + from core.builder.context.context import RESERVED_NAMES + from core.builder.renderer import NameCollisionError, _reject_name_collisions + assert 'pictures' in RESERVED_NAMES + with pytest.raises(NameCollisionError): + _reject_name_collisions({'pictures': 1}, 'values') + # and the quiet half: a name that merely looks like it is fine + _reject_name_collisions({'picture': 1, 'pics': 2}, 'values') + + def test_an_entry_needs_a_file_and_a_height(self): + """ + Both, and nothing else. Every one of the 2003 includes in the repository writes a height + and none writes any other attribute, so a third one is a deliberate addition here rather + than something invented at a call site -- a mistyped attribute name is silent, and an + unsized figure is refused much later with a message about `\pandocbounded`. + """ + from enschema import SchemaError + + from core.builder.renderer import StandaloneContext + schema = StandaloneContext._schema + schema.validate({'id': 'demo', 'pictures': {'a': {'file': 'a.svg', 'height': '40mm'}}}) + for bad in ({'file': 'a.svg'}, {'height': '40mm'}, + {'file': 'a.svg', 'height': '40mm', 'width': '2cm'}): + with pytest.raises(SchemaError): + schema.validate({'id': 'demo', 'pictures': {'a': bad}}) + + class TestDisplayTagsOwnTheirLine: r""" A display tag is put on a line of its own before rendering, and only where it is not already. diff --git a/docs/filters.md b/docs/filters.md index 73faa879..4d85c56a 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -45,6 +45,8 @@ Every example below is rendered in this context, which is what `example_context( | `eq.sys` | `x &+& y &=& 1 \\ x &-& y &=& 0` | a system, for `\|arr` | | `const.g` | `\qty{9.80665}{\metre\per\second\squared}` | measured, `digits: 2` -- so `.approx` is `9.8` | | `words.air` | `vzduch` | a `words:` entry | +| `blocks.setup` | `set xlabel "t"` … | a `blocks:` entry | +| `pictures.hanging` | `hanging.png`, `height: 40mm` | a `pictures:` entry | | `n`, `team`, `people`, `date` | `3`, three names, one author, 2025-10-02 | for the `.jtex` examples | @@ -1466,6 +1468,25 @@ is spelled as written. `(§ i18n.words['and'] §)` is the older form and still w fallback to English and never was** -- a word a language has not got is boxed in red, reported at the end of the render, and the render then exits nonzero. +### `pictures.` + +`pictures. · pictures.('caption') · pictures.(height='55mm')` + +The figures declared in this problem's `pictures:`. Emits the whole Markdown include -- file, size +and label -- so the only thing left at the call site is the caption and where the figure sits. + +**The key is the label.** 440 of the 869 hand-written figure labels in the repository did not follow +the `fig::` convention, and one did not even match its own problem id; a wrong figure +label is silent until a crossref resolves to nothing. A caption is what makes pandoc emit a +`figure`, so it decides the number and the label together -- which is why a label written onto an +uncaptioned image reaches the page as nothing at all. + + (§ pictures.hanging §) (§ pictures.hanging('Lano') §) + +produces + + ![](hanging.png){#fig:demo:hanging height=40mm} ![Lano](hanging.png){#fig:demo:hanging height=40mm} + ## `meta.yaml`: a problem ### `values:` @@ -1528,6 +1549,21 @@ The case it exists for is a gnuplot preamble shared by several plots. **`|`, nev scalar collapses newlines into spaces and gnuplot wants one directive per line. Tags inside a block are expanded by the second pass, so a preamble can write `tcold = (§ tcold.mag §)` and stay in step. +### `pictures:` + +`pictures: {name: {file: , height: }}` + +A figure, hoisted out of the per-language files the way `eq:` is. Reached as `(§ pictures.name §)`; +**the key becomes the label**, `{#fig::}`, and nobody types one. + +Both keys are required and there are no others. The caption is **not** here: it is prose, it is +translated, and it goes at the call site -- `(§ pictures.name('Lano cez kladku') §)`. The size is a +default rather than a rule, because a tear-off has a fixed height and translations do not have equal +line counts, so a language that needs the room writes `(§ pictures.name(height='55mm') §)` and says +so in one visible place. Whether a figure is captioned -- and so numbered, and so referenceable -- +is decided by the file being rendered, not at the call site: a statement never captions its figures +and a solution always does, including where the caption is empty. + ### `authors:` `authors: {idea: [...], problem: [...], solution: [...]}` diff --git a/modules/naboj/builder/renderer.py b/modules/naboj/builder/renderer.py index fcd27408..ecaef9b9 100755 --- a/modules/naboj/builder/renderer.py +++ b/modules/naboj/builder/renderer.py @@ -252,6 +252,28 @@ class CLIInterface(renderer.CLIInterface, ABC): 'answer-interval.md': True, } + #: Every file a Náboj problem may hold, and whether its figures carry a caption. + #: + #: The same taxonomy and the same rule, because a caption is to a figure what a label is to an + #: equation: it is what makes pandoc emit a `figure`, and so what gives the drawing a number + #: that a `[-@fig:…]` can reach. **A statement never captions its figures**, so they are never + #: numbered -- and nobody wanted them to be, since of the 169 figure labels written in + #: statements across the repository not one is referenced from anywhere. + #: + #: **A solution always does**, including where the caption is empty. That is the half that + #: buys something: a figure captioned in one language and not another is numbered in one + #: booklet and not another, which is how `18/firework-bee/en` came to print `??` where its + #: `sk` and `cs` siblings are fine. + figure_captions = { + 'problem.md': False, + 'problem-extra.md': False, + 'solution.md': True, + 'answer.md': True, + 'answer-extra.md': True, + 'answer-also.md': True, + 'answer-interval.md': True, + } + if __name__ == "__main__": CLIInterface().run() diff --git a/tools/editor/app.py b/tools/editor/app.py index 4f0b948c..58cc828f 100644 --- a/tools/editor/app.py +++ b/tools/editor/app.py @@ -537,7 +537,8 @@ def evaluation_context(unit, target, meta_text): meta.validate() context = build_render_context( meta, language, root=unit.path, - labelled=cli.equation_numbering.get(filename, True)) + labelled=cli.equation_numbering.get(filename, True), + captioned=cli.figure_captions.get(filename, True)) CONTEXT_CACHE[key] = context while len(CONTEXT_CACHE) > CONTEXT_CACHE_SIZE: diff --git a/tools/editor/static/highlight.js b/tools/editor/static/highlight.js index 4fed4c1f..3fd2f4f9 100644 --- a/tools/editor/static/highlight.js +++ b/tools/editor/static/highlight.js @@ -82,7 +82,8 @@ const INNER = [ // are the entries' own names and the deeper ones are `magnitude:` and its siblings, or a // language code. Only the name level names something a template may write, which is why // `entryIndent` exists rather than a hard-coded depth. -const META_EVAL_BLOCKS = { eq: "eq.", values: "", derived: "", blocks: "blocks.", words: "words." }; +const META_EVAL_BLOCKS = { eq: "eq.", values: "", derived: "", blocks: "blocks.", + words: "words.", pictures: "pictures." }; // Where `INNER` applies. In Markdown and gnuplot that is inside a `(§ … §)`; in a meta it is the // values of `derived:`, which is where `PQ`, `QL` and `.to()` are actually written — 158 of the From 1b106eb1064cf19ed98964f6f2f782a224e016b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Thu, 8 Oct 2026 11:46:17 +0000 Subject: [PATCH 4/7] A caption wraps in the source and prints on one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was the wrong way round. The converter encoded a multi-line caption's break as a `\n` in the Jinja string, which put the break in the rendered output -- where it does nothing, a newline inside `![…]` being a space to pandoc -- and the long line in the source, where it costs the 120-character limit and a readable diff. 21 call sites came out over 120 characters and the longest at 249. `Picture` collapses the caption's internal newlines now, so the wrapping is the source's business and the break is free. The sources keep their original wrapping, written with real newlines, which Jinja takes inside a string literal. Proven neutral rather than assumed: `\caption{first line\nsecond line}` and `\caption{first line second line}` are the same to TeX, and the 42 affected files convert to byte-identical TeX once whitespace is normalised. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 98 ++++++++++++++++++++++ core/builder/context/quantities/picture.py | 19 ++++- core/tests/test_renderer.py | 13 +++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 541d07b8..17bfd46c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -567,6 +567,104 @@ schema wants `magnitude:` — that meta has never loaded and its `problem.md` fa Sharing one drawing between two problems is a **symlink**, not an `include()`: the two can end up in different volumes, and a path written into the text is the fragile half of that. +## A figure is a meta entry: `pictures:` + +An include written out by hand types four things, three of which are facts about the *drawing* +and were therefore typed once per language: + +``` +![Lano visiace cez kladku](hanging.png){#fig:hanging-rope:hanging height=40mm} +``` + +Those three move into the meta, and the key becomes the label: + +```yaml +pictures: + hanging: + file: hanging.png + height: 40mm +``` + +``` +(§ pictures.hanging §) the default size, captioned by the file's rule +(§ pictures.hanging('Lano cez kladku') §) a caption, which stays per-language +(§ pictures.hanging(height='55mm') §) one language needs the room, visibly +``` + +**The key is the label**, and that is the whole bargain. 440 of the repository's 869 +hand-written figure labels did not follow `fig::` -- 336 had no key part at all, 104 +a key that is not an identifier, and `fig:letna-skola` did not even match its own problem id -- +against about 1 % on the equation side. A wrong figure label is silent until a crossref resolves +to nothing. + +**The size is a default, not a rule.** A tear-off has a fixed height and translations do not have +equal line counts, so a language that needs the room says so at its own call site. What the +default kills is the other 166: of 386 figures written in two or more languages, **43 % printed at +different sizes in different booklets** -- `20/resistor-range` at 14 mm, 35 mm and 55 mm -- none of +it chosen, because nobody ever sees two booklets side by side. + +### A statement never captions its figures; a solution always does + +`figure_captions` in `modules/naboj/builder/renderer.py`, stated in both directions exactly as +`equation_numbering` is and for the same reason -- which of a module's files is a statement is the +module's own taxonomy. `core`'s is empty, and a file it does not mention keeps what it had. + +**A caption is what makes pandoc emit a `figure`**, so it decides the number and the label +together. They are one decision, not two, and that is why the rule is worth stating: + +- a label written onto an *uncaptioned* image reaches the page as nothing. 553 of the + repository's hand-written labels did exactly that; +- so a figure captioned in one language and not another is numbered in one booklet and not + another. `18/firework-bee/en` referenced its figure, nothing captioned it, and the English + booklet printed **`??`** while `sk` and `cs` were fine. + +Nothing is lost by dropping a statement's labels: of the 169 defined in statements, **not one is +referenced** from anywhere. All 46 figure crossrefs live in solutions and point at solutions. + +**An empty caption is written `![]`.** Not `![]` and not `![ ]` -- pandoc needs the alt +text to have content before it makes a `figure`, so both of those come out as a bare +`\includegraphics` with no number and no label, which is the state this rule exists to end. An +HTML comment is content to the parser and nothing on the page. Nobody types it; the tag emits it. + +`convertor.py` used to delete `\caption{}` and its `\label` with it, under "Remove empty captions +and labels". That rule is gone. It put every such figure straight back to unnumbered, and it was +dead code until this made it live -- no source writes an empty alt and nothing under `build/` +carried a `\caption{}`. + +### Converting one + +`picture-literal` reports a hand-typed include naming a file the problem's own `pictures:` already +declares -- the figure counterpart of `value-equation-literal`, and silent where nothing is +declared, because a problem nobody has converted is not a problem with a defect in it. + +The gate is sharper than byte-identity, because the caption rule is *supposed* to move the page: +**the file, the size and the caption's own text must not change**, and the only things that may +are the label and the empty-caption marker. Then count the dangling `[-@fig:…]` across the +scope's whole rendered output, which is the one thing a rename can break. + +Four traps, each caught by that gate rather than by reading: + +- **Offsets go stale across keys.** Replacing one figure's call sites shortens the file, so the + next key's offsets are wrong -- `28/buzz-saw` lost the `Uv` of `Uvažujme`. Collect every + replacement first and apply them per file in one descending pass. +- **`onô.svg` generates the key `on`**, which YAML 1.1 loads as the boolean `True`. The schema + refuses it by name; avoid the whole `y/n/yes/no/on/off/true/false/null` set, and re-read each + meta to check the keys came back as strings. +- **Two drawings may share one label.** `28/ice-ice-baby` labels both its statement figure and its + solution figure `calippo`, which LaTeX never noticed because the statement one carried no + `\label`. The key goes to whichever is captioned, or the crossref lands on the figure that has + none. +- **A caption holding a Jinja tag cannot be a string argument**, since the tag ends at the first + `§)`. Four call sites in `25/czechlights` and `25/public-transport` stay literal and show up as + `picture-literal`. + +**Wrap a long caption across lines, with real newlines.** Jinja takes them inside a string literal, +and `Picture` emits the caption on one line, so where the break falls is free: a newline inside +`![…]` is a space to pandoc, and the `\caption{}` it writes sets identically either way. Encoding +the break as a `\n` escape instead gets it exactly backwards -- the break lands where it does +nothing and the long line where it hurts. That was done for one commit, and it put 21 call sites +over 120 characters, the longest at 249. + ## Translated words inside maths A word that appears inside maths has to change with the language, and writing it out per diff --git a/core/builder/context/quantities/picture.py b/core/builder/context/quantities/picture.py index 09f49cdf..b0ac7e3c 100644 --- a/core/builder/context/quantities/picture.py +++ b/core/builder/context/quantities/picture.py @@ -1,3 +1,6 @@ +import re + + class Picture: r""" A figure declared once in `pictures:` and included as `(§ pictures. §)`. @@ -80,11 +83,25 @@ def _label(self) -> str: def _attribute_text(self) -> str: return ' '.join(f"{key}={value}" for key, value in self.attributes.items()) + @staticmethod + def _one_line(caption: str) -> str: + r""" + A caption wrapped across several source lines, emitted on one. + + The wrapping belongs to the source, where a long line is a real cost -- the 120-character + limit, a diff nobody can read -- and it buys nothing in the output: a newline inside + `![…]` is a space to pandoc, and the `\caption{}` it writes sets identically either way. + Encoding the break instead, as a `\n` in the Jinja string, got this exactly backwards: + it put the break where it does nothing and the long line where it hurts, and 21 call + sites came out over 120 characters, the longest at 249. + """ + return re.sub(r'\s*\n\s*', ' ', caption) + def __str__(self) -> str: """The Markdown include, which the second render pass expands any tags inside.""" alt = '' if self.captioned: - alt = self.caption if self.caption else self.EMPTY_CAPTION + alt = self._one_line(self.caption) if self.caption else self.EMPTY_CAPTION attributes = ' '.join(part for part in (self._label, self._attribute_text()) if part) # The attribute block is never omitted even when it is empty: `convertor.py` refuses an # image that reaches LaTeX without one -- `RegexFailure(r'\\pandocbounded')` -- because an diff --git a/core/tests/test_renderer.py b/core/tests/test_renderer.py index aaa95c85..7914a1b3 100644 --- a/core/tests/test_renderer.py +++ b/core/tests/test_renderer.py @@ -540,6 +540,19 @@ def test_the_same_picture_may_be_included_twice_in_a_statement(self, tmp_path): '(§ pictures.hanging §)\n\n(§ pictures.hanging §)\n', 'problem.md') assert out.count('![](hanging.png)') == 2 + def test_a_wrapped_caption_is_emitted_on_one_line(self, tmp_path): + r""" + The wrapping belongs to the source and buys nothing in the output, where a newline inside + `![…]` is a space to pandoc and the `\caption{}` sets identically either way. + + It was the other way round for one commit -- the break encoded as a `\n` in the Jinja + string, which put it where it does nothing and the long line where it hurts: 21 call sites + came out over 120 characters and the longest at 249. + """ + out = self.render_named(tmp_path, 'sk', self.META, + "(§ pictures.hanging('first line\nsecond line') §)\n", 'solution.md') + assert '![first line second line](hanging.png){#fig:' in out + def test_core_itself_has_no_opinion(self): """Which file is a statement is the module's taxonomy, as it is for equation numbering.""" from core.builder.renderer import CLIInterface as CoreCLI From 5d5f1e7a842bff0c5fceddc29648566d92fabf4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Thu, 8 Oct 2026 12:53:22 +0000 Subject: [PATCH 5/7] A caption may be declared in the entry, and then it may interpolate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one thing a call-site caption cannot do. A caption is passed as a string argument, and a `(§ … §)` inside a string literal ends the enclosing tag at its first `§)` -- so `(§ pictures.x('… $(§ Imax §)$') §)` does not parse at all, and four call sites in `25/czechlights` and `25/public-transport` had to stay literal. `caption:` in the entry takes it instead, per language or as a bare string where every language shares one, resolved the way a `words:` term is. Nothing lexes it there, so it needs no quoting: the text is emitted as written and the next render pass expands whatever tags it holds, which is how `blocks:` has always worked and is why `render_to_fixpoint` keeps going until the output stops changing. The call site stays the normal place for a caption and wins where both are given. It is prose, only 5 of 386 read the same in every language, and a translator may want it to read differently; this is the exception, not a second home. Also strips the caption as well as joining it, because a `|` block scalar keeps its final newline and that arrived as a space before the closing `]`. Four new tests, and the four call sites convert: the TeX is identical modulo whitespace, so `picture-literal` is now silent across the whole of naboj. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 20 ++++++++++-- core/builder/context/quantities/picture.py | 26 +++++++++++++-- core/builder/reference.py | 8 +++-- core/builder/renderer.py | 17 +++++++++- core/tests/test_renderer.py | 37 ++++++++++++++++++++++ docs/filters.md | 17 ++++++---- 6 files changed, 111 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 17bfd46c..830a2f0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -655,8 +655,24 @@ Four traps, each caught by that gate rather than by reading: `\label`. The key goes to whichever is captioned, or the crossref lands on the figure that has none. - **A caption holding a Jinja tag cannot be a string argument**, since the tag ends at the first - `§)`. Four call sites in `25/czechlights` and `25/public-transport` stay literal and show up as - `picture-literal`. + `§)`. That caption goes under `caption:` in the entry instead, per language or as a bare string + where every language shares it: + + ```yaml + pictures: + toomuch: + file: dimensions.svg + height: 50mm + caption: + sk: 'Dĺžky pásikov; hrubo sú miesta, ktorými tečie viac ako $(§ Imax §)$.' + en: 'Lengths of the strips; thick lines denote current higher than (§ Imax §).' + ``` + + Nothing lexes it there, so it needs no quoting: the text is emitted as written and the *next* + render pass expands it, exactly as a tag inside a `blocks:` entry is. The call site stays the + normal place for a caption and wins where both are given -- this is for the one case the call + site cannot express. The four in `25/czechlights` and `25/public-transport` are the only ones + in the repository that need it. **Wrap a long caption across lines, with real newlines.** Jinja takes them inside a string literal, and `Picture` emits the caption on one line, so where the break falls is free: a newline inside diff --git a/core/builder/context/quantities/picture.py b/core/builder/context/quantities/picture.py index b0ac7e3c..d3039511 100644 --- a/core/builder/context/quantities/picture.py +++ b/core/builder/context/quantities/picture.py @@ -22,9 +22,25 @@ def __init__(self, *, captioned: bool = True, caption: str | None = None, + declared_caption: str | None = None, attributes: dict | None = None): self.id = ident self.file = file + #: A caption written in the meta rather than at the call site, already resolved for this + #: language by `build_render_context`. + #: + #: The call site is the right place for a caption and stays the normal one: it is prose, + #: only 5 of 386 are the same in every language, and a translator may want it to read + #: differently. This is for the one case the call site cannot express -- **a caption that + #: interpolates**. A caption is passed as a string argument, and a `(§ … §)` inside a + #: string literal ends the enclosing tag at its first `§)`, so + #: `(§ pictures.x('… $(§ Imax §)$') §)` does not parse at all. + #: + #: Here it needs no quoting, because nothing lexes it: the text is emitted into the + #: Markdown as written and the *next* render pass expands whatever tags it holds. That is + #: how `blocks:` works, and it is the same reason -- `render_to_fixpoint` keeps going + #: until the output stops changing. + self.declared_caption = declared_caption #: Whether this file's figures carry a caption, and therefore a number and a label. #: #: Set by the renderer from the file being rendered against the module's own @@ -66,6 +82,7 @@ def __call__(self, caption: str | None = None, **attributes) -> 'Picture': return Picture(self.id, self.file, captioned=self.captioned, caption=self.caption if caption is None else caption, + declared_caption=self.declared_caption, attributes=self.attributes | attributes) @property @@ -95,13 +112,18 @@ def _one_line(caption: str) -> str: it put the break where it does nothing and the long line where it hurts, and 21 call sites came out over 120 characters, the longest at 249. """ - return re.sub(r'\s*\n\s*', ' ', caption) + # Stripped as well as joined: a `caption:` written as a YAML `|` block keeps its + # final newline, which would otherwise arrive as a space before the closing `]`. + return re.sub(r'\s*\n\s*', ' ', caption).strip() def __str__(self) -> str: """The Markdown include, which the second render pass expands any tags inside.""" alt = '' if self.captioned: - alt = self._one_line(self.caption) if self.caption else self.EMPTY_CAPTION + # The call site first, then the meta, then the marker that makes it a numbered figure + # with nothing written in it. + written = self.caption if self.caption else self.declared_caption + alt = self._one_line(written) if written else self.EMPTY_CAPTION attributes = ' '.join(part for part in (self._label, self._attribute_text()) if part) # The attribute block is never omitted even when it is empty: `convertor.py` refuses an # image that reaches LaTeX without one -- `RegexFailure(r'\\pandocbounded')` -- because an diff --git a/core/builder/reference.py b/core/builder/reference.py index 67ee8166..dc7187e8 100644 --- a/core/builder/reference.py +++ b/core/builder/reference.py @@ -948,13 +948,17 @@ def _render_static(template: str) -> str: 'one directive per line. Tags inside a block are expanded by the second pass, so a ' 'preamble can write `tcold = (§ tcold.mag §)` and stay in step.'), Entry('pictures', 'meta-key', env=META, section='`meta.yaml`: a problem', - signature='pictures: {name: {file: , height: }}', + signature='pictures: {name: {file: , height: , caption: {lang: text}}}', summary='A figure, hoisted out of the per-language files the way `eq:` is. Reached as ' '`(§ pictures.name §)`; **the key becomes the label**, `{#fig::}`, ' 'and nobody types one.', note='Both keys are required and there are no others. The caption is **not** here: it ' 'is prose, it is translated, and it goes at the call site -- ' - "`(§ pictures.name('Lano cez kladku') §)`. The size is a default rather than a " + "`(§ pictures.name('Lano cez kladku') §)` -- except where it *interpolates*, " + 'which a string argument cannot express at all, since a `(§ … §)` inside one ends ' + 'the enclosing tag at its first `§)`. That caption goes under `caption:`, per ' + 'language or as a bare string, and is emitted as written for the next pass to ' + "expand. The size is a default rather than a " 'rule, because a tear-off has a fixed height and translations do not have equal ' "line counts, so a language that needs the room writes " "`(§ pictures.name(height='55mm') §)` and says so in one visible place. Whether a " diff --git a/core/builder/renderer.py b/core/builder/renderer.py index 7fb937bc..2e6661dd 100755 --- a/core/builder/renderer.py +++ b/core/builder/renderer.py @@ -430,6 +430,13 @@ def __init__(self, new_id: str, path: Path, **defaults): PICTURE_ENTRY = { 'file': str, 'height': str, + # A caption that cannot be written at the call site, because it interpolates: a `(§ … §)` + # inside a string argument ends the enclosing tag at its first `§)`. Spelled per language, or + # as a bare string where every language shares it, exactly as a `words:` term is -- and + # resolved the same way, lazily, so a language that has none simply falls back to the call + # site and then to an empty caption. The text is emitted as written and the next render pass + # expands its tags. + Opt('caption'): Or(str, {Opt(str): str}), } @@ -637,10 +644,18 @@ def build_render_context(meta: Context, locale: str, *, root: Path, labelled: bo # solution's always are, which is what stops one being referenceable in English and not in # Slovak. if 'pictures' in meta.data: + def declared_caption(entry): + caption = entry.get('caption') + if isinstance(caption, dict): + return caption.get(locale) + return caption + ctx.add(pictures={ key: Picture(f"{meta.data['id']}:{key}", entry['file'], captioned=captioned, - attributes={k: v for k, v in entry.items() if k != 'file'}) + declared_caption=declared_caption(entry), + attributes={k: v for k, v in entry.items() + if k not in ('file', 'caption')}) for key, entry in meta.data['pictures'].items() }) diff --git a/core/tests/test_renderer.py b/core/tests/test_renderer.py index 7914a1b3..92540405 100644 --- a/core/tests/test_renderer.py +++ b/core/tests/test_renderer.py @@ -553,6 +553,43 @@ def test_a_wrapped_caption_is_emitted_on_one_line(self, tmp_path): "(§ pictures.hanging('first line\nsecond line') §)\n", 'solution.md') assert '![first line second line](hanging.png){#fig:' in out + DECLARED_META = ("authors:\n idea: []\n problem: []\n solution: []\n" + "tags: ['kinematics']\n" + "values:\n Imax:\n magnitude: 2\n unit: 'ampere'\n" + "pictures:\n hanging:\n file: hanging.png\n height: 40mm\n" + " caption:\n sk: 'viac ako (§ Imax §)'\n" + " en: 'more than (§ Imax §)'\n") + + def test_a_declared_caption_may_interpolate(self, tmp_path): + r""" + The one thing a call-site caption cannot do. A caption is passed as a string argument, and + a `(§ … §)` inside a string literal ends the enclosing tag at its first `§)`, so + `(§ pictures.x('… (§ Imax §)') §)` does not parse at all. + + In the meta nothing lexes it: the text is emitted as written and the *next* render pass + expands it, which is exactly how a tag inside a `blocks:` entry works. + """ + out = self.render_named(tmp_path, 'sk', self.DECLARED_META, + '(§ pictures.hanging §)\n', 'solution.md') + assert r'![viac ako \qty{2}{\ampere}](hanging.png){#fig:' in out + + def test_a_declared_caption_follows_the_language(self, tmp_path): + out = self.render_named(tmp_path, 'en', self.DECLARED_META, + '(§ pictures.hanging §)\n', 'solution.md') + assert '![more than ' in out + + def test_the_call_site_beats_the_meta(self, tmp_path): + """The call site stays the normal place for a caption; the meta is the exception.""" + out = self.render_named(tmp_path, 'sk', self.DECLARED_META, + "(§ pictures.hanging('written here') §)\n", 'solution.md') + assert '![written here]' in out + + def test_a_statement_ignores_a_declared_caption_too(self, tmp_path): + """The rule is about the file, not about where the caption was written.""" + out = self.render_named(tmp_path, 'sk', self.DECLARED_META, + '(§ pictures.hanging §)\n', 'problem.md') + assert out.strip() == '![](hanging.png){height=40mm}' + def test_core_itself_has_no_opinion(self): """Which file is a statement is the module's taxonomy, as it is for equation numbering.""" from core.builder.renderer import CLIInterface as CoreCLI diff --git a/docs/filters.md b/docs/filters.md index 4d85c56a..bf8a5bee 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -1551,18 +1551,21 @@ are expanded by the second pass, so a preamble can write `tcold = (§ tcold.mag ### `pictures:` -`pictures: {name: {file: , height: }}` +`pictures: {name: {file: , height: , caption: {lang: text}}}` A figure, hoisted out of the per-language files the way `eq:` is. Reached as `(§ pictures.name §)`; **the key becomes the label**, `{#fig::}`, and nobody types one. Both keys are required and there are no others. The caption is **not** here: it is prose, it is -translated, and it goes at the call site -- `(§ pictures.name('Lano cez kladku') §)`. The size is a -default rather than a rule, because a tear-off has a fixed height and translations do not have equal -line counts, so a language that needs the room writes `(§ pictures.name(height='55mm') §)` and says -so in one visible place. Whether a figure is captioned -- and so numbered, and so referenceable -- -is decided by the file being rendered, not at the call site: a statement never captions its figures -and a solution always does, including where the caption is empty. +translated, and it goes at the call site -- `(§ pictures.name('Lano cez kladku') §)` -- except where +it *interpolates*, which a string argument cannot express at all, since a `(§ … §)` inside one ends +the enclosing tag at its first `§)`. That caption goes under `caption:`, per language or as a bare +string, and is emitted as written for the next pass to expand. The size is a default rather than a +rule, because a tear-off has a fixed height and translations do not have equal line counts, so a +language that needs the room writes `(§ pictures.name(height='55mm') §)` and says so in one visible +place. Whether a figure is captioned -- and so numbered, and so referenceable -- is decided by the +file being rendered, not at the call site: a statement never captions its figures and a solution +always does, including where the caption is empty. ### `authors:` From 699938ba8620686251831b975c8fe1ef6fab222c Mon Sep 17 00:00:00 2001 From: 084intheImpala Date: Sat, 17 Oct 2026 21:32:04 +0100 Subject: [PATCH 6/7] \insertPicture cannot open an empty line above its picture graphicx's scaling is not exact, so a picture bound by `width=\linewidth` can come out a hundredth of a point wider than the line -- under \hfuzz, so nothing warns -- and then no longer fits beside `center`'s empty \item label. TeX breaks after the label and an empty centred line, a whole \baselineskip, opens above the drawing. 29/amusement-speed lost 5 mm to it and ran off its pt tear-off page; 29/chopper had the same gap. The picture now sits in a \makebox exactly \linewidth wide, so it can never be wider than the line. Pictures bound by their height are unaffected. Co-Authored-By: Claude Opus 5.5 --- core/latex/utilities.tex | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/core/latex/utilities.tex b/core/latex/utilities.tex index c24fbc32..c61fa61d 100644 --- a/core/latex/utilities.tex +++ b/core/latex/utilities.tex @@ -27,12 +27,19 @@ \unskip{\tiny\dotfill} } +% The \makebox is exactly \linewidth wide whatever it holds. graphicx's scaling is not +% exact, so `width=\linewidth` can come out a hundredth of a point wider than the line, +% too little for an overfull warning but enough that the picture no longer fits beside +% `center`'s empty \item label: TeX breaks after the label, and an empty centred line -- +% a whole \baselineskip -- opens above the drawing. `29/amusement-speed` lost 5 mm to it. \NewDocumentCommand{\insertPicture}{O{} m}{% \begin{center} - \IfFileExists{\activeDirectory/#2}{% - \includegraphics[#1]{\activeDirectory/#2}% - }{% - \includegraphics[#1]{example-image}% + \makebox[\linewidth]{% + \IfFileExists{\activeDirectory/#2}{% + \includegraphics[#1]{\activeDirectory/#2}% + }{% + \includegraphics[#1]{example-image}% + }% }% \end{center} } From aa8916633ececad92257aff8aa937607903b88f9 Mon Sep 17 00:00:00 2001 From: 084intheImpala Date: Sun, 18 Oct 2026 00:11:16 +0100 Subject: [PATCH 7/7] Venue instructions: build instructions-inner from the language folder The venue builder joined source/naboj onto a launch directory that already is source/naboj, so instructions-inner was silently skipped. The make rules now look for instructions-inner.jtex (not .md) and rebuild the venue when it changes, and the venue keeps its own start time instead of the volume's. Co-Authored-By: Claude Opus 5.5 --- modules/naboj/builder/contexts/buildable.py | 3 +-- modules/naboj/builder/venue.py | 2 +- modules/naboj/module.mk | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/naboj/builder/contexts/buildable.py b/modules/naboj/builder/contexts/buildable.py index daf5ba49..dee50adb 100644 --- a/modules/naboj/builder/contexts/buildable.py +++ b/modules/naboj/builder/contexts/buildable.py @@ -60,8 +60,7 @@ def __init__(self, root, *args): def populate(self, competition, volume, venue): super().populate(competition, volume) self.adopt( - venue=ContextVenue(self.root, competition, volume, venue) - .override('start', self.data['volume']['start']), + venue=ContextVenue(self.root, competition, volume, venue), i18n=ContextI18nGlobal(self.root, competition) ).add(language=i18n.languages[self.data['venue']['language']].as_dict()) diff --git a/modules/naboj/builder/venue.py b/modules/naboj/builder/venue.py index e739dd34..39e392e5 100644 --- a/modules/naboj/builder/venue.py +++ b/modules/naboj/builder/venue.py @@ -33,7 +33,7 @@ def language_path(self) -> tuple: def build_templates(self, *, new_name: str | None = None) -> None: super().build_templates(new_name=new_name) - language_root = Path(self.launch_directory, 'source', 'naboj', *self.language_path()) + language_root = Path(self.launch_directory, *self.language_path()) language_renderer = jinja.StaticRenderer(language_root) for template in self.language_templates: diff --git a/modules/naboj/module.mk b/modules/naboj/module.mk index 4628f810..d7695105 100644 --- a/modules/naboj/module.mk +++ b/modules/naboj/module.mk @@ -152,6 +152,7 @@ build/naboj/%/build-venue: \ $$(subst $$(cdir),,$$(abspath build/naboj/$$*/../../../copy-static)) \ $$(call naboj_problem_dirs,$$*) \ $$(NABOJ_TEMPLATES) \ + $$(wildcard $$(subst $$(cdir),,$$(abspath source/naboj/$$*/../../languages/*/instructions-inner.jtex))) \ $$(subst $$(cdir),,$$(abspath source/naboj/$$*/../../../i18n)) $(call prepare_arguments,venue) python -m modules.naboj.builder.venue 'source/naboj/' 'modules/naboj/templates/' $(word 1,$(words)) $(word 2,$(words)) $(word 4,$(words)) -o '$(dir $@)' @@ -216,7 +217,7 @@ build/naboj/%/constants.tex: \ # Instructions to be put on the table before the competition (content) # % //venues/ build/naboj/%/instructions-inner.tex: \ - $$(wildcard $$(subst $(cdir),,$$(abspath source/naboj/$$*/../../languages/*/instructions-inner.md))) \ + $$(wildcard $$(subst $(cdir),,$$(abspath source/naboj/$$*/../../languages/*/instructions-inner.jtex))) \ build/naboj/$$*/build-venue ; # Instructions to be put on the table before the competition (full document)