From c2c4875c0b210acee5cde219968fcb25a6bcbc29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 09:47:03 +0000 Subject: [PATCH] Poster sections say what they are in braces; no heading name is special {.stats}, {.abstract}, {.summary} and {.references} replace the headings Stats, Abstract, Summary and References, which are now ordinary sections. The heading text is the writer's own, and every part is placed where it is in the file, so a stats strip can sit halfway down. {cols=N} on a section gives its band its own number of columns, so one band can have two and the next three. colour= also colours an abstract's box and a summary's banner. The example posters, the tests and both poster chapters use the new form. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MSgoaW6yVJY8XteKGYy9GB --- docs/guide/12_posters.md | 67 ++++++------ docs/guide/13_writing_posters.md | 35 +++--- docs/requirements/01_functional.md | 1 + docs/requirements/04_traceability.md | 4 +- examples/poster/landscape.md | 6 +- examples/poster/poster.md | 27 ++++- examples/poster/release.md | 8 +- examples/poster/reliability.md | 8 +- src/py_doc/poster.py | 154 ++++++++++++++------------- tests/test_poster.py | 53 +++++++-- 10 files changed, 215 insertions(+), 148 deletions(-) diff --git a/docs/guide/12_posters.md b/docs/guide/12_posters.md index f18916e..416b317 100644 --- a/docs/guide/12_posters.md +++ b/docs/guide/12_posters.md @@ -28,7 +28,7 @@ The examples in `examples/poster/` show four kinds, and `examples/py-doc.yml` li | Example | What it shows | |---|---| -| `poster.md` | Portrait A1 in the default theme: a picture, a Mermaid diagram, bar and line charts, segments, flow, lanes and `lcd` screens. Also a timeline, a JSON code block and a full-width section. A QR code is in the footer. Every setting is in its front matter | +| `poster.md` | Portrait A1 in the default theme: a picture, a Mermaid diagram, bar and line charts, segments, flow, lanes and `lcd` screens. Also a timeline, a JSON code block, a full-width section, and a band of three columns (`{cols=3}`) under a band of two. A QR code is in the footer. Every setting is in its front matter | | `landscape.md` | Landscape A1 in three columns, in the theme of a made-up company, with its web fonts inside the page (`embed_fonts: [latin]`) and a partner logo in the footer. A numbered Mermaid sequence from `diagrams.mermaid`, a gallery of mounting drawings, an acceptance checklist, handover steps, a QR block and an included table. The words and the layout (orientation, paper) are in the front matter, and the company's look (theme, header, footer) is in `py-doc.yml` | | `release.md` | Portrait A3 in the dark theme: a column chart with two series, a before and after `compare` and a Mermaid sequence with notes. Also title cards, a vertical timeline, callouts, `people`, references and a `{{version}}` variable | | `reliability.md` | Portrait A0 in the print theme, in three columns set by `columns` and `split`, with the logo on the right and `version: git`. PlantUML, Graphviz, Vega-Lite and ditaa (PNG) diagrams, a `figure` block, a line chart with `max`, `height` and `colours`, `highlight` figures, `progress` bars, `[[INCLUDE:]]`, `[[INCLUDE-CODE:]]`, `[[IF:]]` and `[[NOT:]]` with `conditions` from `py-doc.yml`, and table and figure captions | @@ -94,53 +94,48 @@ A mistake in `py-doc.yml`, such as an unknown key or a mapping where a value sho ## Page structure -A poster is laid out from the top down, always in this order: +A poster is its header, then its sections in the order of the file, then its footer. What each section is, and how wide, is said in braces at the end of its heading. No heading name has a meaning of its own, so the headings are yours to choose. -1. **Header**: `title`, `subtitle`, `byline` and `logo` from the settings. Without a title, the first `#` heading is the title. -1. **Stats** and **abstract**: `## Stats` and `## Abstract`, full width, wherever they are in the file. -1. **The bands**, in the order of the sections. A section whose heading ends in `{.wide}` is a full-width band of its own. The sections between two wide ones are a band of columns, balanced by height. -1. **Summary** and **references**: `## Summary` and `## References`, full width, wherever they are in the file. -1. **Footer**: from the `footer` setting, full width at the bottom. - -So a poster that goes full width, two columns, full width, two columns, full width is written as: - -```markdown -## 1. Overview {.wide} -## 2. Left of the first band -## 3. Right of the first band -## 4. Results {.wide} -## 5. Left of the second band -## 6. Right of the second band -## 7. Next steps {.wide} -## Summary -``` +| Braces | The section is | +|---|---| +| None | A section in a band of columns | +| `{.wide}` | A section that spans every column, a band of its own | +| `{.stats}` | The strip of figures: a table of value, label, and optionally a change and a verdict. The header row is ignored | +| `{.abstract}` | The introduction box | +| `{.summary}` | The banner, from its first paragraph, then a block for each `###` | +| `{.references}` | A list in small type, in two columns, under its own heading | -`columns:` sets how many columns every band of columns has. +A stats strip, abstract, summary or references list is full width and has no heading bar: the heading names it in the file, and only a references list shows it. Each goes where it is in the file, so a second stats strip can sit halfway down. -| Heading | Becomes | -|---|---| -| `## Stats` | A table of value, label, and optionally a change and a verdict, shown as the strip of figures. The header row is ignored | -| `## Abstract` | The introduction box | -| `## 1. Heading: accent` | A section. The text after the first colon is coloured | -| `## Summary` | The first paragraph is the banner, and each `###` is a block | -| `## References` | A list in small type above the footer, in two columns | +The other braces change one section: -A section heading can end with attributes in braces: +- `{cols=3}` gives its band of columns three columns. Give it on any one section of the band. Without it, the band has `columns:` columns. +- `{.flow}` lets the section go on in the next column, between two of its blocks, under its heading again with "(continued)". Use it for a section much taller than the rest. A caption stays with its picture or table. +- `{colour=coral}` colours its heading bar, or the box of an abstract or the banner of a summary, with any block colour. -- `{.wide}` makes the section span every column. The sections before it are balanced into the columns above it, and those after it into the columns below. `split` cannot be used with a wide section. -- `{.flow}` lets the section go on in the next column, between two of its blocks, under its heading again with "(continued)". Use it for a section much taller than the rest. A caption stays with its picture or table. It has no effect with `split` or on a wide section. -- `{colour=coral}` colours its heading bar, with any block colour. +The sections between two full-width parts are a band of columns, balanced by height. Of every way to share them out, in order, the page takes those whose tallest column is shortest, and of those the one whose columns are most even. `split` chooses the columns by hand instead, on a poster with one band of columns. -Of every way to share the sections out, in order, the page takes those whose tallest column is shortest, and of those the one whose columns are most even. +So a poster that goes full width, two columns, full width, three columns, then a summary is written as: ```markdown -## 5. Roadmap: what comes next {.wide} -## 6. Troubleshoot: when a node will not join {.wide colour=slate} +## Key figures {.stats} +## Why this matters {.abstract} +## 1. Overview {.wide} +## 2. Left of the first band {cols=2} +## 3. Right of the first band +## 4. Results {.wide colour=slate} +## 5. First of three {cols=3} +## 6. Second of three +## 7. Third of three +## In short {.summary} +## Sources {.references} ``` +The header comes from the `title`, `subtitle`, `byline`, `logo` and `header` settings, and the footer from the `footer` setting. They have no section, because they are the same on every page of a project. Without a `title`, the first `#` heading is the title. In a section heading, the text after the first colon is coloured. + ### Trends -A third column in `## Stats` is a change, shown under the label with an arrow. A change that starts with `+` or `up` points up, one that starts with `-` or `down` points down, and anything else points sideways. Up and sideways are good news (`good` colour) and down is bad news (`bad` colour), unless a fourth column says `good` or `bad`: +A third column in a `{.stats}` table is a change, shown under the label with an arrow. A change that starts with `+` or `up` points up, one that starts with `-` or `down` points down, and anything else points sideways. Up and sideways are good news (`good` colour) and down is bad news (`bad` colour), unless a fourth column says `good` or `bad`: ```markdown | Value | Label | Change | Verdict | diff --git a/docs/guide/13_writing_posters.md b/docs/guide/13_writing_posters.md index f3e31d4..5c419e8 100644 --- a/docs/guide/13_writing_posters.md +++ b/docs/guide/13_writing_posters.md @@ -42,18 +42,18 @@ Open `sn100.html` in a browser. Keep it open as you write: build again and reloa ## The parts of the page -A poster is built from its `##` headings. Four headings are special, and every other one is a section: +A poster is built from its `##` headings, in the order you write them. A heading is an ordinary section unless the braces at its end say otherwise, and the heading text is always yours: | You write | It becomes | Advice | |---|---|---| | Front matter `title`, `subtitle`, `byline` | The header | A title of up to six words. The subtitle says why it matters | -| `## Stats` | The strip of figures under the header | Three or four figures that make the case at a glance | -| `## Abstract` | The introduction box | Two or three sentences: the problem and what the poster shows | +| `## Key figures {.stats}` | A strip of figures | Three or four figures that make the case at a glance | +| `## Introduction {.abstract}` | The introduction box | Two or three sentences: the problem and what the poster shows | | `## 1. Heading: accent` | A section, in columns | Number the sections in reading order | -| `## Summary` | The banner and the closing blocks | The one message, then one block for each thing to remember | -| `## References` | A small list above the footer | Only what a reader would look up | +| `## In short {.summary}` | The banner and the closing blocks | The one message, then one block for each thing to remember | +| `## References {.references}` | A small list | Only what a reader would look up | -The parts always appear in the order of this table, whatever order you write them in. Sections keep your order, top to bottom, then left to right. +Write them in the order of this table, as most posters go. Every part is placed where it is in the file, so you can move one, such as a second strip of figures halfway down. ## The header @@ -70,10 +70,10 @@ A left-aligned header suits a long title. A centred one suits a short title and ## The stats strip -`## Stats` is a table of a value and a label. The header row is ignored, so it can say anything: +`{.stats}` makes a table of a value and a label into the strip. The header row is ignored, so it can say anything: ```markdown -## Stats +## Key figures {.stats} | Value | Label | Change | Verdict | |---|---|---|---| @@ -88,7 +88,7 @@ Keep each value short, such as `5 years` or `0.2 °C`, so it reads from across a ## The introduction -`## Abstract` is the blue box under the stats. Write two or three sentences that a reader can take in before deciding to read on. It is ordinary Markdown, so **bold** and links work. +`{.abstract}` makes a section the blue box: `## Introduction {.abstract}`. Write two or three sentences that a reader can take in before deciding to read on. It is ordinary Markdown, so **bold** and links work. ## Sections @@ -98,17 +98,18 @@ Every other `##` heading is a section. The text before the first colon is the he ## 2. Power: where the charge goes ``` -The sections are shared out between the columns, in order: two columns for a portrait poster and three for a landscape one. The page measures each section, charts and diagrams included, and balances the columns by height. Change that with `columns:` and `split:` in the front matter: +The sections are shared out between the columns, in order: two columns for a portrait poster and three for a landscape one. The page measures each section, charts and diagrams included, and balances the columns by height. Change the number for the whole poster with `columns:` in the front matter, or for one band with `{cols=3}` on any of its sections. `split:` chooses the columns by hand, on a poster with one band: ```yaml columns: 3 split: 2, 2 # two sections in the first column, two in the second, the rest in the third ``` -Three attributes in braces after a heading change one section: +Four attributes in braces after a heading change one section: - `{.wide}` makes the section span every column. Use it for a timeline, a wide diagram or a table with many columns. The sections before it are balanced into columns above it, and the ones after it below. - `{.flow}` lets a long section go on in the next column, between two of its blocks, under its heading marked "(continued)". The page breaks it only when that makes the columns more even. +- `{cols=3}` gives its band of sections three columns, so one band can have two and the next three. - `{colour=red}` colours its heading bar, to mark a warning or a section that stands apart. ```markdown @@ -319,10 +320,10 @@ A local picture, `![Board](board.svg)`, is embedded in the page. A table is show ## The summary and references -`## Summary` closes the poster. Its first paragraph is the banner, so make it the one message from your plan. Each `###` under it is a block, for the two or three things a reader should take away: +`{.summary}` closes the poster: `## In short {.summary}`. Its first paragraph is the banner, so make it the one message from your plan. Each `###` under it is a block, for the two or three things a reader should take away: ```markdown -## Summary +## In short {.summary} One coin cell, five years, one message only when something changes. @@ -335,7 +336,7 @@ A reading a minute costs little, because measuring is cheap. The radio is the cost, so the node sends only what is new. ``` -`## References` is a list in small type above the footer. Number it, and cite its entries in the text as `[1]`. +`{.references}` makes a list in small type, under its own heading, such as `## References {.references}`. Number it, and cite its entries in the text as `[1]`. ## The footer @@ -454,14 +455,14 @@ footer: qr-label: Full notes --- -## Stats +## Key figures {.stats} | Value | Label | Change | Verdict | |---|---|---|---| | 5.7 years | Battery life | +14 % | | | 4 s | Typical join | -7 s | good | -## Abstract +## Introduction {.abstract} Firmware 2.1 sends less, joins faster and can always go back to the last version that worked. @@ -484,7 +485,7 @@ Roll back | A failed self-test returns to the old copy | amber > **Warning:** do not update a node whose battery is below 2.4 V. -## Summary +## In short {.summary} Install 2.1 everywhere: it costs one message and saves months of battery. diff --git a/docs/requirements/01_functional.md b/docs/requirements/01_functional.md index 9abd133..4fba1a2 100644 --- a/docs/requirements/01_functional.md +++ b/docs/requirements/01_functional.md @@ -124,6 +124,7 @@ title: Functional requirements | FR-157 | `version: git` on a poster shall take its version from Git, as on a document. | Could | Met | | FR-158 | `py-doc lint` and `py-doc build --lint` shall lint the posters of `py-doc.yml` with every rule but the document structure, acronym and passive voice rules. | Should | Met | | FR-159 | `py-doc poster --watch` shall build the posters again on every save of their sources and reload them in the browser, and show a failed build's error over the last good page. | Could | Met | +| FR-160 | A poster's sections shall say in braces what they are and how they are laid out: `{.stats}`, `{.abstract}`, `{.summary}`, `{.references}`, `{.wide}` and `{cols=}`. No heading name shall have a meaning of its own, and every part shall be placed in the order of the file. | Should | Met | ## Tool container diff --git a/docs/requirements/04_traceability.md b/docs/requirements/04_traceability.md index b391a97..0fb9945 100644 --- a/docs/requirements/04_traceability.md +++ b/docs/requirements/04_traceability.md @@ -19,7 +19,7 @@ title: Traceability | FR-080 to FR-086, QR-081 | `c4/` | `test_c4.py` | | FR-100 to FR-106 | `lint/`, `markdown/` | `test_lint.py`, `test_lint_cli.py`, `test_structure.py`, `test_vale.py`, `test_markdownlint.py`, `test_markdown_ast.py` | | FR-120 to FR-127, QR-082 | `site/`, `pandoc/resources/site.lua` | `test_site.py` | -| FR-130 to FR-139, FR-150 to FR-159 | `poster.py`, `poster_blocks.py`, `poster_fonts.py`, `poster_settings.py`, `poster_themes.py`, `poster_cli.py`, `poster_watch.py`, `config.py`, `app.py`, `lint/linter.py`, `diagrams/tools/poster_pdf.js` | `test_poster.py`, `test_poster_browser.py` | +| FR-130 to FR-139, FR-150 to FR-160 | `poster.py`, `poster_blocks.py`, `poster_fonts.py`, `poster_settings.py`, `poster_themes.py`, `poster_cli.py`, `poster_watch.py`, `config.py`, `app.py`, `lint/linter.py`, `diagrams/tools/poster_pdf.js` | `test_poster.py`, `test_poster_browser.py` | | FR-028 | `diagrams/render.py`, `config.py` | `test_diagrams.py` | | FR-128 | `site/pages.py`, `site/engines.py` | `test_site.py` | | FR-140 to FR-143, QR-040 to QR-043 | `runtime/`, `tools_cli.py` | `test_runtime.py`, `test_image.py`, `test_setup.py`, `test_doctor.py`, `test_platform.py` | @@ -51,7 +51,7 @@ The issues of the repository, as reviewed on 18 October 2026, and the requiremen | #48 | Site navigation from document metadata | FR-122 | Done: navigation from the documents, their titles and order, `site.groups`, and a hand-made navigation kept | | #149 | HTML posters from Markdown: py-doc poster, themes and orientation | FR-130 to FR-135 | Done: `py-doc poster`, portrait and landscape, columns, built-in and file themes, examples | | #150 | Poster: full-width sections | FR-137 | Done: `{.wide}` on a section heading, and `{colour=}` for its heading bar | -| #151 | Poster: callout types, timeline, trend stats and references | FR-136, FR-137 | Done: callouts in both spellings, `timeline`, trends in `## Stats`, `## References`, and `chart` | +| #151 | Poster: callout types, timeline, trend stats and references | FR-136, FR-137 | Done: callouts in both spellings, `timeline`, trends in the `{.stats}` strip, a `{.references}` list, and `chart` | | #152 | Poster: QR code and partner logos in the footer | FR-137 | Done: `footer.qr`, `footer.logos`, and a `qr` block | | #153 | Poster: embed theme fonts so a poster looks the same offline | FR-150 | Done: `fonts.files` in a theme, and `embed_fonts` (or `--embed-fonts`) for the stylesheet's web fonts, cached | | #161 | A host runtime, to build with the tools on the PATH instead of the container | None yet | Open, low priority | diff --git a/examples/poster/landscape.md b/examples/poster/landscape.md index 34950b0..089a4ec 100644 --- a/examples/poster/landscape.md +++ b/examples/poster/landscape.md @@ -6,7 +6,7 @@ orientation: landscape # the sections are written for three columns, so the lay paper: a1 --- -## Stats +## Key figures {.stats} | Value | Label | |---|---| @@ -15,7 +15,7 @@ paper: a1 | -95 dBm | Weakest signal accepted | | 0 | Tools beyond a phone and a ladder | -## Abstract +## Introduction {.abstract} A network of SN-100 nodes reports through gateways to an MQTT broker. Commissioning places the gateways, joins each node, checks its signal and readings, and records where it is. This poster is the checklist the field team follows. @@ -135,7 +135,7 @@ https://example.com/commissioning/upload | `E01` | The sensor is not found: return the node, do not open it | | `RETRY 5/5` | The broker did not answer: check the gateway's Ethernet, then call support | -## Summary +## In short {.summary} Plan the gateways, fit them first, join each node and record where it is. diff --git a/examples/poster/poster.md b/examples/poster/poster.md index 1ff5b3f..3dab08c 100644 --- a/examples/poster/poster.md +++ b/examples/poster/poster.md @@ -13,7 +13,7 @@ footer: qr-label: Datasheet --- -## Stats +## Key figures {.stats} | Value | Label | Change | Verdict | |---|---|---|---| @@ -22,7 +22,7 @@ footer: | 0.2 °C | Temperature accuracy | | | | 1 KB | Firmware update block | | | -## Abstract +## Introduction {.abstract} The SN-100 measures temperature and humidity and publishes each reading over MQTT. It sleeps between readings, so one coin cell lasts five years. This poster shows how it wakes, measures, sends and goes back to sleep. @@ -144,7 +144,28 @@ Jul 2026 | Display option | For installers | green Oct 2026 | Ten-year cell | Twice the life | amber ``` -## Summary +## 8. SN-100-T: temperature only {cols=3} + +Order code `SN100-T-868`. For rooms where only the temperature matters, such as server rooms. + +- Temperature, ±0.2 °C +- Cell life 6.5 years + +## 9. SN-100-TH: temperature and humidity + +Order code `SN100-TH-868`. The standard node, and the one this poster describes. + +- Humidity, ±1.8 % RH +- Cell life 5 years + +## 10. SN-100-THD: with a display + +Order code `SN100-THD-868`. For installers and for rooms a person checks by eye. + +- The display of section 5 +- Cell life 4.5 years + +## In short {.summary} One coin cell, five years, one message only when something changes. diff --git a/examples/poster/release.md b/examples/poster/release.md index 5f2f6e8..f681138 100644 --- a/examples/poster/release.md +++ b/examples/poster/release.md @@ -14,7 +14,7 @@ paper: a3 width: 1100 --- -## Stats +## Key figures {.stats} | Value | Label | Change | Verdict | |---|---|---|---| @@ -23,7 +23,7 @@ width: 1100 | 2 | Firmware copies kept | +1 | | | 0 | Failed updates in the pilot | -3 | good | -## Abstract +## Introduction {.abstract} Firmware 2.1 sends less, joins faster and can always go back to the last version that worked. @@ -129,7 +129,7 @@ Lena Chen | Firmware release manager | sn100-firmware@example.com Ruth Osei | Field support lead | +44 1632 960 100 ``` -## Summary +## In short {.summary} Install 2.1 everywhere: it costs one message and saves months of battery. @@ -141,7 +141,7 @@ Nothing changes on site. Joining is quicker. Every update reports its result, so a failed node is visible at once. -## References +## References {.references} 1. SN-100 Datasheet, SN-DS-001. 1. SN-100 release notes, firmware {{version}}. diff --git a/examples/poster/reliability.md b/examples/poster/reliability.md index 8254ec8..4667310 100644 --- a/examples/poster/reliability.md +++ b/examples/poster/reliability.md @@ -17,7 +17,7 @@ footer: logos: [brand/test-lab.svg] --- -## Stats +## Key figures {.stats} | Value | Label | Change | Verdict | |---|---|---|---| @@ -26,7 +26,7 @@ footer: | 61 | Nodes returned this quarter | -18 | good | | 5.2 years | B10 cell life | +0.4 years | | -## Abstract +## Introduction {.abstract} This review covers every SN-100 node in service from 1 July to 30 September 2026. The return rate fell for the third quarter running, to 0.51 % a year, just above the 0.50 % target. Condensation in cold stores is now the largest single cause, and a coated sensor ships from batch 26-40. Life tests put the B10 cell life at 5.2 years, so nine cells in ten outlast the five years on the datasheet. @@ -247,7 +247,7 @@ Nodes with a coated sensor ship from November. Nothing changes on site, and your https://example.com/reliability/sn-rr-2026-q3 ``` -## Summary +## In short {.summary} The return rate is at target, the cell outlasts its promise, and the one growing cause has a fix in production. @@ -263,7 +263,7 @@ Every return is torn down. Only one in eight finds no fault. One coating removes both ways condensation stops a node. -## References +## References {.references} 1. SN-100 Datasheet, SN-DS-001, revision C. 1. SN-100 field reliability review, Q2 2026, SN-RR-2026-Q2. diff --git a/src/py_doc/poster.py b/src/py_doc/poster.py index 9218016..5bf6eef 100644 --- a/src/py_doc/poster.py +++ b/src/py_doc/poster.py @@ -354,26 +354,42 @@ def heading_html(h): return esc(h) +ROLES = ("stats", "abstract", "summary", "references") # a section that is one of these says so: ## Key figures {.stats} + + def heading_attributes(heading, where): - """A section heading and its ``{.wide .flow colour=red}``: whether it spans every column, whether it may - go on in the next column, and its colour.""" + """A section heading and what its braces say: ``{.stats}`` and the other roles, ``{.wide}``, ``{.flow}``, + ``{cols=3}`` and ``{colour=red}``. Returns the heading without them, and a mapping of them.""" + attrs = {"role": "section", "wide": False, "flow": False, "colour": None, "cols": None} m = _ATTRIBUTES.search(heading) if not m: - return heading.strip(), False, None, False - wide, colour, flow = False, None, False - for item in parse_opts(m["attrs"]): - if item == ".wide": - wide = True - elif item == ".flow": - flow = True - elif item == "colour" or item == "color": - colour = parse_opts(m["attrs"])[item] + return heading.strip(), attrs + text, options = heading[: m.start()].strip(), parse_opts(m["attrs"]) + for item, value in options.items(): + if item.startswith(".") and item[1:] in ROLES: + if attrs["role"] != "section": + raise PosterError(f"{where}: heading '{text}' has two roles, .{attrs['role']} and {item}: give it one") + attrs["role"] = item[1:] + elif item in (".wide", ".flow"): + attrs[item[1:]] = True + elif item in ("colour", "color"): + attrs["colour"] = value + elif item == "cols": + if not value.isdigit() or int(value) < 1: + raise PosterError(f"{where}: heading '{text}' has cols={value}; it must be a whole number of at least 1") + attrs["cols"] = int(value) else: - logger.warning(f"{where}: heading '{heading.strip()}' has '{item}', which a poster does not use") - if wide and flow: - logger.warning(f"{where}: heading '{heading.strip()}' is wide, so .flow is ignored: a wide section is never split") - flow = False - return heading[: m.start()].strip(), wide, colour, flow + known = ", ".join([*(f".{r}" for r in ROLES), ".wide", ".flow", "cols=", "colour="]) + logger.warning(f"{where}: heading '{text}' has '{item}', which a poster does not use. It takes {known}") + if attrs["role"] != "section": + for name in ("wide", "flow", "cols"): # a stats strip, abstract, summary or references list is always full width + if attrs[name]: + logger.warning(f"{where}: heading '{text}' is .{attrs['role']}, which is always full width, so {name} is ignored") + attrs[name] = False if name != "cols" else None + elif attrs["wide"] and (attrs["flow"] or attrs["cols"]): + logger.warning(f"{where}: heading '{text}' is wide, so .flow and cols= are ignored: a wide section has no columns") + attrs["flow"], attrs["cols"] = False, None + return text, attrs def parse_stats(text): @@ -497,68 +513,66 @@ def build(md_path, runtime: Callable[[], Runtime], settings: dict | None = None, title = meta.get("title") or (h1.group(1) if h1 else md_path.stem) parts = re.split(r"(?m)^## +(.+)$", body) - stats, abstract, summary, references, sections = [], "", None, "", [] - for heading, content in zip(parts[1::2], parts[2::2]): - heading, wide, colour, flow = heading_attributes(heading, md_path) - key = heading.lower() - if key == "stats": - stats = parse_stats(content) - elif key == "abstract": - abstract = render(content.strip(), ctx) - elif key == "summary": - summary = content - elif key == "references": - references = render(content.strip(), ctx) - else: - sections.append((heading, content.strip(), {"wide": wide, "colour": colour, "flow": flow})) - if not sections: + items = [(*heading_attributes(heading, md_path), content.strip()) for heading, content in zip(parts[1::2], parts[2::2])] + if not items: raise PosterError(f"{md_path}: no sections found (expected '## Heading' blocks)") orientation = _choice(meta, "orientation", ("portrait", "landscape"), md_path) count = number(meta, "columns", COLUMNS[orientation], md_path) - if meta.get("split") and any(extra["wide"] for _, _, extra in sections): - raise PosterError(f"{md_path}: split cannot be used with wide sections; the columns are balanced between them") - def sec(h, c, extra): - style = f' style="background:{ctx.theme.colour(extra["colour"])}"' if extra["colour"] else "" - wide = ' class="wide"' if extra["wide"] else ' class="breakable"' if extra["flow"] else "" + def sec(h, attrs, c): + style = f' style="background:{ctx.theme.colour(attrs["colour"])}"' if attrs["colour"] else "" + wide = ' class="wide"' if attrs["wide"] else ' class="breakable"' if attrs["flow"] else "" return f'{heading_html(h)}
{render(c, ctx)}
' - groups, run = [], [] # the column groups, and a wide section between two of them - for section in sections: - if section[2]["wide"]: - if run: - groups.append(run) - groups.append(section) + def role_html(h, attrs, c): + """A stats strip, abstract, summary or references list: full width, where it is in the file.""" + background = f' style="background:{ctx.theme.colour(attrs["colour"])}"' if attrs["colour"] else "" + if attrs["role"] == "stats": + figures = "".join( + f'
{esc(v)}{esc(label)}{trend_html(change, verdict)}
' + for v, label, change, verdict in parse_stats(c)) + return f'
{figures}
' if figures else "" + if attrs["role"] == "abstract": + return f'
{render(c, ctx)}
' + if attrs["role"] == "references": + return f'
{esc(h)}{render(c, ctx)}
' + chunks = re.split(r"(?m)^### +(.+)$", c) # the summary: a banner, then a block for each ### + banner = " ".join(chunks[0].split()) + blocks = "".join( + f'

