Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<pid>:<key>` -- 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
Expand Down
3 changes: 2 additions & 1 deletion core/audit/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion core/builder/context/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
134 changes: 134 additions & 0 deletions core/builder/context/quantities/picture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import re


class Picture:
r"""
A figure declared once in `pictures:` and included as `(§ pictures.<key> §)`.

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:<pid>:<key>}`, 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__())
12 changes: 10 additions & 2 deletions core/builder/convertor.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,16 @@ class Convertor:
RegexReplacement(r"\\includegraphics(?P<options>\[.*\])?{(?P<stem>.*)\.(?P<extension>png|jpg|pdf)}",
r"\\insertPicture\g<options>{\g<stem>.\g<extension>}",
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/"
Expand Down
Loading
Loading