-
Notifications
You must be signed in to change notification settings - Fork 35
Add puzzle story source (Sudoku, Binoxxo, Futoshiki, Kakuro, Shikaku) with solutions in the appendix and optional explanations - #124
Conversation
All 7 branches now have real PRs open against j6k4m8/goosepaper (j6k4m8#118-j6k4m8#124, two already merged) - added a PR column to the branch table so it's clear at a glance what's been submitted where.
## Summary - `Goosepaper.get_stories(deduplicate=...)` already existed, but nothing else ever passed a value through: `_render_html_document()` (used by both `to_html()` and `to_pdf()`) calls `self.get_stories()` with no arguments, so the option was unreachable except by calling `get_stories()` directly. - Add a `deduplicate` constructor argument that `get_stories()` now falls back to when its own parameter is omitted, so `to_html()`/`to_pdf()` honor it too. - Default unchanged (`False`), so existing behavior is untouched unless a caller opts in. ## Why this matters beyond this PR This closes a gap that #124 (puzzle-explanations) currently has: #124's `"footer"`/`"appendix"` explanation modes rely on `Goosepaper.get_stories(deduplicate=True)` to collapse a rules blurb requested by several sources of the same puzzle type down to one copy. #124's own tests call `get_stories(deduplicate=True)` directly to verify this, but neither `goosepaper/__main__.py` nor `to_html()`/`to_pdf()` pass `deduplicate=True` through today - so a real user generating a paper via the CLI/Docker wouldn't actually get that dedup behavior, only a caller that bypasses `to_html()`/`to_pdf()` and calls `get_stories()` directly would. This PR is what makes that dedup reachable from a real render, e.g. `Goosepaper(providers, deduplicate=True).to_pdf(...)`. ## Test plan - [x] `pytest goosepaper/test_goosepaper.py` - 15 passed (13 existing + 2 new: `to_html()` deduplicates when the constructor flag is set; doesn't by default) - [x] Full suite (`pytest`) - 80 passed
j6k4m8
commented
Aug 8, 2026
Smengerl
commented
Aug 9, 2026
Combined with #123, and a full merge-order check across all open PRs
Consolidation: this PR (feature/puzzle-explanations) was branched off #123's branch (feature/puzzle-provider) and has been kept in sync with it via merge ever since — git diff origin/feature/puzzle-provider origin/feature/puzzle-explanations -- goosepaper/puzzlegen is empty, so this PR already fully contains #123's code. No rebase/squash needed to combine them; I've closed #123 as superseded by this one. This PR is also conflict-free against current master.
Why post an order here at all: combining two PRs into one changes the shape of the queue, so before doing that I checked all 13 currently-open PRs against each other — not just "do they touch the same file" but an actual local git merge --no-commit per pair, to find real conflicts. Posting the full result here since this PR is now the biggest node in that graph; I've left a short pointer comment on each other affected PR linking back to this one.
Conflict clusters found (real merge conflicts, not just shared files)
A. styles.py — #120 ↔ #126 (both insert a new CSS block at the same anchor point)
B. storyprovider/rss.py + test_rss.py — #135, #134, #129, #128, #127, #121
Conflicting pairs: (135,134), (135,127), (129,121), (128,127), (128,121), (127,121)
C. config.py/test_config.py/test_utils.py/util.py/README.md (each adds a new source-type registration to the same schema dict) — #133, #132, this PR, #121
Every pair conflicts: (133,132), (133,this), (133,121), (132,this), (132,121), (this,121)
#136 also touches config.py (DeliverySettings) but a different section entirely — zero conflicts with anything, despite the shared file.
#121 is the hub: it's in both cluster B and C, conflicting with 6 of the other 12 PRs. Merging it last in both clusters means it absorbs one final rebase instead of forcing repeated conflicts onto its 6 siblings one at a time. This is inherent to the overlapping code (13 conflicting pairs total, independent of ordering) — the order below is about predictability, not eliminating conflicts that can't be eliminated.
Suggested merge order
- feat: add retention_keep_last_n/retention_prefix delivery settings #136 , feat: clean two-level PDF outline (sections/headlines) by default, configurable #131 — no dependencies on anything else, merge whenever
- fix: wrap long code blocks instead of letting them overflow the column #120 → fix: hide interactive <button> elements in print/PDF output #126 — styles.py pair
- feat: optionally prefer the feed's own title over readability's extracted title #129 → Work around a headline duplicated inside readability's extracted article body #134 → fix(rss): resolve relative URLs in readability-extracted article bodies #135 → fix: don't drop an entire feed's stories when one entry fails to fetch #128 → fix: fall back to sniffed encoding when a page omits an explicit charset #127 — rss.py-only PRs
- feat: add "comic" source type for daily comic strips (XKCD, gocomics.com, arcamax.com) #133 → feat: add SectionProvider wrapper for tagging any provider's stories with a section #132 → this PR (Add puzzle story source (Sudoku, Binoxxo, Futoshiki, Kakuro, Shikaku) with solutions in the appendix and optional explanations #124 ) — config.py source-schema cluster
- Add native content_filters/skip_title_patterns to "rss" sources #121 last — touches both clusters B and C
Happy to rebase this PR (or any of the others) at whatever point in that order works for you — just say the word.
Smengerl
commented
Aug 9, 2026
Correction to my comment above: the "conflict-free against current master" line was wrong — I'd tested against my fork's own stale master mirror, not j6k4m8/goosepaper:master (the actual PR base). Against the real base, GitHub reports this PR as dirty (real conflict), caused by #138 (register_story_provider, merged 2026年08月08日 — not one of my PRs) inserting a test in test_config.py at the same spot this PR's own new tests land. Trivial to resolve with a rebase, and unrelated to the sibling-PR conflict clusters/order described above — those were tested branch-to-branch directly, not against master, so they're unaffected by this. I'll rebase this PR once we're ready to act on the merge order.
Smengerl
commented
Aug 11, 2026
Fixed a real rendering bug in Kakuro clue cells: the right-sum ("h") was rendering bottom-right of its clue cell instead of top-right, and the down-sum ("v") top-left instead of bottom-left - backwards from the standard Kakuro convention (right-sum upper-right, down-sum lower-left). Root cause: WeasyPrint inverts the vertical axis for an absolutely-positioned span nested inside a table cell this way (position:relative div inside a position:relative td) - top: 0 renders at the visual bottom, bottom: 0 at the visual top. Confirmed by rendering an isolated, distinctly-labelled clue cell before/after. Fix: swap top/bottom between .kakuro-h/.kakuro-v (left/right unaffected).
...English No need for the fork-only German localization - switch the rule blurbs, " - Solution" suffix, and "How does X work?" explanation headline to match what PR j6k4m8#124 already uses upstream (English, unlocalized). Also add docs/reference/storyprovider/puzzle.py.md (copied from j6k4m8#124) and point the README's Logic Puzzles link at it, matching every other source type's docs convention instead of the inline "see below" section.
...Shikaku)
New "puzzle" source type: generates a puzzle grid (and its solution, on a
following page) and renders both as plain HTML/CSS - no images, no
external dependencies, renders natively through the existing WeasyPrint
pipeline.
- goosepaper/puzzlegen/{sudoku,binoxxo,futoshiki,kakuro,shikaku}/: vendored
generation cores (grid/rules/solver/puzzle - no rendering, no CLI, no
reportlab) adapted from a private, unpublished puzzle-generator project.
Each is a self-contained backtracking generator + uniqueness solver with
a random.Random seed parameter for reproducible sets.
- goosepaper/storyprovider/puzzle.py: PuzzleStoryProvider dispatches to the
right generator/renderer pair and returns a Story per puzzle plus one
per solution. Solutions are batched at the end of the returned list
(rather than interleaved after each puzzle) and marked
PlacementPreference.FULLPAGE with a `break-before: page` rule scoped to
this provider's own inline CSS - worked around like this because
`break-before`/`page-break-before` do not force a page break for content
inside a `column-count` container in this WeasyPrint version (verified
against isolated minimal-HTML test cases); the workaround gives a clean
page per puzzle/solution under `layout: "1col"`, and degrades gracefully
(no crash, just no forced break) under multi-column layouts.
- goosepaper/config.py, goosepaper/util.py: schema validation and provider
wiring for the new "puzzle" source type
(puzzle_type/box_size/size/difficulty/count/seed).
Verified end-to-end for all five puzzle types (grid + solution rendering,
uniqueness of generated puzzles, config validation) via a downstream
project's local + real reMarkable-upload runs.
Two related changes, both only possible now that PlacementPreference. APPENDIX exists: 1. Puzzle solutions now use PlacementPreference.APPENDIX instead of FULLPAGE. They're still collected separately from their puzzles, but now render in the paper's shared end-of-document appendix block (grouped with every other appendix-placed story, puzzle or not) rather than inline after this provider's own stories. This fixes the remaining gap in the old approach: FULLPAGE's break-before: page only reliably starts a new page under layout: "1col" - APPENDIX stories render outside any column-count container, so the page break is reliable under multi-column layouts too. 2. New `explanation` option on the "puzzle" source type: "none" (default, unchanged behavior), "inline" (a short rules blurb appended to every puzzle instance's own body), "footer" (one blurb in normal reading order, right after this source's puzzles), or "appendix" (one blurb grouped into the end-of-document appendix alongside the solutions). "footer"/"appendix" each add exactly one extra Story per get_stories() call, not one per puzzle instance - and its headline is stable per puzzle_type (not per instance/date), so if several "puzzle" sources across a config all request an explanation for the same type, Goosepaper's own get_stories(deduplicate=True) collapses them to a single explanation, the same generic mechanism proven for PlacementPreference.APPENDIX in general (see test_goosepaper.py::test_appendix_stories_with_identical_headline_are_deduplicated). - goosepaper/storyprovider/puzzle.py: _EXPLANATIONS (short German rules text per puzzle type), explanation handling in get_stories(), solutions switched to APPENDIX. Removed the now-unused `article.placement-fullpage` CSS rule this provider carried as its own workaround - no longer needed now that solutions use APPENDIX. - goosepaper/config.py, goosepaper/util.py: schema validation and provider wiring for the new "explanation" field. - goosepaper/storyprovider/test_puzzle.py: new test module - solution placement, all four explanation modes, invalid-mode rejection, and an end-to-end cross-source dedup proof via a real Goosepaper instance. Verified visually via a real 2-column PDF render: two Sudoku sources (easy + hard) both requesting a "footer" explanation for the same type produce exactly one "Wie funktioniert Sudoku?" note (after the first Sudoku, not repeated after the second), and a Kakuro source requesting "appendix" placement lands its explanation alongside the solutions at the very end of the document.
...ulty Two puzzles of the same type+difficulty within one get_stories() call (count > 1) previously got the identical headline (e.g. two "Medium Sudoku" puzzles from one count=2 source). Story.date defaults to None and this fork's headline-based cross-provider deduplicate=True mechanism matches on (headline, date), so two undated same-headline stories look like duplicates - meaning the second puzzle (and its solution) would silently vanish under deduplicate=True, exactly the mode this fork's downstream consumers default to. Fix: append an instance index - " (1)", " (2)", ... - whenever count > 1. A single puzzle (the common case, count == 1) keeps its plain headline, unchanged. goosepaper/storyprovider/test_puzzle.py: new test module covering both the unchanged count == 1 case and the count > 1 disambiguation.
Previously "footer" mode placed the explanation as a normal Story in the reading flow (PlacementPreference.NONE) - visually indistinguishable from any other paragraph, landing wherever it happened to fall, not at the bottom of the page like a reader would expect from something called a footer/footnote. Now it uses WeasyPrint's actual CSS footnote support (float: footnote): the explanation lands at the bottom of whichever page it ends up on, with a numbered marker, cleanly separated from the flow. Deduplication (one explanation Story per puzzle_type, unchanged mechanism) still applies, which creates a real constraint: with only one puzzle_type across three difficulties as three separate sources (the common shape - e.g. "Sudoku Leicht"/"Sudoku Mittel"/"Sudoku Schwer" as three sections), only one of the three ever carries the actual footnote content after dedup; the other two need a reference mark showing the *same* number as that one real footnote, not their own separate footnote. Two approaches that would normally do this were tried and don't work in this WeasyPrint version: target-counter() (for pointing a plain reference at "whatever number this specific footnote ended up with") returned 0 instead of the real value, and reusing WeasyPrint's own auto-incrementing `footnote` counter directly doesn't work either, since it counts every float: footnote in the whole document in appearance order - a later occurrence's counter value has nothing to do with an earlier, unrelated footnote's number. So the number is entirely ours: _EXPLANATION_NUMBER assigns each puzzle_type a fixed number (1-5, from _EXPLANATIONS' dict order). Every puzzle instance gets a plain <sup class="puzzle-footnote-xref"> showing that fixed number, regardless of whether it's the one difficulty that happens to carry the real footnote after dedup or not; the real footnote overrides WeasyPrint's own footnote-marker content to match. Both ::footnote-call (the auto marker at the footnote's own origin point, now redundant since every instance already carries its own xref) and the now-otherwise-empty explanation Story's own headline/divider are suppressed via :has() (confirmed working in WeasyPrint's actual layout engine, not just the soupsieve selector matching used elsewhere in this fork) - without that, dedup leaves an empty heading with a stray divider line behind in the normal flow, since only the floated span itself moves to the page bottom. goosepaper/storyprovider/test_puzzle.py: new tests covering the CSS footnote class name, the xref marker on every instance, and (end-to-end through Goosepaper) that the xref number on a second difficulty's puzzle matches the one real footnote surviving deduplication.
...enerated label
PuzzleStoryProvider previously always rendered its own generated
"Medium Sudoku"-style text as a visible heading over every puzzle
instance. That's the only thing distinguishing puzzle types/difficulties
when several of them share one section (goosepaper has no other concept
of "this source's own label") - but it's pure duplication once a config
gives each type+difficulty its own dedicated section (e.g. a "Sudoku
Mittel" section containing only sudoku/medium sources), where the
section's own heading already says the same thing.
New optional `name` constructor arg controls what's actually shown:
- given: rendered as that instance's own heading (and "{name} -
Lösung" on its solution) - instead of the auto-generated text, not
alongside it.
- not given: nothing extra renders: only the enclosing section's own
heading identifies the puzzle. Correct for a dedicated section;
leaves same-section puzzles of different types indistinguishable
otherwise, so this is a per-source config choice, not a global one.
The auto-generated "Medium Sudoku (1)"-style text keeps existing as the
Story's internal `headline` no matter what - Goosepaper's own
deduplicate=True (matches on headline+date) and per-story anchor-
uniqueness both need a stable, distinct identity that doesn't depend on
what a user did or didn't type into `name`, so it's computed exactly as
before and just never rendered (article:has(.puzzle-body) >
.story-headline { display: none; }, the same :has()-based suppression
technique already used for "footer" explanations, confirmed working in
WeasyPrint's actual layout engine).
goosepaper/storyprovider/test_puzzle.py: new tests for both states (no
name -> no visible label, internal headline unaffected; name given ->
becomes the visible label, internal headline still the auto-generated
one) plus HTML-escaping of a custom name.
Two independent tightenings to the "puzzle" source's config surface: - `puzzle_type` no longer defaults to "sudoku". A config that forgets it should fail loudly (TypeError) instead of silently always generating Sudoku - there's no sensible implicit choice among five otherwise-equal puzzle types. - For every type but sudoku, `size` no longer needs to be spelled out at every difficulty. Each puzzlegen module's `Difficulty` dataclass now also carries a `size` (binoxxo: 8/10/14, futoshiki: 5/6/7, kakuro: 5/6/7, shikaku: 5/10/20 for easy/medium/hard), and PuzzleStoryProvider falls back to that table instead of one flat DEFAULT_SIZE when `size` is left unset. This isn't just a config convenience: shikaku's own DIFFICULTIES comment already documented "hard" as tuned for a 20x20 grid specifically (min_blocks=20 doesn't even fit a small grid's cell count) - so leaving size and difficulty fully decoupled meant the config *could* ask for combinations the generator was never measured against. Passing `size` explicitly still overrides the table, same as before. Sudoku's `box_size` stays a flat default (3, independent of difficulty) since that's genuinely how it's used today - no difficulty currently asks for a different box_size, so there's no table to add. Both changes are pure config-surface tightening: every real config already sets puzzle_type, and box_size/size can now be omitted entirely once a difficulty is picked - removing was the whole point (see the sibling goosepaper-addon repo's config cleanup).
Adds a full options table (required/optional, default, behavior) for the "puzzle" source type to the README, and a working example to example-config.json, verified by generating a real PDF from it end-to-end (the rendered Sudoku grid appears on its own page, "Medium Sudoku" in the table of contents).
Merges forward feature/puzzle-provider's new options table/example, then
extends both with this branch's own addition: the `explanation` field
("none"/"inline"/"footer"/"appendix").
Found during review, reproduced end-to-end before fixing: 1. goosepaper/config.py's own "puzzle" source schema still listed puzzle_type as optional, even though PuzzleStoryProvider.__init__ takes it as a plain required positional argument with no default. A config omitting puzzle_type passed schema validation and then crashed with a raw, unhandled `TypeError: PuzzleStoryProvider.__init__() missing 1 required positional argument: 'puzzle_type'` instead of this codebase's normal clean ConfigError. Moved puzzle_type from "optional" to "required" in _source_schema() - the existing required-field-checking loop in _parse_source() already does the rest, no new mechanism needed. 2. `name` - this branch's own headline feature - was never added to config.py's "puzzle" optional-keys set or to util.py's puzzle options-forwarding lambda. A config setting `"name"` on a puzzle source was rejected outright with "Unknown field(s) in source #N: name", making the feature this branch introduces completely unreachable through goosepaper's own standalone --config path (it only worked through the separate addon-side pydantic schema, which was wired up correctly). Added `name` to both the schema and the util.py filter, with a string validator matching how other free-text fields (headline, text, subreddit) are already validated. Added regression tests for both: goosepaper/test_config.py now covers a puzzle source with `name` loading successfully and a puzzle source missing puzzle_type raising a clean ConfigError; goosepaper/test_utils.py gained test_construct_story_providers_passes_puzzle_options, verifying every option (including `name`) actually reaches the constructed PuzzleStoryProvider - the exact thing that was silently missing before.
Found during review: PuzzleStoryProvider's count-loop disambiguation (the "(i+1)" suffix) only protects against collisions within one provider instance. Two separate "puzzle" sources sharing the same puzzle_type and difficulty, both left at count=1, produce Stories with the identical internal headline - it's derived from puzzle_type + difficulty only, never from seed - so Goosepaper's deduplicate=True silently drops one puzzle and its solution. Reproduced directly: two providers, same type+difficulty, different seeds (genuinely different puzzles) -> only one survives get_stories(deduplicate=True). Doesn't affect any of the three production goosepaper-addon configs (none repeat an exact type+difficulty pair across sources), and a real fix would mean redesigning how cross-instance identity works without a shared registry between independently-constructed providers - out of scope for this review pass. Documented the limitation on get_stories() with the correct mitigation (use one source with `count: N`, not N separate count-1 sources - seed does not help, it was almost documented as a fix here before double-checking against base_label's actual definition, which doesn't reference seed at all), and added a test that pins down the current behavior so it can only change on purpose.
Two cleanups: - Removed the `size` parameter entirely. It let a config override the per-difficulty grid size table, but size and difficulty were never meant to be independent - the whole point of deriving size from difficulty (previous commit) was that combinations like shikaku "hard" at a small size were never measured/tested. Letting `size` override that reintroduces exactly the risk the derivation was added to remove, for no real benefit (nothing in this fork or the addon-side project it was built for ever used the override). Removed from PuzzleStoryProvider.__init__, config.py's schema/validators, and util.py's provider-construction filter; a config that still sets `size` now gets a clean "Unknown field(s)" error instead of it being silently ignored or accepted. - Documented `box_size`'s actual supported values (2 -> 4x4, 3 -> the classic 9x9) instead of just naming the default. - Removed a stray reference to "a sibling puzzle-generation project" in puzzlegen/__init__.py's docstring - irrelevant to this fork on its own. Updated tests accordingly: renamed the difficulty-derives-size test (it's no longer "when unset", there's nothing else to set), replaced the override test with one confirming `size` is rejected outright. 85 passed.
Same fix as feature/puzzle-provider (which this branch already merged before that fix landed there) - "this fork's addition"/"added by this fork" only made sense in the staging fork's own combined README.
The right-sum ("h") was rendering bottom-right of its clue cell
instead of top-right, and the down-sum ("v") top-left instead of
bottom-left - exactly backwards. Confirmed by rendering an isolated,
distinctly-labelled clue cell: WeasyPrint inverts the vertical axis
for an absolutely-positioned span nested this way inside a table cell
(position:relative div inside a position:relative td) - "top: 0"
lands at the visual bottom, "bottom: 0" at the visual top.
Swapping top/bottom between .kakuro-h and .kakuro-v (right/left stay
as they were) corrects both labels to the standard Kakuro convention:
right-sum in the upper-right triangle, down-sum in the lower-left.
Verified by re-rendering a sample puzzle before/after.
🤖 Generated with Claude Code
... rule text The maintainer flagged " - Lösung" in the README; the same inconsistency also existed in the actual provider code (the solution heading suffix and the "explanation" story's headline), not just the docs, and in the German puzzle rule blurbs shown to readers. Rest of the codebase is English-only, so match that for now rather than partially localizing.
Same pattern the maintainer suggested for comic.py's comic_type in j6k4m8#133. Mirrors the "kept in sync by hand" comment convention already used there.
puzzle_type and explanation validation followed the identical not-in/raise pattern. Error message wording is unchanged.
...ide convention Follows the same format j6k4m8#140 established for the other built-in providers (and that j6k4m8#133 already used for comic.py.md). README link deliberately left out for now - the README section is one of the files already conflicting with master, better resolved together.
Kakuro's row/column sum labels weren't flush-centered in their clue triangles: absolute positioning had an inverted vertical axis under WeasyPrint, and a later CSS Grid attempt turned out to duplicate each grid item into two overlapping boxes internally. A nested 2x2 <table> per clue cell (matching kakuros.com's own markup) sidesteps both bugs and centers correctly via plain table-cell layout. Also moves puzzle.py's embedded CSS out of a Python string into a real puzzle.css file (loaded via importlib.resources, package-data updated to ship it), matching the styles.py convention - and restructures/ trims that stylesheet by puzzle type along the way.
9981369 to
3fbf94c
Compare
Uh oh!
There was an error while loading. Please reload this page.
This PR now covers the whole puzzle feature (combined with #123)
Per request: this PR is now the single place for all puzzle logic — the
"puzzle"source itself (Sudoku, Binoxxo, Futoshiki, Kakuro, Shikaku, from #123) plus the appendix/explanations work this PR originally added on top of it.Nothing needed to change code-wise to get here: this branch (
feature/puzzle-explanations) was branched off #123's branch and has been kept in sync with it via merge ever since, so it already fully contains #123 —git diffbetween the two branches ongoosepaper/puzzlegen/is empty. I've closed #123 as superseded by this PR.Both other former dependencies are also resolved: #122 (
PlacementPreference.APPENDIX) and #130 (deduplicatewired throughto_html()/to_pdf()) are merged, so the "Correction" caveat further down about deduplication not reaching the normal CLI/Docker path no longer applies — it does now.Why
Two things this PR fixes, both problems with the puzzle provider as it stood on its own before this PR:
1. Solutions don't actually collect at the end with multiple puzzle
sources. Batching via
PlacementPreference.FULLPAGEalone only reorders within oneprovider's own output. With two separate
"puzzle"sources (therealistic case - a real newspaper mixing Sudoku, Binoxxo, etc.), each
provider's solution would land right after that provider's own puzzle,
not collected together at the true end of the issue.
Reproduced directly: two-source config (one Sudoku source, one Binoxxo source) - before,
each solution appeared immediately next to its own puzzle; after, both
puzzles render together first, then both solutions together in the
appendix. Screenshots attached.
2. No way to show a puzzle's rules. A Sudoku, sure, everyone knows
it - but Futoshiki or Shikaku aren't universally familiar, and a print
puzzle book almost always includes a one-line reminder of the rules
somewhere. There was no way to add one.
What
"puzzle"source type itself: Sudoku, Binoxxo, Futoshiki, Kakuro, Shikaku generators +PuzzleStoryProvider(from Add a "puzzle" story source (Sudoku, Binoxxo, Futoshiki, Kakuro, Shikaku) #123 ).PlacementPreference.APPENDIXinstead of
FULLPAGE. "Batched like a puzzle book's answersection" intent, working regardless of
how many separate puzzle sources a config has.
explanationoption on the"puzzle"source:"none"(default) /"inline"(a short rulesblurb repeated under every puzzle instance) /
"footer"(a real CSSfootnote -
float: footnote- at the bottom of whichever page thepuzzle lands on, not a paragraph glued into the reading flow; every
instance of that
puzzle_typein the paper shares one footnote via asmall reference mark) /
"appendix"(one rules blurb perpuzzle_type,grouped with the solutions).
"footer"and"appendix"explanations use a stable per-puzzle_typeheadline, soGoosepaper.get_stories(deduplicate=True)collapses a rules blurb requested by several sources of the same type
down to one copy - same dedup mechanism Add PlacementPreference.APPENDIX for reliable end-of-document content #122 already proved works for
appendix content generally, no new mechanism needed here. Now that fix: actually expose the existing deduplicate option to callers #130 is
merged,
to_html()/to_pdf()/the CLI passdeduplicate=Truethrough, so thisreaches a normal render, not just a direct
get_stories()call.Reproduction
Two sources:
{ "sources": [ {"type": "puzzle", "puzzle_type": "sudoku", "difficulty": "medium", "name": "Sudoku"}, {"type": "puzzle", "puzzle_type": "binoxxo", "difficulty": "hard", "name": "Binoxxo (schwer)"} ] }Before:
puzzle solution renders immediately next to its own puzzle (same page, second column).
After (this branch):
puzzles are shown together and appendix holds the solutions. Explanations given in footnotes.
Example attached:
Rätselheft 2026年07月29日.pdf
Testing
daily - all of them use
explanation: "footer"with multiple puzzlesources per issue, exactly the scenario this PR fixes - including a
real delivery run to a reMarkable.
Merge-order note (siblings, not dependencies)
This PR has no remaining hard dependencies - #122, #123, and #130 are all resolved (merged or absorbed into this PR). What's left is mechanical (non-semantic) overlap with other open PRs that touch the same shared files.
I ran a local pairwise merge check across all my currently open PRs to find real conflicts (not just shared files). Full breakdown and reasoning in my comment below on this PR - short version: this PR conflicts with #133 and #132 (same
config.pysource-type schema block) and with #121 (same block, plusrss.py/test_rss.pyfor #121 separately). Suggested landing order for the whole queue is in that comment too.