{esc(head.strip())}

{render(text.strip(), ctx)}
' + for head, text in zip(chunks[1::2], chunks[2::2])) + return ((f'' if banner else "") + + (f'
{blocks}
' if blocks else "")) + + bands, run = [], [] # in file order: full-width parts, and runs of sections that share columns + for item in items: + if item[1]["role"] == "section" and not item[1]["wide"]: + run.append(item) + continue + if run: + bands.append(run) run = [] - else: - run.append(section) + bands.append(item) if run: - groups.append(run) + bands.append(run) + runs = [band for band in bands if isinstance(band, list)] + if meta.get("split") and len(runs) != 1: + raise PosterError(f"{md_path}: split chooses the columns of one band of sections, and this poster has {len(runs)}. " + "Leave it out, and the columns of each band are balanced for you") middle = "" - for group in groups: - if isinstance(group, tuple): - middle += sec(*group) - else: - columns = columns_of(group, count, meta.get("split"), md_path) - cols = "".join(f'
{"".join(sec(*s) for s in column)}
' for column in columns) - balanced = "" if meta.get("split") else " data-balance" # the page script shares them out by height - middle += f'
{cols}
' - - stats_html = "".join( - f'
{esc(v)}{esc(label)}{trend_html(change, verdict)}
' - for v, label, change, verdict in stats - ) - - summary_html = "" - if summary: - chunks = re.split(r"(?m)^### +(.+)$", summary) - banner = " ".join(chunks[0].split()) - blocks = "".join( - f'

