diff --git a/CLAUDE.md b/CLAUDE.md index 541d07b8..830a2f0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -567,6 +567,120 @@ 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 + `§)`. 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 +`![…]` 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/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..d3039511 --- /dev/null +++ b/core/builder/context/quantities/picture.py @@ -0,0 +1,134 @@ +import re + + +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, + 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 + #: `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, + declared_caption=self.declared_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()) + + @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. + """ + # 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: + # 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 + # 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..dc7187e8 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,24 @@ 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: , 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') §)` -- 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.'), 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 +1242,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..2e6661dd 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,27 @@ 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, + # 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}), +} + + 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 +501,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 +523,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 +635,30 @@ 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: + 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, + 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() + }) + return ctx @@ -618,6 +680,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 +726,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/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/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} } 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/core/tests/test_renderer.py b/core/tests/test_renderer.py index 988b78f6..92540405 100644 --- a/core/tests/test_renderer.py +++ b/core/tests/test_renderer.py @@ -467,6 +467,181 @@ 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_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 + + 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 + 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..bf8a5bee 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,24 @@ 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: , 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') §)` -- 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:` `authors: {idea: [...], problem: [...], solution: [...]}` 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/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/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() 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) 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