{esc(h.strip())}

{render(c.strip(), ctx)}
' - for h, c in zip(chunks[1::2], chunks[2::2])) - summary_html = (f'' if banner else "") + \ - (f'
{blocks}
' if blocks else "") + for band in bands: + if isinstance(band, tuple): + middle += role_html(*band) if band[1]["role"] != "section" else sec(*band) + continue + asked = {attrs["cols"] for _, attrs, _ in band if attrs["cols"]} + if len(asked) > 1: + raise PosterError(f"{md_path}: the band of sections from '{band[0][0]}' asks for {' and '.join(map(str, sorted(asked)))} " + "columns. Give cols= once, on any of its sections") + columns = columns_of([(h, c, attrs) for h, attrs, c in band], asked.pop() if asked else count, meta.get("split"), md_path) + cols = "".join(f'
{"".join(sec(h, attrs, c) for h, c, attrs in column)}
' for column in columns) + balanced = "" if meta.get("split") else " data-balance" # the page script shares them out by height + middle += f'
{cols}
' # minimum layout width, the script widens it until the content fits width = number(meta, "width", 1400 if orientation == "portrait" else 1800, md_path, least=400) @@ -589,11 +603,7 @@ def sec(h, c, extra):
{header_html(meta, title, ctx, md_path)}
-{f'
{stats_html}
' if stats_html else ""} -{f'
{abstract}
' if abstract else ""} {middle} -{summary_html} -{f'
References{references}
' if references else ""} {footer_html(meta.get("footer") or {}, ctx)}
diff --git a/tests/test_poster.py b/tests/test_poster.py index 0110f56..d45f116 100644 --- a/tests/test_poster.py +++ b/tests/test_poster.py @@ -38,14 +38,14 @@ paper: a2 --- - ## Stats + ## Key figures {.stats} | Value | Label | |---|---| | 12 | Months | | 3 | Windows | - ## Abstract + ## Introduction {.abstract} The meter closes its **valve** on credit. @@ -88,7 +88,7 @@ A note under the lanes. ``` - ## Summary + ## In short {.summary} The valve is safe. @@ -577,10 +577,49 @@ def test_a_wide_section_spans_the_columns_and_splits_them_into_groups(tmp_path, assert page.count('

3. Wide

' in page assert page.index("2. B") < page.index("3. Wide") < page.index("4. C") - with pytest.raises(PosterError, match="split cannot be used with wide sections"): + with pytest.raises(PosterError, match="split chooses the columns of one band of sections, and this poster has 2"): build(write(tmp_path, "---\nsplit: 1\n---\n" + text), lambda: object()) +def test_every_part_is_where_it_is_in_the_file_and_braces_say_what_it_is(tmp_path, drawn): + text = ( + "## Opening {.abstract colour=navy}\n\nWhy.\n\n## 1. A {.wide}\n\nAll across.\n\n" + "## Mid-poster figures {.stats}\n\n| Value | Label |\n|---|---|\n| 4 s | Join |\n\n" + "## 2. B {cols=3}\n\nOne.\n\n## 3. C\n\nTwo.\n\n## 4. D\n\nThree.\n\n" + "## Bottom line {.summary}\n\nThe point.\n\n## Sources {.references}\n\n1. A paper.\n\n## 5. After {.wide}\n\nLast.\n" + ) + page = build(write(tmp_path, text), lambda: object())[0].split("
", 1)[1] + order = ['

Why.', "1. A", '

', '
The point.', "Sources", "5. After"] # fmt: skip + assert [page.index(part) for part in order] == sorted(page.index(part) for part in order) + assert page.count('
') == 3, "the band takes cols=3 from one of its sections" + assert "Opening" not in page and "Mid-poster figures" not in page and "Bottom line" not in page + + +def test_the_old_names_are_ordinary_sections_now(tmp_path, drawn): + page, _ = build(write(tmp_path, "## Stats\n\nNot a strip.\n\n## Summary\n\nNot a banner.\n"), lambda: object()) + assert "

Stats

" in page and "

Summary

" in page and 'class="stats"' not in page.split("
")[1] + + +@pytest.mark.parametrize( + "heading, message", + [ + ("## A {.stats .summary}", "has two roles, .stats and .summary"), + ("## A {cols=0}", "has cols=0; it must be a whole number of at least 1"), + ], +) +def test_a_heading_attribute_mistake_is_an_error(tmp_path, drawn, heading, message): + with pytest.raises(PosterError, match=message): + build(write(tmp_path, f"{heading}\n\nText.\n"), lambda: object()) + + +def test_a_band_asks_for_one_number_of_columns_and_roles_take_no_layout(tmp_path, drawn, caplog): + with pytest.raises(PosterError, match="asks for 2 and 3 columns. Give cols= once"): + build(write(tmp_path, "## A {cols=2}\n\nOne.\n\n## B {cols=3}\n\nTwo.\n"), lambda: object()) + build(write(tmp_path, "## Intro {.abstract .wide cols=2}\n\nWhy.\n\n## A\n\nOne.\n"), lambda: object()) + assert "is .abstract, which is always full width, so wide is ignored" in caplog.text + + def test_an_unknown_heading_attribute_is_a_warning(tmp_path, drawn, caplog): page, _ = build(write(tmp_path, "## A {.narrow}\n\nText.\n"), lambda: object()) assert "

A

" in page and "has '.narrow', which a poster does not use" in caplog.text @@ -617,7 +656,7 @@ def test_the_old_footer_keys_still_work(tmp_path, drawn): def test_stats_show_a_trend_whose_colour_follows_its_direction_or_verdict(tmp_path, drawn): - text = "## Stats\n\n| Value | Label | Change | Verdict |\n|---|---|---|---|\n| 5 y | Life | +14 % | |\n| 4 s | Join | -7 s | good |\n| 3 | Faults | +1 | bad |\n| 2 | Same | 0 | |\n\n## A\n\nText.\n" + text = "## Figures {.stats}\n\n| Value | Label | Change | Verdict |\n|---|---|---|---|\n| 5 y | Life | +14 % | |\n| 4 s | Join | -7 s | good |\n| 3 | Faults | +1 | bad |\n| 2 | Same | 0 | |\n\n## A\n\nText.\n" page, _ = build(write(tmp_path, text), lambda: object()) assert '▲ +14 %' in page assert '▼ -7 s' in page @@ -812,7 +851,7 @@ def test_a_qr_block_draws_the_code_with_its_label(tmp_path, drawn): def test_references_are_a_list_above_the_footer(tmp_path, drawn): - page, _ = build(write(tmp_path, "## A\n\nText [1].\n\n## References\n\n1. SN-100 datasheet, SN-DS-001.\n1. MQTT 5.0, OASIS.\n"), lambda: object()) + page, _ = build(write(tmp_path, "## A\n\nText [1].\n\n## References {.references}\n\n1. SN-100 datasheet, SN-DS-001.\n1. MQTT 5.0, OASIS.\n"), lambda: object()) refs = page.split('
', 1)[1] assert refs.startswith("References
    ") and "
  1. MQTT 5.0, OASIS.
  2. " in refs assert refs.index("
") < refs.index("