clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

Update netwalk and puzzle render polish for volume 1 (#64)

FelineStateMachine committed ddec2b5
* initial pass

* netwalk polishing.

* increase difficulty across netwalk

* removed configured theme from render tests

* visual testing proto

* debug rendering, pdf changes

* style fixes

* remove tmp file

* Invalidate Netwalk cached mouse origin after recompute

* add netwalk vhs
commit
ddec2b5b57ce1f855b77c8f6e96005bbbf44d447
parent
37f6e11c4182f435fcae32d89ab759f2b687311c
author
FelineStateMachine <dami.etoile@gmail.com>
84 files changed, +5409 -511
MREADME.md37+-
Mcatalog/catalog_test.go1+
Mcmd/export_pdf.go68+-
Mcmd/export_pdf_test.go53+-
Mcmd/root.go8+-
Acmd/test.go159++++
Mfillomino/print_adapter.go21+-
Mfillomino/style.go11+
Afillomino/testdata/visual_states.jsonl4+
Mgo.mod2+
Mgo.sum5+
Mhashiwokakero/print_adapter.go23+-
Mhashiwokakero/style.go12+-
Ahashiwokakero/testdata/visual_states.jsonl4+
Mhitori/print_adapter.go21+-
Mhitori/style.go13+-
Ahitori/testdata/visual_states.jsonl4+
Ainternal/testjsonl/records.go55+
Mjustfile13+
Mlightsout/style.go17+-
Alightsout/testdata/visual_states.jsonl4+
Anetwalk/README.md39+
Anetwalk/export.go60+
Anetwalk/gamemode.go120+++
Anetwalk/generator.go372+++++++++
Anetwalk/grid.go450+++++++++++
Anetwalk/help.md30+
Anetwalk/keys.go49+
Anetwalk/model.go201+++++
Anetwalk/netwalk_test.go350++++++++
Anetwalk/print_adapter.go147+++
Anetwalk/style.go180++++
Anetwalk/testdata/visual_states.jsonl10+
Mnonogram/export.go23+-
Mnonogram/print_adapter.go25+-
Mnonogram/print_adapter_test.go1+
Mnonogram/style.go2+-
Anonogram/testdata/visual_states.jsonl3+
Mnurikabe/print_adapter.go21+-
Mnurikabe/style.go2+-
Anurikabe/testdata/visual_states.jsonl3+
Mpdfexport/font.go7+-
Mpdfexport/font_test.go12+-
Apdfexport/parse_netwalk.go104++
Mpdfexport/render.go251++++--
Mpdfexport/render_cover.go1273+++++++++++++++++++++++++++++-
Mpdfexport/render_cover_test.go184++++
Apdfexport/render_difficulty.go165++++
Apdfexport/render_difficulty_test.go143+++
Apdfexport/render_instructions.go64++
Apdfexport/render_instructions_test.go86++
Mpdfexport/render_kit.go3+-
Mpdfexport/render_layout_test.go155++++-
Mpdfexport/render_metadata.go10+-
Apdfexport/render_plan.go164++++
Mpdfexport/render_title.go19+-
Mpdfexport/render_tokens.go35+-
Mpdfexport/types.go19+-
Mregistry/registry.go2+
Mregistry/registry_test.go1+
Mrippleeffect/print_adapter.go21+-
Arippleeffect/testdata/visual_states.jsonl3+
Mshikaku/print_adapter.go21+-
Mshikaku/style.go4+-
Ashikaku/testdata/visual_states.jsonl3+
Mspellpuzzle/print_adapter.go21+-
Msudoku/model.go2+-
Msudoku/print_adapter.go21+-
Msudoku/style.go7+-
Asudoku/testdata/visual_states.jsonl4+
Msudokurgb/print_adapter.go21+-
Asudokurgb/testdata/visual_states.jsonl4+
Mtakuzu/print_adapter.go78+-
Mtakuzu/print_adapter_test.go87+-
Mtakuzu/style.go68++-
Mtakuzu/takuzu_test.go8+-
Atakuzu/testdata/visual_states.jsonl4+
Mtakuzuplus/print_adapter.go4+-
Mtakuzuplus/style.go130+++-
Mtakuzuplus/takuzuplus_test.go8+-
Atakuzuplus/testdata/visual_states.jsonl4+
Avhs/netwalk.gifbin
Avhs/netwalk.tape66++
Mwordsearch/print_adapter.go11+-
M README.md +28 -9
@@ -2,19 +2,19 @@
22 33 A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).44 5-Fourteen puzzle games, multiple difficulty modes, daily and weekly deterministic challenges, XP progression, 365 theme options, and an explicit built-in registry plus metadata catalog for adding new games.5+Fifteen puzzle games, multiple difficulty modes, daily and weekly deterministic challenges, XP progression, 365 theme options, and an explicit built-in registry plus metadata catalog for adding new games.66 77 ![PuzzleTea menu](vhs/menu.gif)88 99 ## Features1010 11-- **14 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Shikaku, Sudoku, Sudoku RGB, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+11+- **15 puzzle games** -- Fillomino, Netwalk, Nonogram, Nurikabe, Ripple Effect, Shikaku, Sudoku, Sudoku RGB, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+1212 - **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions.1313 - **Weekly gauntlet** -- Each ISO calendar week has a shared 99-puzzle ladder. The current week unlocks sequentially from `#01` to `#99`; past weeks can be reviewed from completed saves only.1414 - **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP, and weekly puzzles add slot-based bonus XP.1515 - **Stats dashboard** -- Profile level, daily streak status, weekly completion progress, victory counts, and XP progress bars per category.1616 - **365 color themes** -- Live-preview theme picker with WCAG-compliant contrast enforcement. Dark and light themes included.17-- **Mouse support** -- Drag interactions in Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search; click-to-focus in Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+; click-to-toggle in Lights Out.17+- **Mouse support** -- Drag interactions in Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search; click-to-focus in Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+; click-to-rotate in Netwalk; click-to-toggle in Lights Out.1818 - **Seeded puzzles** -- Share a seed string to generate identical puzzles across sessions and machines.1919 - **Save/load persistence** -- Games auto-save to SQLite. Resume any in-progress game by name.2020
@@ -34,6 +34,7 @@ Fourteen puzzle games, multiple difficulty modes, daily and weekly deterministic
3434 | **Hashiwokakero** | Connect islands with bridges | 12 modes across 7x7 to 13x13 grids |3535 | **Hitori** | Shade cells to eliminate duplicates | 6 modes from 5x5 to 12x12 |3636 | **Lights Out** | Toggle lights to turn all off | Easy (3x3) to Extreme (9x9) |37+| **Netwalk** | Rotate network tiles until every computer reaches the server | Mini 5x5 through Expert 13x13 |3738 | **Takuzu** | Fill grid with two symbols | 7 modes from 6x6 to 14x14 |3839 | **Takuzu+** | Fill grid with symbols plus `=` and `x` relation clues | 7 modes from 6x6 to 14x14 |3940
@@ -107,6 +108,7 @@ puzzletea new sudoku hard
107108 puzzletea new ripeto expert108109 puzzletea new spell beginner109110 puzzletea new lights-out111+puzzletea new netwalk "Easy 7x7"110112 puzzletea new hashi "Easy 7x7"111113 ```112114
@@ -144,15 +146,25 @@ puzzletea new nonogram mini -e 6 -o nonogram-mini-set.jsonl
144146 puzzletea new sudoku --export 10 -o sudoku-mixed.jsonl --with-seed zine-issue-01145147 ```146148 147-Render one or more JSONL packs into a half-letter print PDF:149+Render one or more JSONL packs into a print PDF:148150 149151 ```bash150152 puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01 --volume 1 --title "Catacombs & Pines"151153 ```152154 153-`--title` sets the pack subtitle (title page, and cover pages when enabled), and `--volume` sets the volume number.154-By default, covers are not included. Use `--cover-color` to include front/back cover pages.155-Page count is always auto-padded to a multiple of 4 for half-letter booklet printing.155+`--title` sets the pack subtitle (title page, and outside cover when enabled), and `--volume` sets the volume number.156+`half-letter` renders a plain booklet interior with no cover block.157+`duplex-booklet` automatically includes the 4-page black-ink cover block with blank inside covers, and the actual cover color comes from the physical stock you print on.158+When `duplex-booklet` is enabled, the title page shifts to logical page 3 so the first two and last two pages stay on cover stock.159+Page count is always auto-padded to a multiple of 4 for booklet printing.160+161+Use `--sheet-layout duplex-booklet` to emit a landscape US Letter PDF with two portrait half-letter booklet pages per sheet side:162+163+```bash164+puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01-duplex.pdf --shuffle-seed issue-01 --volume 1 --title "Catacombs & Pines" --sheet-layout duplex-booklet165+```166+167+`duplex-booklet` is meant for duplex printing without printer-side booklet mode. Print landscape on short edge.156168 157169 Font license note (Atkinson Hyperlegible Next):158170
@@ -178,7 +190,7 @@ puzzletea --continue amber-falcon
178190 179191 ### CLI Aliases180192 181-Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `spell`/`spellpuzzle` for Spell Puzzle, `rgb sudoku`/`ripeto`/`sudoku ripeto` for Sudoku RGB, `binairo`/`binary` for Takuzu, `takuzu plus`/`binario+`/`binario plus` for Takuzu+, `words`/`wordsearch`/`ws` for Word Search, and `rectangles` for Shikaku.193+Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `network` for Netwalk, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `spell`/`spellpuzzle` for Spell Puzzle, `rgb sudoku`/`ripeto`/`sudoku ripeto` for Sudoku RGB, `binairo`/`binary` for Takuzu, `takuzu plus`/`binario+`/`binario plus` for Takuzu+, `words`/`wordsearch`/`ws` for Word Search, and `rectangles` for Shikaku.182194 183195 ## Controls184196
@@ -198,7 +210,7 @@ Arrow keys, WASD, and Vim bindings (`hjkl`) are supported for grid movement acro
198210 199211 ### Mouse200212 201-Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search support drag interactions. Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+ support mouse focus or click-to-cycle interactions. Lights Out supports click to toggle. See each game's help for details.213+Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search support drag interactions. Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+ support mouse focus or click-to-cycle interactions. Netwalk supports click-to-rotate and right-click lock toggles. Lights Out supports click to toggle. See each game's help for details.202214 203215 ## Game Persistence204216
@@ -290,6 +302,13 @@ Toggle lights to turn all off.
290302 291303 [Game details and controls](lightsout/README.md)292304 305+### Netwalk306+Rotate network tiles until every computer reaches the server in one loop-free tree.307+308+![Netwalk](vhs/netwalk.gif)309+310+[Game details and controls](netwalk/README.md)311+293312 ### Takuzu294313 Fill the grid with two symbols following three simple rules.295314
M catalog/catalog_test.go +1 -0
M cmd/export_pdf.go +22 -46
@@ -3,7 +3,6 @@ package cmd
33 import (44 "fmt"55 "path/filepath"6- "strconv"76 "strings"87 "time"98
@@ -22,25 +21,25 @@ var (
2221 flagPDFVolume int2322 flagPDFAdvert string2423 flagPDFShuffleSeed string25- flagPDFCoverColor string24+ flagPDFSheetLayout string2625 )2726 2827 var exportPDFCmd = &cobra.Command{2928 Use: "export-pdf <input.jsonl> [more.jsonl ...]",30- Short: "Convert one or more PuzzleTea JSONL exports into a half-letter printable PDF",31- Long: "Parse one or more JSONL export files, order puzzles by progressive difficulty with seeded mixing, and render a half-letter PDF with a title page, one puzzle per page, optional covers (when --cover-color is set), and automatic page-count padding to a multiple of 4 for booklet printing.",29+ Short: "Convert one or more PuzzleTea JSONL exports into a printable PDF",30+ Long: "Parse one or more JSONL export files, order puzzles by progressive difficulty with seeded mixing, and render either a half-letter PDF or an imposed duplex-booklet PDF with a title page, one puzzle per logical half-letter page, and automatic page-count padding to a multiple of 4 for booklet printing. The duplex-booklet layout includes the 4-page black-ink cover block automatically.",3231 Args: cobra.MinimumNArgs(1),3332 RunE: runExportPDF,3433 }3534 3635 func init() {3736 exportPDFCmd.Flags().StringVarP(&flagPDFOutput, "output", "o", "", "write output PDF path (defaults to <first-input>-print.pdf)")38- exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "subtitle shown on the title page (and on covers when enabled)")37+ exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "subtitle shown on the title page (and on the outside cover when enabled)")3938 exportPDFCmd.Flags().StringVar(&flagPDFHeader, "header", "", "optional intro paragraph shown on the title page under 'PuzzleTea Puzzle Pack'")40- exportPDFCmd.Flags().IntVar(&flagPDFVolume, "volume", 1, "volume number shown on the title page (and on covers when enabled) (must be >= 1)")39+ exportPDFCmd.Flags().IntVar(&flagPDFVolume, "volume", 1, "volume number shown on the title page (and on the outside cover when enabled) (must be >= 1)")4140 exportPDFCmd.Flags().StringVar(&flagPDFAdvert, "advert", "Find more puzzles at github.com/FelineStateMachine/puzzletea", "advert text shown on the title page")4241 exportPDFCmd.Flags().StringVar(&flagPDFShuffleSeed, "shuffle-seed", "", "seed for deterministic within-band difficulty mixing")43- exportPDFCmd.Flags().StringVar(&flagPDFCoverColor, "cover-color", "", `accent color for optional front/back covers: hex "#RRGGBB" or decimal "R,G,B" (omit for no covers)`)42+ exportPDFCmd.Flags().StringVar(&flagPDFSheetLayout, "sheet-layout", "half-letter", "physical PDF layout: half-letter or duplex-booklet (landscape US Letter with two half-letter pages per sheet side; print duplex on short edge). duplex-booklet automatically includes the 4-page cover block")4443 }4544 4645 func runExportPDF(cmd *cobra.Command, args []string) error {
@@ -115,17 +114,16 @@ func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string,
115114 if err := validatePDFVolume(flagPDFVolume); err != nil {116115 return pdfexport.RenderConfig{}, err117116 }117+ sheetLayout, err := parsePDFSheetLayout(flagPDFSheetLayout)118+ if err != nil {119+ return pdfexport.RenderConfig{}, err120+ }118121 119122 subtitle := strings.TrimSpace(flagPDFTitle)120123 if subtitle == "" {121124 subtitle = defaultPDFTitle(docs)122125 }123126 124- coverColor, err := parseCoverColor(flagPDFCoverColor)125- if err != nil {126- return pdfexport.RenderConfig{}, fmt.Errorf("--cover-color: %w", err)127- }128-129127 cfg := pdfexport.RenderConfig{130128 CoverSubtitle: subtitle,131129 HeaderText: strings.TrimSpace(flagPDFHeader),
@@ -133,11 +131,22 @@ func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string,
133131 AdvertText: flagPDFAdvert,134132 GeneratedAt: now,135133 ShuffleSeed: shuffleSeed,136- CoverColor: coverColor,134+ SheetLayout: sheetLayout,137135 }138136 return cfg, nil139137 }140138 139+func parsePDFSheetLayout(raw string) (pdfexport.SheetLayout, error) {140+ switch strings.ToLower(strings.TrimSpace(raw)) {141+ case "", "half-letter":142+ return pdfexport.SheetLayoutHalfLetter, nil143+ case "duplex-booklet":144+ return pdfexport.SheetLayoutDuplexBooklet, nil145+ default:146+ return pdfexport.SheetLayoutHalfLetter, fmt.Errorf("--sheet-layout must be half-letter or duplex-booklet")147+ }148+}149+141150 func buildModeDifficultyLookup(definitions []puzzle.Definition) map[string]map[string]float64 {142151 lookup := make(map[string]map[string]float64, len(definitions))143152
@@ -206,36 +215,3 @@ func annotateDifficulty(puzzles []pdfexport.Puzzle, lookup map[string]map[string
206215 func normalizeDifficultyToken(s string) string {207216 return puzzle.NormalizeName(s)208217 }209-210-// parseCoverColor parses a cover color string in hex ("#RRGGBB") or211-// decimal ("R,G,B") format. Returns nil if s is empty (no cover pages).212-func parseCoverColor(s string) (*pdfexport.RGB, error) {213- s = strings.TrimSpace(s)214- if s == "" {215- return nil, nil216- }217-218- // Hex format: #RRGGBB or RRGGBB219- hex := strings.TrimPrefix(s, "#")220- if len(hex) == 6 {221- r, errR := strconv.ParseUint(hex[0:2], 16, 8)222- g, errG := strconv.ParseUint(hex[2:4], 16, 8)223- b, errB := strconv.ParseUint(hex[4:6], 16, 8)224- if errR == nil && errG == nil && errB == nil {225- return &pdfexport.RGB{R: uint8(r), G: uint8(g), B: uint8(b)}, nil226- }227- }228-229- // Decimal format: R,G,B230- parts := strings.Split(s, ",")231- if len(parts) == 3 {232- r, errR := strconv.ParseUint(strings.TrimSpace(parts[0]), 10, 8)233- g, errG := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 8)234- b, errB := strconv.ParseUint(strings.TrimSpace(parts[2]), 10, 8)235- if errR == nil && errG == nil && errB == nil {236- return &pdfexport.RGB{R: uint8(r), G: uint8(g), B: uint8(b)}, nil237- }238- }239-240- return nil, fmt.Errorf("invalid color %q — use hex \"#RRGGBB\" or decimal \"R,G,B\"", s)241-}
M cmd/export_pdf_test.go +33 -20
@@ -62,7 +62,7 @@ func TestBuildRenderConfigForPDFUsesTitleAsCoverSubtitle(t *testing.T) {
6262 flagPDFHeader = "Custom heading paragraph"6363 flagPDFVolume = 76464 flagPDFAdvert = "Custom advert"65- flagPDFCoverColor = ""65+ flagPDFSheetLayout = "half-letter"6666 6767 now := time.Date(2026, 2, 22, 11, 0, 0, 0, time.UTC)6868 docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Nonogram"}}}
@@ -94,7 +94,7 @@ func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) {
9494 flagPDFTitle = ""9595 flagPDFVolume = 19696 flagPDFAdvert = "Find more puzzles"97- flagPDFCoverColor = ""97+ flagPDFSheetLayout = "half-letter"9898 9999 docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Sudoku"}}}100100 cfg, err := buildRenderConfigForPDF(docs, "seed-2", time.Now())
@@ -106,34 +106,47 @@ func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) {
106106 }107107 }108108 109-func TestBuildRenderConfigForPDFCoverColorControlsCoverPages(t *testing.T) {109+func TestSheetLayoutControlsCoverPages(t *testing.T) {110110 reset := snapshotExportPDFFlags()111111 defer reset()112112 113113 flagPDFTitle = "Issue 01"114114 flagPDFVolume = 1115115 flagPDFAdvert = "Find more puzzles"116- docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Sudoku"}}}116+ if pdfexport.SheetLayoutHalfLetter == pdfexport.SheetLayoutDuplexBooklet {117+ t.Fatal("expected distinct sheet layout values")118+ }119+}117120 118- flagPDFCoverColor = ""119- cfgNoCover, err := buildRenderConfigForPDF(docs, "seed-3", time.Now())121+func TestBuildRenderConfigForPDFParsesSheetLayout(t *testing.T) {122+ reset := snapshotExportPDFFlags()123+ defer reset()124+125+ flagPDFVolume = 1126+ flagPDFSheetLayout = "duplex-booklet"127+128+ cfg, err := buildRenderConfigForPDF(nil, "seed-5", time.Now())120129 if err != nil {121- t.Fatalf("buildRenderConfigForPDF (no cover color) error = %v", err)130+ t.Fatalf("buildRenderConfigForPDF error = %v", err)122131 }123- if cfgNoCover.CoverColor != nil {124- t.Fatalf("CoverColor = %+v, want nil when --cover-color is omitted", cfgNoCover.CoverColor)132+ if cfg.SheetLayout != pdfexport.SheetLayoutDuplexBooklet {133+ t.Fatalf("SheetLayout = %d, want duplex booklet", cfg.SheetLayout)125134 }135+}126136 127- flagPDFCoverColor = "#112233"128- cfgWithCover, err := buildRenderConfigForPDF(docs, "seed-4", time.Now())129- if err != nil {130- t.Fatalf("buildRenderConfigForPDF (with cover color) error = %v", err)131- }132- if cfgWithCover.CoverColor == nil {133- t.Fatal("CoverColor = nil, want parsed color when --cover-color is set")137+func TestBuildRenderConfigForPDFRejectsInvalidSheetLayout(t *testing.T) {138+ reset := snapshotExportPDFFlags()139+ defer reset()140+141+ flagPDFVolume = 1142+ flagPDFSheetLayout = "brochure"143+144+ _, err := buildRenderConfigForPDF(nil, "seed-6", time.Now())145+ if err == nil {146+ t.Fatal("expected invalid --sheet-layout error")134147 }135- if *cfgWithCover.CoverColor != (pdfexport.RGB{R: 0x11, G: 0x22, B: 0x33}) {136- t.Fatalf("CoverColor = %+v, want {R:17 G:34 B:51}", *cfgWithCover.CoverColor)148+ if !strings.Contains(err.Error(), "--sheet-layout") {149+ t.Fatalf("error = %q, want mention of --sheet-layout", err.Error())137150 }138151 }139152
@@ -272,17 +285,17 @@ func snapshotExportPDFFlags() func() {
272285 oldHeader := flagPDFHeader273286 oldVolume := flagPDFVolume274287 oldAdvert := flagPDFAdvert275- oldCoverColor := flagPDFCoverColor276288 oldOutput := flagPDFOutput277289 oldShuffle := flagPDFShuffleSeed290+ oldSheetLayout := flagPDFSheetLayout278291 279292 return func() {280293 flagPDFTitle = oldTitle281294 flagPDFHeader = oldHeader282295 flagPDFVolume = oldVolume283296 flagPDFAdvert = oldAdvert284- flagPDFCoverColor = oldCoverColor285297 flagPDFOutput = oldOutput286298 flagPDFShuffleSeed = oldShuffle299+ flagPDFSheetLayout = oldSheetLayout287300 }288301 }
M cmd/root.go +6 -2
A cmd/test.go +159 -0
@@ -0,0 +1,159 @@
1+package cmd2+3+import (4+ "bufio"5+ "encoding/json"6+ "fmt"7+ "io"8+ "os"9+ "path/filepath"10+ "strings"11+12+ tea "charm.land/bubbletea/v2"13+ "github.com/FelineStateMachine/puzzletea/pdfexport"14+ "github.com/FelineStateMachine/puzzletea/registry"15+ "github.com/spf13/cobra"16+)17+18+const (19+ testRenderWidth = 12020+ testRenderHeight = 4021+)22+23+var testOutput string24+25+type testInputRecord struct {26+ lineNo int27+ record pdfexport.JSONLRecord28+}29+30+var testCmd = &cobra.Command{31+ Use: "test <input.jsonl>",32+ Short: "Render saved puzzle exports into an ANSI review artifact",33+ Long: "Parse a PuzzleTea export JSONL file, import each saved puzzle, and render the full board view in a stable ANSI text format for visual review.",34+ Args: cobra.ExactArgs(1),35+ RunE: runTest,36+}37+38+func init() {39+ testCmd.Flags().StringVarP(&testOutput, "output", "o", "", "write rendered output to a file (defaults to stdout)")40+}41+42+func runTest(cmd *cobra.Command, args []string) error {43+ applyTheme(flagTheme)44+45+ records, err := loadTestRecords(args[0])46+ if err != nil {47+ return err48+ }49+50+ output, err := renderTestRecords(args[0], records)51+ if err != nil {52+ return err53+ }54+55+ return writeTestOutput(cmd.OutOrStdout(), testOutput, output)56+}57+58+func loadTestRecords(path string) ([]testInputRecord, error) {59+ if !strings.EqualFold(filepath.Ext(path), ".jsonl") {60+ return nil, fmt.Errorf("%s: expected .jsonl input", path)61+ }62+63+ f, err := os.Open(path)64+ if err != nil {65+ return nil, fmt.Errorf("open input jsonl: %w", err)66+ }67+ defer f.Close()68+69+ scanner := bufio.NewScanner(f)70+ scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)71+72+ records := make([]testInputRecord, 0, 16)73+ lineNo := 074+ for scanner.Scan() {75+ lineNo++76+ line := strings.TrimSpace(scanner.Text())77+ if line == "" {78+ continue79+ }80+81+ var record pdfexport.JSONLRecord82+ if err := json.Unmarshal([]byte(line), &record); err != nil {83+ return nil, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err)84+ }85+ if record.Schema != pdfexport.ExportSchemaV1 {86+ return nil, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema)87+ }88+89+ records = append(records, testInputRecord{lineNo: lineNo, record: record})90+ }91+ if err := scanner.Err(); err != nil {92+ return nil, fmt.Errorf("read input jsonl: %w", err)93+ }94+ if len(records) == 0 {95+ return nil, fmt.Errorf("%s: input jsonl is empty", path)96+ }97+98+ return records, nil99+}100+101+func renderTestRecords(path string, records []testInputRecord) (string, error) {102+ var b strings.Builder103+104+ for _, item := range records {105+ record := item.record106+ gameType := strings.TrimSpace(record.Puzzle.Game)107+ if gameType == "" {108+ gameType = strings.TrimSpace(record.Pack.Category)109+ }110+ if gameType == "" {111+ return "", fmt.Errorf("%s:%d: missing puzzle game/category", path, item.lineNo)112+ }113+114+ g, err := registry.Import(gameType, record.Puzzle.Save)115+ if err != nil {116+ return "", fmt.Errorf("%s:%d: import %q: %w", path, item.lineNo, gameType, err)117+ }118+119+ sized, _ := g.Update(tea.WindowSizeMsg{Width: testRenderWidth, Height: testRenderHeight})120+ fmt.Fprintf(121+ &b,122+ "=== %s | %s | %s | #%d ===\n",123+ gameType,124+ testHeaderValue(record.Puzzle.Mode, record.Pack.ModeSelection),125+ testHeaderValue(record.Puzzle.Name, "unnamed"),126+ record.Puzzle.Index,127+ )128+ b.WriteString(sized.View())129+ b.WriteString("\n\n")130+ }131+132+ return b.String(), nil133+}134+135+func testHeaderValue(value, fallback string) string {136+ value = strings.TrimSpace(value)137+ if value != "" {138+ return value139+ }140+ return fallback141+}142+143+func writeTestOutput(stdout io.Writer, path, content string) error {144+ if strings.TrimSpace(path) == "" {145+ _, err := io.WriteString(stdout, content)146+ return err147+ }148+149+ dir := filepath.Dir(path)150+ if dir != "" && dir != "." {151+ if err := os.MkdirAll(dir, 0o755); err != nil {152+ return fmt.Errorf("create output directory: %w", err)153+ }154+ }155+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {156+ return fmt.Errorf("write output file: %w", err)157+ }158+ return nil159+}
M fillomino/print_adapter.go +6 -15
M fillomino/style.go +11 -0
A fillomino/testdata/visual_states.jsonl +4 -0
@@ -0,0 +1,4 @@
1+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"provided-and-editable","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n2 . 1\n. . .","provided":"#.#\n#.#\n...","mode_title":"Visual Fixture","max_cell_value":3}}}2+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"mixed-regions","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n3 . .\n. . 1","provided":"###\n#..\n..#","mode_title":"Visual Fixture","max_cell_value":3}}}3+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"overfull-region-feedback","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 2\n3 . .\n. . 1","provided":"...\n#..\n..#","mode_title":"Visual Fixture","max_cell_value":3}}}4+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-complete-grid","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n3 3 3\n1 2 2","provided":"#.#\n...\n#..","mode_title":"Visual Fixture","max_cell_value":3}}}
M go.mod +2 -0
M go.sum +5 -0
M hashiwokakero/print_adapter.go +7 -16
@@ -40,7 +40,10 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *pdfexport.HashiData) {
4040 4141 spanX := max(data.Width-1, 1)4242 spanY := max(data.Height-1, 1)43- area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)43+ body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo)44+ rules := []string{"Connect islands horizontally/vertically with up to two bridges and no crossings."}45+ ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules)46+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines)4447 step := pdfexport.FitHashiCellSize(spanX, spanY, area)4548 if step <= 0 {4649 return
@@ -55,24 +58,12 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *pdfexport.HashiData) {
5558 drawHashiBoardBorder(pdf, originX, originY, boardW, boardH, islandRadius)5659 drawHashiIslands(pdf, originX, originY, step, islandRadius, data.Islands)5760 58- ruleY := pdfexport.InstructionY(originY+boardH+pdfexport.InstructionLineHMM, pageH, 1)59- pdfexport.SetInstructionStyle(pdf)60- pdf.SetXY(area.X, ruleY)61- pdf.CellFormat(62- area.W,63- pdfexport.InstructionLineHMM,64- "Connect islands horizontally/vertically with up to two bridges and no crossings.",65- "",66- 0,67- "C",68- false,69- 0,70- "",71- )61+ ruleY := pdfexport.InstructionY(originY+boardH+pdfexport.InstructionLineHMM, pageH, ruleLines)62+ pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules)7263 }7364 7465 func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height int, step float64) {75- pdf.SetFillColor(230, 230, 230)66+ pdf.SetFillColor(180, 180, 180)7667 r := math.Max(0.20, math.Min(0.55, step*0.035))7768 for y := range height {7869 for x := range width {
M hashiwokakero/style.go +9 -3
A hashiwokakero/testdata/visual_states.jsonl +4 -0
@@ -0,0 +1,4 @@
1+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"disconnected-corners","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":4,"height":4,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":3,"Y":0,"Required":2},{"ID":2,"X":0,"Y":3,"Required":2},{"ID":3,"X":3,"Y":3,"Required":2}],"bridges":null,"mode_title":"Visual Fixture"}}}2+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"partial-single-bridge","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":3,"height":1,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":2,"Y":0,"Required":1}],"bridges":[{"Island1":0,"Island2":1,"Count":1}],"mode_title":"Visual Fixture"}}}3+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"corner-network-choice","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":3,"height":3,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":2,"Y":0,"Required":1},{"ID":2,"X":0,"Y":2,"Required":1}],"bridges":[{"Island1":0,"Island2":1,"Count":1}],"mode_title":"Visual Fixture"}}}4+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"double-bridge-pair","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":3,"height":1,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":2,"Y":0,"Required":2}],"bridges":[{"Island1":0,"Island2":1,"Count":2}],"mode_title":"Visual Fixture"}}}
M hitori/print_adapter.go +6 -15
M hitori/style.go +12 -1
A hitori/testdata/visual_states.jsonl +4 -0
@@ -0,0 +1,4 @@
1+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"unmarked-duplicates","game":"Hitori","mode":"Visual Fixture","save":{"size":3,"numbers":"113\n231\n312","marks":"...\n...\n...","mode_title":"Visual Fixture"}}}2+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"adjacent-shaded-conflict","game":"Hitori","mode":"Visual Fixture","save":{"size":4,"numbers":"1213\n2341\n3124\n1432","marks":"XX..\n....\n....\n....","mode_title":"Visual Fixture"}}}3+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"circled-duplicate-conflict","game":"Hitori","mode":"Visual Fixture","save":{"size":3,"numbers":"113\n231\n312","marks":"OO.\n...\n...","mode_title":"Visual Fixture"}}}4+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-valid-layout","game":"Hitori","mode":"Visual Fixture","save":{"size":4,"numbers":"1213\n2341\n3124\n1432","marks":"X...\n....\n....\n..X.","mode_title":"Visual Fixture"}}}
A internal/testjsonl/records.go +55 -0
M justfile +13 -0
M lightsout/style.go +13 -4
A lightsout/testdata/visual_states.jsonl +4 -0
@@ -0,0 +1,4 @@
1+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"cursor-lit-center","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,false,false],[false,true,false],[false,false,false]],"initial_grid":[[false,false,false],[false,true,false],[false,false,false]],"cx":1,"cy":1,"mode_title":"Visual Fixture"}}}2+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"cursor-dark-corner","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,true,false],[false,false,false],[true,false,true]],"initial_grid":[[false,true,false],[false,false,false],[true,false,true]],"cx":0,"cy":0,"mode_title":"Visual Fixture"}}}3+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"mixed-hotspots","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[true,false,true],[false,true,false],[true,false,true]],"initial_grid":[[true,false,true],[false,true,false],[true,false,true]],"cx":2,"cy":0,"mode_title":"Visual Fixture"}}}4+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-all-off","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,false,false],[false,false,false],[false,false,false]],"initial_grid":[[false,false,false],[false,false,false],[false,false,false]],"cx":1,"cy":1,"mode_title":"Visual Fixture"}}}
A netwalk/README.md +39 -0
A netwalk/export.go +60 -0
@@ -0,0 +1,60 @@
1+package netwalk2+3+import (4+ "encoding/json"5+ "fmt"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+type Save struct {11+ Size int `json:"size"`12+ Masks string `json:"masks"`13+ Rotations string `json:"rotations"`14+ InitialRotations string `json:"initial_rotations"`15+ Kinds string `json:"kinds"`16+ Locks string `json:"locks"`17+ ModeTitle string `json:"mode_title"`18+}19+20+func (m Model) GetSave() ([]byte, error) {21+ save := Save{22+ Size: m.puzzle.Size,23+ Masks: encodeMaskRows(m.puzzle.Tiles),24+ Rotations: encodeRotationRows(m.puzzle.Tiles, false),25+ InitialRotations: encodeRotationRows(m.puzzle.Tiles, true),26+ Kinds: encodeKindRows(m.puzzle.Tiles),27+ Locks: encodeLockRows(m.puzzle.Tiles),28+ ModeTitle: m.modeTitle,29+ }30+ data, err := json.Marshal(save)31+ if err != nil {32+ return nil, fmt.Errorf("marshal netwalk save: %w", err)33+ }34+ return data, nil35+}36+37+func ImportModel(data []byte) (*Model, error) {38+ var save Save39+ if err := json.Unmarshal(data, &save); err != nil {40+ return nil, fmt.Errorf("unmarshal netwalk save: %w", err)41+ }42+43+ if save.Locks == "" {44+ save.Locks = encodeRows(save.Size, func(_, _ int) byte { return '.' })45+ }46+ puzzle, err := decodePuzzle(save.Size, save.Masks, save.Rotations, save.InitialRotations, save.Kinds, save.Locks)47+ if err != nil {48+ return nil, err49+ }50+51+ cursor := puzzle.firstActive()52+ m := &Model{53+ puzzle: puzzle,54+ cursor: game.Cursor{X: cursor.X, Y: cursor.Y},55+ keys: DefaultKeyMap,56+ modeTitle: save.ModeTitle,57+ }58+ m.recompute()59+ return m, nil60+}
A netwalk/gamemode.go +120 -0
@@ -0,0 +1,120 @@
1+package netwalk2+3+import (4+ _ "embed"5+ "math/rand/v2"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/gameentry"9+ "github.com/FelineStateMachine/puzzletea/puzzle"10+)11+12+//go:embed help.md13+var HelpContent string14+15+type NetwalkMode struct {16+ game.BaseMode17+ Size int18+ FillRatio float6419+ Profile generateProfile20+}21+22+var (23+ _ game.Mode = NetwalkMode{}24+ _ game.Spawner = NetwalkMode{}25+ _ game.SeededSpawner = NetwalkMode{}26+)27+28+func NewMode(title, desc string, size int, fillRatio float64, profile generateProfile) NetwalkMode {29+ return NetwalkMode{30+ BaseMode: game.NewBaseMode(title, desc),31+ Size: size,32+ FillRatio: fillRatio,33+ Profile: profile,34+ }35+}36+37+func (m NetwalkMode) Spawn() (game.Gamer, error) {38+ p, err := GenerateSeededWithDensity(39+ m.Size,40+ m.FillRatio,41+ m.Profile,42+ rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())),43+ )44+ if err != nil {45+ return nil, err46+ }47+ return New(m, p)48+}49+50+func (m NetwalkMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {51+ p, err := GenerateSeededWithDensity(m.Size, m.FillRatio, m.Profile, rng)52+ if err != nil {53+ return nil, err54+ }55+ return New(m, p)56+}57+58+var (59+ miniProfile = generateProfile{60+ ParentDegreeWeights: [5]int{16, 16, 6, -8, -12},61+ OrthogonalPackedWeight: 2,62+ DiagonalPackedWeight: 1,63+ SpanGrowthWeight: 12,64+ MinSpanRatio: 0.55,65+ }66+ easyProfile = generateProfile{67+ ParentDegreeWeights: [5]int{14, 14, 8, -4, -8},68+ OrthogonalPackedWeight: 4,69+ DiagonalPackedWeight: 2,70+ SpanGrowthWeight: 14,71+ MinSpanRatio: 0.62,72+ }73+ mediumProfile = generateProfile{74+ ParentDegreeWeights: [5]int{10, 10, 12, 2, -4},75+ OrthogonalPackedWeight: 6,76+ DiagonalPackedWeight: 3,77+ SpanGrowthWeight: 14,78+ MinSpanRatio: 0.70,79+ }80+ hardProfile = generateProfile{81+ ParentDegreeWeights: [5]int{6, 6, 16, 8, 0},82+ OrthogonalPackedWeight: 8,83+ DiagonalPackedWeight: 4,84+ SpanGrowthWeight: 16,85+ MinSpanRatio: 0.78,86+ }87+ expertProfile = generateProfile{88+ ParentDegreeWeights: [5]int{4, 4, 18, 10, 2},89+ OrthogonalPackedWeight: 10,90+ DiagonalPackedWeight: 5,91+ SpanGrowthWeight: 18,92+ MinSpanRatio: 0.84,93+ }94+)95+96+var Modes = []game.Mode{97+ NewMode("Mini 5x5", "Compact 5×5 network with a denser starter tree.", 5, 0.50, miniProfile),98+ NewMode("Easy 7x7", "7×7 board with fuller coverage and gentle local tangles.", 7, 0.57, easyProfile),99+ NewMode("Medium 9x9", "Balanced 9×9 network with tighter clusters and more branching.", 9, 0.64, mediumProfile),100+ NewMode("Hard 11x11", "Dense 11×11 board that packs branches into close local interactions.", 11, 0.72, hardProfile),101+ NewMode("Expert 13x13", "Large, crowded network with heavy branching and frequent near-miss tangles.", 13, 0.78, expertProfile),102+}103+104+var ModeDefinitions = gameentry.BuildModeDefs(Modes)105+106+var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{107+ Name: "Netwalk",108+ Description: "Rotate network tiles until every computer connects to the server.",109+ Aliases: []string{"network"},110+ Modes: ModeDefinitions,111+ DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2),112+})113+114+var Entry = gameentry.NewEntry(gameentry.EntrySpec{115+ Definition: Definition,116+ Help: HelpContent,117+ Import: game.AdaptImport(ImportModel),118+ Modes: Modes,119+ Print: PDFPrintAdapter,120+})
A netwalk/generator.go +372 -0
@@ -0,0 +1,372 @@
1+package netwalk2+3+import (4+ "errors"5+ "math"6+ "math/rand/v2"7+)8+9+const maxGenerateAttempts = 6410+11+type generateProfile struct {12+ ParentDegreeWeights [5]int13+ OrthogonalPackedWeight int14+ DiagonalPackedWeight int15+ SpanGrowthWeight int16+ MinSpanRatio float6417+}18+19+var legacyGenerateProfile = generateProfile{20+ ParentDegreeWeights: [5]int{16, 16, 8, -2, -4},21+}22+23+func Generate(size, targetActive int) (Puzzle, error) {24+ return GenerateSeeded(size, targetActive, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())))25+}26+27+func GenerateSeeded(size, targetActive int, rng *rand.Rand) (Puzzle, error) {28+ return generateSeededWithProfile(size, targetActive, legacyGenerateProfile, rng)29+}30+31+func GenerateSeededWithDensity(size int, fillRatio float64, profile generateProfile, rng *rand.Rand) (Puzzle, error) {32+ return generateSeededWithProfile(size, targetActiveFromFillRatio(size, fillRatio), profile, rng)33+}34+35+func generateSeededWithProfile(size, targetActive int, profile generateProfile, rng *rand.Rand) (Puzzle, error) {36+ if size <= 1 {37+ return Puzzle{}, errors.New("netwalk size must be at least 2")38+ }39+ if targetActive < 2 {40+ targetActive = 241+ }42+ if targetActive > size*size {43+ targetActive = size * size44+ }45+46+ for attempt := 0; attempt < maxGenerateAttempts; attempt++ {47+ puzzle := buildTreePuzzle(size, targetActive, profile, rng)48+ scramblePuzzle(&puzzle, rng)49+ if !analyzePuzzle(puzzle).solved {50+ return puzzle, nil51+ }52+ }53+54+ return Puzzle{}, errors.New("failed to generate netwalk puzzle")55+}56+57+type frontierEdge struct {58+ from point59+ to point60+}61+62+type activeBounds struct {63+ minX int64+ maxX int65+ minY int66+ maxY int67+}68+69+func buildTreePuzzle(size, targetActive int, profile generateProfile, rng *rand.Rand) Puzzle {70+ puzzle := newPuzzle(size)71+ root := point{X: size / 2, Y: size / 2}72+ puzzle.Root = root73+74+ active := map[point]struct{}{root: {}}75+ adjacency := map[point]directionMask{root: 0}76+77+ for len(active) < targetActive {78+ bounds := measureActiveBounds(active)79+ frontier := collectFrontier(size, active)80+ if len(frontier) == 0 {81+ break82+ }83+84+ edge := frontier[weightedFrontierIndex(size, frontier, active, adjacency, bounds, profile, rng)]85+ active[edge.to] = struct{}{}86+ adjacency[edge.to] = 087+88+ dir := directionBetween(edge.from, edge.to)89+ adjacency[edge.from] |= dir90+ adjacency[edge.to] |= opposite(dir)91+ }92+93+ for p := range active {94+ kind := nodeCell95+ if p == root {96+ kind = serverCell97+ }98+ puzzle.Tiles[p.Y][p.X] = tile{99+ BaseMask: adjacency[p],100+ Kind: kind,101+ }102+ }103+104+ return puzzle105+}106+107+func collectFrontier(size int, active map[point]struct{}) []frontierEdge {108+ frontier := make([]frontierEdge, 0, len(active)*2)109+ for y := range size {110+ for x := range size {111+ cell := point{X: x, Y: y}112+ if _, ok := active[cell]; !ok {113+ continue114+ }115+ for _, dir := range directions {116+ next := point{X: cell.X + dir.dx, Y: cell.Y + dir.dy}117+ if next.X < 0 || next.X >= size || next.Y < 0 || next.Y >= size {118+ continue119+ }120+ if _, ok := active[next]; ok {121+ continue122+ }123+ frontier = append(frontier, frontierEdge{from: cell, to: next})124+ }125+ }126+ }127+ return frontier128+}129+130+func targetActiveFromFillRatio(size int, fillRatio float64) int {131+ if size <= 1 {132+ return 2133+ }134+ target := int(math.Round(float64(size*size) * fillRatio))135+ if target < 2 {136+ return 2137+ }138+ if target > size*size {139+ return size * size140+ }141+ return target142+}143+144+func measureActiveBounds(active map[point]struct{}) activeBounds {145+ first := true146+ bounds := activeBounds{}147+ for p := range active {148+ if first {149+ bounds = activeBounds{minX: p.X, maxX: p.X, minY: p.Y, maxY: p.Y}150+ first = false151+ continue152+ }153+ if p.X < bounds.minX {154+ bounds.minX = p.X155+ }156+ if p.X > bounds.maxX {157+ bounds.maxX = p.X158+ }159+ if p.Y < bounds.minY {160+ bounds.minY = p.Y161+ }162+ if p.Y > bounds.maxY {163+ bounds.maxY = p.Y164+ }165+ }166+ return bounds167+}168+169+func (b activeBounds) spanX() int {170+ return b.maxX - b.minX + 1171+}172+173+func (b activeBounds) spanY() int {174+ return b.maxY - b.minY + 1175+}176+177+func weightedFrontierIndex(178+ size int,179+ frontier []frontierEdge,180+ active map[point]struct{},181+ adjacency map[point]directionMask,182+ bounds activeBounds,183+ profile generateProfile,184+ rng *rand.Rand,185+) int {186+ if len(frontier) == 1 {187+ return 0188+ }189+190+ weights := make([]int, len(frontier))191+ total := 0192+ for i, edge := range frontier {193+ weight := frontierWeight(size, edge, active, adjacency, bounds, profile)194+ weights[i] = weight195+ total += weight196+ }197+198+ pick := rng.IntN(total)199+ running := 0200+ for i, weight := range weights {201+ running += weight202+ if pick < running {203+ return i204+ }205+ }206+ return len(frontier) - 1207+}208+209+func frontierWeight(210+ size int,211+ edge frontierEdge,212+ active map[point]struct{},213+ adjacency map[point]directionMask,214+ bounds activeBounds,215+ profile generateProfile,216+) int {217+ deg := degree(adjacency[edge.from])218+ if deg >= len(profile.ParentDegreeWeights) {219+ deg = len(profile.ParentDegreeWeights) - 1220+ }221+222+ weight := 64 + profile.ParentDegreeWeights[deg]223+ orthogonal, diagonal := packedNeighborCounts(size, edge, active)224+ weight += orthogonal * profile.OrthogonalPackedWeight225+ weight += diagonal * profile.DiagonalPackedWeight226+ weight += spanGrowthScore(size, edge.to, bounds, profile)227+ if weight < 1 {228+ return 1229+ }230+ return weight231+}232+233+func packedNeighborCounts(234+ size int,235+ edge frontierEdge,236+ active map[point]struct{},237+) (int, int) {238+ orthogonal := 0239+ diagonal := 0240+ for _, dir := range directions {241+ next := point{X: edge.to.X + dir.dx, Y: edge.to.Y + dir.dy}242+ if next == edge.from || !inBounds(size, next) {243+ continue244+ }245+ if _, ok := active[next]; ok {246+ orthogonal++247+ }248+ }249+250+ for _, delta := range [][2]int{{-1, -1}, {1, -1}, {1, 1}, {-1, 1}} {251+ next := point{X: edge.to.X + delta[0], Y: edge.to.Y + delta[1]}252+ if !inBounds(size, next) {253+ continue254+ }255+ if _, ok := active[next]; ok {256+ diagonal++257+ }258+ }259+ return orthogonal, diagonal260+}261+262+func spanGrowthScore(size int, candidate point, bounds activeBounds, profile generateProfile) int {263+ target := minSpanTarget(size, profile.MinSpanRatio)264+ if target == 0 || profile.SpanGrowthWeight == 0 {265+ return 0266+ }267+268+ score := 0269+ if bounds.spanX() < target && (candidate.X < bounds.minX || candidate.X > bounds.maxX) {270+ score += profile.SpanGrowthWeight271+ }272+ if bounds.spanY() < target && (candidate.Y < bounds.minY || candidate.Y > bounds.maxY) {273+ score += profile.SpanGrowthWeight274+ }275+ return score276+}277+278+func minSpanTarget(size int, ratio float64) int {279+ if size <= 0 || ratio <= 0 {280+ return 0281+ }282+ target := int(math.Ceil(float64(size) * ratio))283+ if target < 1 {284+ return 1285+ }286+ if target > size {287+ return size288+ }289+ return target290+}291+292+func inBounds(size int, p point) bool {293+ return p.X >= 0 && p.X < size && p.Y >= 0 && p.Y < size294+}295+296+func scramblePuzzle(puzzle *Puzzle, rng *rand.Rand) {297+ if puzzle == nil {298+ return299+ }300+301+ changed := 0302+ nonEmpty := 0303+ for y := range puzzle.Size {304+ for x := range puzzle.Size {305+ t := &puzzle.Tiles[y][x]306+ if !isActive(*t) {307+ continue308+ }309+ nonEmpty++310+ options := uniqueRotations(t.BaseMask)311+ rotation := options[rng.IntN(len(options))]312+ t.Rotation = rotation313+ t.InitialRotation = rotation314+ if rotation != 0 {315+ changed++316+ }317+ }318+ }319+320+ if changed > 0 || nonEmpty == 0 {321+ return322+ }323+324+ active := make([]point, 0, nonEmpty)325+ for y := range puzzle.Size {326+ for x := range puzzle.Size {327+ if isActive(puzzle.Tiles[y][x]) {328+ active = append(active, point{X: x, Y: y})329+ }330+ }331+ }332+ for _, p := range active {333+ t := &puzzle.Tiles[p.Y][p.X]334+ options := uniqueRotations(t.BaseMask)335+ if len(options) <= 1 {336+ continue337+ }338+ t.Rotation = options[1%len(options)]339+ t.InitialRotation = t.Rotation340+ return341+ }342+}343+344+func directionBetween(from, to point) directionMask {345+ switch {346+ case to.X == from.X && to.Y == from.Y-1:347+ return north348+ case to.X == from.X+1 && to.Y == from.Y:349+ return east350+ case to.X == from.X && to.Y == from.Y+1:351+ return south352+ case to.X == from.X-1 && to.Y == from.Y:353+ return west354+ default:355+ return 0356+ }357+}358+359+func opposite(mask directionMask) directionMask {360+ switch mask {361+ case north:362+ return south363+ case east:364+ return west365+ case south:366+ return north367+ case west:368+ return east369+ default:370+ return 0371+ }372+}
A netwalk/grid.go +450 -0
@@ -0,0 +1,450 @@
1+package netwalk2+3+import (4+ "fmt"5+ "math/bits"6+)7+8+type directionMask uint89+10+const (11+ north directionMask = 1 << iota12+ east13+ south14+ west15+)16+17+const allDirections = north | east | south | west18+19+type cellKind uint820+21+const (22+ emptyCell cellKind = iota23+ nodeCell24+ serverCell25+)26+27+type point struct {28+ X int29+ Y int30+}31+32+type tile struct {33+ BaseMask directionMask34+ Rotation uint835+ InitialRotation uint836+ Locked bool37+ Kind cellKind38+}39+40+type Puzzle struct {41+ Size int42+ Root point43+ Tiles [][]tile44+}45+46+type boardState struct {47+ nonEmpty int48+ connected int49+ dangling int50+ locked int51+ solved bool52+ allMatched bool53+ connectedToRoot [][]bool54+ tileHasDangling [][]bool55+ rotatedMasks [][]directionMask56+}57+58+type directionSpec struct {59+ dx int60+ dy int61+ bit directionMask62+ opp directionMask63+}64+65+var directions = []directionSpec{66+ {dx: 0, dy: -1, bit: north, opp: south},67+ {dx: 1, dy: 0, bit: east, opp: west},68+ {dx: 0, dy: 1, bit: south, opp: north},69+ {dx: -1, dy: 0, bit: west, opp: east},70+}71+72+func newPuzzle(size int) Puzzle {73+ tiles := make([][]tile, size)74+ for y := range size {75+ tiles[y] = make([]tile, size)76+ }77+ return Puzzle{Size: size, Tiles: tiles}78+}79+80+func (p Puzzle) inBounds(x, y int) bool {81+ return x >= 0 && x < p.Size && y >= 0 && y < p.Size82+}83+84+func (p Puzzle) activeAt(x, y int) bool {85+ return p.inBounds(x, y) && p.Tiles[y][x].Kind != emptyCell86+}87+88+func (p Puzzle) firstActive() point {89+ if p.activeAt(p.Root.X, p.Root.Y) {90+ return p.Root91+ }92+ for y := range p.Size {93+ for x := range p.Size {94+ if p.activeAt(x, y) {95+ return point{X: x, Y: y}96+ }97+ }98+ }99+ return point{}100+}101+102+func isActive(t tile) bool {103+ return t.Kind != emptyCell104+}105+106+func rotateMask(mask directionMask, rotation uint8) directionMask {107+ shift := rotation % 4108+ if shift == 0 {109+ return mask110+ }111+ value := uint8(mask & allDirections)112+ return directionMask(((value << shift) | (value >> (4 - shift))) & uint8(allDirections))113+}114+115+func degree(mask directionMask) int {116+ return bits.OnesCount8(uint8(mask))117+}118+119+func uniqueRotations(mask directionMask) []uint8 {120+ seen := make(map[directionMask]struct{}, 4)121+ rotations := make([]uint8, 0, 4)122+ for rot := uint8(0); rot < 4; rot++ {123+ rotated := rotateMask(mask, rot)124+ if _, ok := seen[rotated]; ok {125+ continue126+ }127+ seen[rotated] = struct{}{}128+ rotations = append(rotations, rot)129+ }130+ return rotations131+}132+133+func maskGlyph(mask directionMask) string {134+ switch mask {135+ case 0:136+ return " "137+ case north:138+ return "╵"139+ case east:140+ return "╶"141+ case south:142+ return "╷"143+ case west:144+ return "╴"145+ case north | south:146+ return "│"147+ case east | west:148+ return "─"149+ case north | east:150+ return "└"151+ case east | south:152+ return "┌"153+ case south | west:154+ return "┐"155+ case west | north:156+ return "┘"157+ case north | east | south:158+ return "├"159+ case east | south | west:160+ return "┬"161+ case south | west | north:162+ return "┤"163+ case west | north | east:164+ return "┴"165+ case north | east | south | west:166+ return "┼"167+ default:168+ return "?"169+ }170+}171+172+func encodeMaskRows(tiles [][]tile) string {173+ return encodeRows(len(tiles), func(x, y int) byte {174+ return nibbleHex(uint8(tiles[y][x].BaseMask))175+ })176+}177+178+func encodeRotationRows(tiles [][]tile, initial bool) string {179+ return encodeRows(len(tiles), func(x, y int) byte {180+ value := tiles[y][x].Rotation181+ if initial {182+ value = tiles[y][x].InitialRotation183+ }184+ return byte('0' + value%4)185+ })186+}187+188+func encodeKindRows(tiles [][]tile) string {189+ return encodeRows(len(tiles), func(x, y int) byte {190+ switch tiles[y][x].Kind {191+ case serverCell:192+ return 'S'193+ case nodeCell:194+ return '#'195+ default:196+ return '.'197+ }198+ })199+}200+201+func encodeLockRows(tiles [][]tile) string {202+ return encodeRows(len(tiles), func(x, y int) byte {203+ if tiles[y][x].Locked {204+ return '#'205+ }206+ return '.'207+ })208+}209+210+func decodePuzzle(size int, masks, rotations, initial, kinds, locks string) (Puzzle, error) {211+ if size <= 0 {212+ return Puzzle{}, fmt.Errorf("invalid netwalk size %d", size)213+ }214+ maskRows, err := decodeRows(size, masks)215+ if err != nil {216+ return Puzzle{}, fmt.Errorf("decode masks: %w", err)217+ }218+ rotationRows, err := decodeRows(size, rotations)219+ if err != nil {220+ return Puzzle{}, fmt.Errorf("decode rotations: %w", err)221+ }222+ initialRows, err := decodeRows(size, initial)223+ if err != nil {224+ return Puzzle{}, fmt.Errorf("decode initial rotations: %w", err)225+ }226+ kindRows, err := decodeRows(size, kinds)227+ if err != nil {228+ return Puzzle{}, fmt.Errorf("decode kinds: %w", err)229+ }230+ lockRows, err := decodeRows(size, locks)231+ if err != nil {232+ return Puzzle{}, fmt.Errorf("decode locks: %w", err)233+ }234+235+ puzzle := newPuzzle(size)236+ rootCount := 0237+ for y := range size {238+ for x := range size {239+ maskValue, ok := parseNibble(maskRows[y][x])240+ if !ok {241+ return Puzzle{}, fmt.Errorf("invalid mask value %q at (%d,%d)", maskRows[y][x], x, y)242+ }243+ rotationValue := rotationRows[y][x]244+ initialValue := initialRows[y][x]245+ if rotationValue < '0' || rotationValue > '3' || initialValue < '0' || initialValue > '3' {246+ return Puzzle{}, fmt.Errorf("invalid rotation at (%d,%d)", x, y)247+ }248+249+ t := tile{250+ BaseMask: directionMask(maskValue),251+ Rotation: uint8(rotationValue - '0'),252+ InitialRotation: uint8(initialValue - '0'),253+ }254+255+ switch kindRows[y][x] {256+ case 'S':257+ t.Kind = serverCell258+ puzzle.Root = point{X: x, Y: y}259+ rootCount++260+ case '#':261+ t.Kind = nodeCell262+ case '.':263+ t.Kind = emptyCell264+ default:265+ return Puzzle{}, fmt.Errorf("invalid cell kind %q at (%d,%d)", kindRows[y][x], x, y)266+ }267+268+ if t.Kind == emptyCell && t.BaseMask != 0 {269+ return Puzzle{}, fmt.Errorf("empty tile at (%d,%d) has mask", x, y)270+ }271+ if t.Kind != emptyCell && t.BaseMask == 0 {272+ return Puzzle{}, fmt.Errorf("active tile at (%d,%d) has empty mask", x, y)273+ }274+ if lockRows[y][x] == '#' {275+ t.Locked = true276+ }277+ puzzle.Tiles[y][x] = t278+ }279+ }280+281+ if rootCount != 1 {282+ return Puzzle{}, fmt.Errorf("expected exactly one root, got %d", rootCount)283+ }284+285+ return puzzle, nil286+}287+288+func encodeRows(size int, valueAt func(x, y int) byte) string {289+ buf := make([]byte, 0, size*size+max(size-1, 0))290+ for y := range size {291+ for x := range size {292+ buf = append(buf, valueAt(x, y))293+ }294+ if y < size-1 {295+ buf = append(buf, '\n')296+ }297+ }298+ return string(buf)299+}300+301+func decodeRows(size int, raw string) ([][]byte, error) {302+ rows := make([][]byte, size)303+ currentRow := 0304+ currentCol := 0305+ rows[currentRow] = make([]byte, size)306+307+ for i := 0; i < len(raw); i++ {308+ ch := raw[i]309+ if ch == '\r' {310+ continue311+ }312+ if ch == '\n' {313+ if currentCol != size {314+ return nil, fmt.Errorf("row %d has width %d, want %d", currentRow, currentCol, size)315+ }316+ currentRow++317+ if currentRow >= size {318+ return nil, fmt.Errorf("too many rows")319+ }320+ rows[currentRow] = make([]byte, size)321+ currentCol = 0322+ continue323+ }324+ if currentCol >= size {325+ return nil, fmt.Errorf("row %d exceeds width %d", currentRow, size)326+ }327+ rows[currentRow][currentCol] = ch328+ currentCol++329+ }330+331+ if currentRow != size-1 || currentCol != size {332+ return nil, fmt.Errorf("incomplete grid")333+ }334+335+ return rows, nil336+}337+338+func nibbleHex(value uint8) byte {339+ if value < 10 {340+ return '0' + value341+ }342+ return 'a' + (value - 10)343+}344+345+func parseNibble(value byte) (uint8, bool) {346+ switch {347+ case value >= '0' && value <= '9':348+ return value - '0', true349+ case value >= 'a' && value <= 'f':350+ return 10 + value - 'a', true351+ case value >= 'A' && value <= 'F':352+ return 10 + value - 'A', true353+ default:354+ return 0, false355+ }356+}357+358+func analyzePuzzle(p Puzzle) boardState {359+ state := boardState{360+ connectedToRoot: make([][]bool, p.Size),361+ tileHasDangling: make([][]bool, p.Size),362+ rotatedMasks: make([][]directionMask, p.Size),363+ }364+ for y := range p.Size {365+ state.connectedToRoot[y] = make([]bool, p.Size)366+ state.tileHasDangling[y] = make([]bool, p.Size)367+ state.rotatedMasks[y] = make([]directionMask, p.Size)368+ for x := range p.Size {369+ t := p.Tiles[y][x]370+ if !isActive(t) {371+ continue372+ }373+ state.nonEmpty++374+ if t.Locked {375+ state.locked++376+ }377+ state.rotatedMasks[y][x] = rotateMask(t.BaseMask, t.Rotation)378+ }379+ }380+381+ allMatched := true382+ halfEdges := 0383+ for y := range p.Size {384+ for x := range p.Size {385+ t := p.Tiles[y][x]386+ if !isActive(t) {387+ continue388+ }389+ mask := state.rotatedMasks[y][x]390+ for _, dir := range directions {391+ if mask&dir.bit == 0 {392+ continue393+ }394+ halfEdges++395+ nx := x + dir.dx396+ ny := y + dir.dy397+ if !p.activeAt(nx, ny) {398+ state.tileHasDangling[y][x] = true399+ state.dangling++400+ allMatched = false401+ continue402+ }403+ neighborMask := state.rotatedMasks[ny][nx]404+ if neighborMask&dir.opp == 0 {405+ state.tileHasDangling[y][x] = true406+ state.dangling++407+ allMatched = false408+ }409+ }410+ }411+ }412+413+ state.allMatched = allMatched414+ if !p.activeAt(p.Root.X, p.Root.Y) {415+ return state416+ }417+418+ queue := []point{p.Root}419+ state.connectedToRoot[p.Root.Y][p.Root.X] = true420+ for len(queue) > 0 {421+ cur := queue[0]422+ queue = queue[1:]423+ state.connected++424+ mask := state.rotatedMasks[cur.Y][cur.X]425+ for _, dir := range directions {426+ if mask&dir.bit == 0 {427+ continue428+ }429+ nx := cur.X + dir.dx430+ ny := cur.Y + dir.dy431+ if !p.activeAt(nx, ny) || state.connectedToRoot[ny][nx] {432+ continue433+ }434+ neighborMask := state.rotatedMasks[ny][nx]435+ if neighborMask&dir.opp == 0 {436+ continue437+ }438+ state.connectedToRoot[ny][nx] = true439+ queue = append(queue, point{X: nx, Y: ny})440+ }441+ }442+443+ matchedEdges := halfEdges / 2444+ state.solved = state.nonEmpty > 0 &&445+ allMatched &&446+ state.connected == state.nonEmpty &&447+ matchedEdges == state.nonEmpty-1448+449+ return state450+}
A netwalk/help.md +30 -0
A netwalk/keys.go +49 -0
A netwalk/model.go +201 -0
@@ -0,0 +1,201 @@
1+package netwalk2+3+import (4+ "fmt"5+6+ "charm.land/bubbles/v2/key"7+ tea "charm.land/bubbletea/v2"8+ "github.com/FelineStateMachine/puzzletea/game"9+)10+11+type Model struct {12+ puzzle Puzzle13+ cursor game.Cursor14+ keys KeyMap15+ modeTitle string16+ showFullHelp bool17+ termWidth int18+ termHeight int19+ originX int20+ originY int21+ originValid bool22+ state boardState23+}24+25+var _ game.Gamer = Model{}26+27+func New(mode NetwalkMode, puzzle Puzzle) (game.Gamer, error) {28+ cursor := puzzle.firstActive()29+ m := Model{30+ puzzle: puzzle,31+ cursor: game.Cursor{X: cursor.X, Y: cursor.Y},32+ keys: DefaultKeyMap,33+ modeTitle: mode.Title(),34+ }35+ m.recompute()36+ return m, nil37+}38+39+func (m Model) Init() tea.Cmd {40+ return nil41+}42+43+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {44+ switch msg := msg.(type) {45+ case game.HelpToggleMsg:46+ m.showFullHelp = msg.Show47+ m.originValid = false48+ case tea.WindowSizeMsg:49+ m.termWidth = msg.Width50+ m.termHeight = msg.Height51+ m.originValid = false52+ case tea.MouseClickMsg:53+ m = m.handleMouseClick(msg)54+ case tea.KeyPressMsg:55+ switch {56+ case key.Matches(msg, m.keys.Rotate):57+ m.rotateCurrent(1)58+ case key.Matches(msg, m.keys.RotateBack):59+ m.rotateCurrent(3)60+ case key.Matches(msg, m.keys.Lock):61+ m.toggleCurrentLock()62+ default:63+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Size-1, m.puzzle.Size-1)64+ }65+ }66+ m.updateKeyBindings()67+ return m, nil68+}69+70+func (m Model) View() string {71+ title := game.TitleBarView("Netwalk", m.modeTitle, m.state.solved)72+ grid := gridView(m)73+ if m.state.solved {74+ return game.ComposeGameView(title, grid)75+ }76+ return game.ComposeGameViewRows(77+ title,78+ grid,79+ game.StableRow(statusBarView(m, m.showFullHelp), statusBarView(m, false), statusBarView(m, true)),80+ )81+}82+83+func (m Model) SetTitle(t string) game.Gamer {84+ m.modeTitle = t85+ return m86+}87+88+func (m Model) IsSolved() bool {89+ return m.state.solved90+}91+92+func (m Model) Reset() game.Gamer {93+ for y := range m.puzzle.Size {94+ for x := range m.puzzle.Size {95+ m.puzzle.Tiles[y][x].Rotation = m.puzzle.Tiles[y][x].InitialRotation96+ m.puzzle.Tiles[y][x].Locked = false97+ }98+ }99+ cursor := m.puzzle.firstActive()100+ m.cursor = game.Cursor{X: cursor.X, Y: cursor.Y}101+ m.originValid = false102+ m.recompute()103+ return m104+}105+106+func (m Model) GetDebugInfo() string {107+ return game.DebugHeader("Netwalk", [][2]string{108+ {"Status", stateLabel(m.state.solved)},109+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},110+ {"Grid Size", fmt.Sprintf("%d×%d", m.puzzle.Size, m.puzzle.Size)},111+ {"Root", fmt.Sprintf("(%d, %d)", m.puzzle.Root.X, m.puzzle.Root.Y)},112+ {"Connected", fmt.Sprintf("%d / %d", m.state.connected, m.state.nonEmpty)},113+ {"Dangling", fmt.Sprintf("%d", m.state.dangling)},114+ {"Locked", fmt.Sprintf("%d", m.state.locked)},115+ })116+}117+118+func stateLabel(solved bool) string {119+ if solved {120+ return "Solved"121+ }122+ return "In Progress"123+}124+125+func (m *Model) rotateCurrent(delta uint8) {126+ if m.state.solved {127+ return128+ }129+ t := &m.puzzle.Tiles[m.cursor.Y][m.cursor.X]130+ if !isActive(*t) || t.Locked {131+ return132+ }133+ t.Rotation = (t.Rotation + delta) % 4134+ m.recompute()135+}136+137+func (m *Model) toggleCurrentLock() {138+ if m.state.solved {139+ return140+ }141+ t := &m.puzzle.Tiles[m.cursor.Y][m.cursor.X]142+ if !isActive(*t) {143+ return144+ }145+ t.Locked = !t.Locked146+ m.recompute()147+}148+149+func (m *Model) recompute() {150+ m.state = analyzePuzzle(m.puzzle)151+ m.originValid = false152+}153+154+func (m Model) handleMouseClick(msg tea.MouseClickMsg) Model {155+ col, row, ok := m.screenToGrid(msg.X, msg.Y)156+ if !ok {157+ return m158+ }159+ m.cursor.X = col160+ m.cursor.Y = row161+162+ switch msg.Button {163+ case tea.MouseLeft:164+ m.rotateCurrent(1)165+ case tea.MouseRight:166+ m.toggleCurrentLock()167+ }168+ return m169+}170+171+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {172+ ox, oy := m.cachedGridOrigin()173+ lx := screenX - ox174+ ly := screenY - oy175+ if lx < 0 || ly < 0 {176+ return 0, 0, false177+ }178+179+ col = lx / cellWidth180+ row = ly / cellHeight181+ if col < 0 || col >= m.puzzle.Size || row < 0 || row >= m.puzzle.Size {182+ return 0, 0, false183+ }184+ return col, row, true185+}186+187+func (m *Model) cachedGridOrigin() (x, y int) {188+ if m.originValid {189+ return m.originX, m.originY190+ }191+ x, y = m.gridOrigin()192+ m.originX, m.originY = x, y193+ m.originValid = true194+ return x, y195+}196+197+func (m *Model) gridOrigin() (x, y int) {198+ title := game.TitleBarView("Netwalk", m.modeTitle, m.state.solved)199+ grid := gridView(*m)200+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)201+}
A netwalk/netwalk_test.go +350 -0
@@ -0,0 +1,350 @@
1+package netwalk2+3+import (4+ "math"5+ "math/rand/v2"6+ "strings"7+ "testing"8+9+ "charm.land/bubbles/v2/key"10+ tea "charm.land/bubbletea/v2"11+ "github.com/FelineStateMachine/puzzletea/game"12+ "github.com/charmbracelet/x/ansi"13+)14+15+func TestRotateMask(t *testing.T) {16+ mask := north | east17+ if got := rotateMask(mask, 1); got != east|south {18+ t.Fatalf("rotateMask(..., 1) = %v, want %v", got, east|south)19+ }20+ if got := rotateMask(mask, 2); got != south|west {21+ t.Fatalf("rotateMask(..., 2) = %v, want %v", got, south|west)22+ }23+}24+25+func TestAnalyzePuzzleSolvedAndDangling(t *testing.T) {26+ puzzle := newPuzzle(2)27+ puzzle.Root = point{X: 0, Y: 0}28+ puzzle.Tiles[0][0] = tile{BaseMask: east, Kind: serverCell}29+ puzzle.Tiles[0][1] = tile{BaseMask: west, Kind: nodeCell}30+31+ state := analyzePuzzle(puzzle)32+ if !state.solved {33+ t.Fatal("expected simple 2-cell puzzle to be solved")34+ }35+36+ puzzle.Tiles[0][1].Rotation = 137+ state = analyzePuzzle(puzzle)38+ if state.solved {39+ t.Fatal("rotated puzzle should not be solved")40+ }41+ if state.dangling == 0 {42+ t.Fatal("expected dangling connectors after bad rotation")43+ }44+}45+46+func TestSaveImportRoundTrip(t *testing.T) {47+ puzzle := newPuzzle(3)48+ puzzle.Root = point{X: 1, Y: 1}49+ puzzle.Tiles[1][1] = tile{BaseMask: north | east | south, Rotation: 1, InitialRotation: 2, Kind: serverCell, Locked: true}50+ puzzle.Tiles[0][1] = tile{BaseMask: south, Rotation: 3, InitialRotation: 3, Kind: nodeCell}51+ puzzle.Tiles[1][2] = tile{BaseMask: west, Rotation: 0, InitialRotation: 1, Kind: nodeCell}52+ puzzle.Tiles[2][1] = tile{BaseMask: north, Rotation: 2, InitialRotation: 2, Kind: nodeCell}53+54+ m := Model{puzzle: puzzle, keys: DefaultKeyMap, modeTitle: "Test"}55+ m.recompute()56+ save, err := m.GetSave()57+ if err != nil {58+ t.Fatalf("GetSave() error = %v", err)59+ }60+61+ imported, err := ImportModel(save)62+ if err != nil {63+ t.Fatalf("ImportModel() error = %v", err)64+ }65+66+ if imported.puzzle.Size != puzzle.Size {67+ t.Fatalf("size = %d, want %d", imported.puzzle.Size, puzzle.Size)68+ }69+ if imported.puzzle.Root != puzzle.Root {70+ t.Fatalf("root = %+v, want %+v", imported.puzzle.Root, puzzle.Root)71+ }72+ if !imported.puzzle.Tiles[1][1].Locked {73+ t.Fatal("expected lock state to round-trip")74+ }75+ if imported.puzzle.Tiles[1][1].Rotation != 1 || imported.puzzle.Tiles[1][1].InitialRotation != 2 {76+ t.Fatal("expected rotations to round-trip")77+ }78+}79+80+func TestGenerateSeededDeterministic(t *testing.T) {81+ rngA := rand.New(rand.NewPCG(10, 20))82+ rngB := rand.New(rand.NewPCG(10, 20))83+84+ a, err := GenerateSeeded(7, 14, rngA)85+ if err != nil {86+ t.Fatalf("GenerateSeeded() error = %v", err)87+ }88+ b, err := GenerateSeeded(7, 14, rngB)89+ if err != nil {90+ t.Fatalf("GenerateSeeded() error = %v", err)91+ }92+93+ if got, want := encodeMaskRows(a.Tiles), encodeMaskRows(b.Tiles); got != want {94+ t.Fatalf("mask encoding mismatch\n got %q\nwant %q", got, want)95+ }96+ if got, want := encodeRotationRows(a.Tiles, true), encodeRotationRows(b.Tiles, true); got != want {97+ t.Fatalf("initial rotations mismatch\n got %q\nwant %q", got, want)98+ }99+}100+101+func TestDefaultKeyMapUsesEnterForLockAndSpaceForRotate(t *testing.T) {102+ if !key.Matches(103+ keyPress("space"),104+ DefaultKeyMap.Rotate,105+ ) {106+ t.Fatal("space should match rotate binding")107+ }108+ if key.Matches(109+ keyPress("enter"),110+ DefaultKeyMap.Rotate,111+ ) {112+ t.Fatal("enter should not match rotate binding")113+ }114+ if !key.Matches(115+ keyPress("enter"),116+ DefaultKeyMap.Lock,117+ ) {118+ t.Fatal("enter should match lock binding")119+ }120+ if key.Matches(121+ keyPress("l"),122+ DefaultKeyMap.Lock,123+ ) {124+ t.Fatal("l should not match lock binding")125+ }126+}127+128+func TestFrontierWeightPrefersPackedCandidatesOnHardProfiles(t *testing.T) {129+ active := map[point]struct{}{130+ {X: 2, Y: 2}: {},131+ {X: 1, Y: 1}: {},132+ {X: 3, Y: 1}: {},133+ }134+ adjacency := map[point]directionMask{135+ {X: 2, Y: 2}: 0,136+ {X: 1, Y: 1}: 0,137+ {X: 3, Y: 1}: 0,138+ }139+ bounds := activeBounds{minX: 0, maxX: 4, minY: 0, maxY: 4}140+141+ packed := frontierWeight(142+ 5,143+ frontierEdge{from: point{X: 2, Y: 2}, to: point{X: 2, Y: 1}},144+ active,145+ adjacency,146+ bounds,147+ hardProfile,148+ )149+ isolated := frontierWeight(150+ 5,151+ frontierEdge{from: point{X: 2, Y: 2}, to: point{X: 2, Y: 3}},152+ active,153+ adjacency,154+ bounds,155+ hardProfile,156+ )157+ if packed <= isolated {158+ t.Fatalf("packed frontier weight = %d, want > isolated %d", packed, isolated)159+ }160+}161+162+func TestSpanGrowthScoreRewardsExpansionBeforeTarget(t *testing.T) {163+ bounds := activeBounds{minX: 2, maxX: 4, minY: 2, maxY: 4}164+165+ growing := spanGrowthScore(9, point{X: 1, Y: 4}, bounds, mediumProfile)166+ stable := spanGrowthScore(9, point{X: 3, Y: 3}, bounds, mediumProfile)167+ if growing <= stable {168+ t.Fatalf("expanding span score = %d, want > stable %d", growing, stable)169+ }170+}171+172+func TestNetwalkModeDensityProgression(t *testing.T) {173+ modes := netwalkModesFromRegistry(t)174+ fill := make([]float64, len(modes))175+ junctions := make([]float64, len(modes))176+177+ for i, mode := range modes {178+ fill[i], junctions[i] = sampleModeMetrics(t, mode, 12)179+ }180+181+ for i := 1; i < len(fill); i++ {182+ if fill[i] <= fill[i-1] {183+ t.Fatalf("fill ratio[%d] = %.3f, want > %.3f", i, fill[i], fill[i-1])184+ }185+ }186+ for i := 2; i < len(junctions); i++ {187+ if junctions[i] <= junctions[i-1] {188+ t.Fatalf("junction avg[%d] = %.3f, want > %.3f", i, junctions[i], junctions[i-1])189+ }190+ }191+192+ for i, mode := range modes {193+ target := float64(targetActiveFromFillRatio(mode.Size, mode.FillRatio)) / float64(mode.Size*mode.Size)194+ if math.Abs(fill[i]-target) > 1e-9 {195+ t.Fatalf("%s fill ratio = %.3f, want %.3f", mode.Title(), fill[i], target)196+ }197+ }198+}199+200+func TestCellRowsShowDirectionalRootsAndLeaves(t *testing.T) {201+ m := Model{}202+203+ m.puzzle = newPuzzle(3)204+ m.puzzle.Tiles[1][1] = tile{Kind: serverCell}205+ m.puzzle.Tiles[1][2] = tile{Kind: nodeCell}206+ m.puzzle.Tiles[0][1] = tile{Kind: nodeCell}207+ m.state.rotatedMasks = make([][]directionMask, 3)208+ for y := range 3 {209+ m.state.rotatedMasks[y] = make([]directionMask, 3)210+ }211+ m.state.rotatedMasks[1][1] = south212+ m.state.rotatedMasks[1][2] = north213+ m.state.rotatedMasks[0][1] = north | east | south214+215+ if got := cellRows(m, 1, 1); got != [cellHeight]string{" ", " ◆ ", " │ "} {216+ t.Fatalf("south root rows = %#v", got)217+ }218+ if got := cellRows(m, 2, 1); got != [cellHeight]string{" │ ", " ● ", " "} {219+ t.Fatalf("north leaf rows = %#v", got)220+ }221+ if got := cellRows(m, 1, 0); got != [cellHeight]string{" │ ", " ├──", " │ "} {222+ t.Fatalf("tee rows = %#v", got)223+ }224+}225+226+func TestGridViewUsesTallerFrameWithoutInteriorBoxes(t *testing.T) {227+ m := Model{228+ puzzle: newPuzzle(2),229+ }230+ m.recompute()231+232+ lines := strings.Split(ansi.Strip(gridView(m)), "\n")233+ if len(lines) != 8 {234+ t.Fatalf("rendered line count = %d, want 8", len(lines))235+ }236+ for _, idx := range []int{1, 2, 3, 4, 5, 6} {237+ if got := strings.Count(lines[idx], "│"); got != 2 {238+ t.Fatalf("line %d has %d vertical borders, want outer frame only", idx, got)239+ }240+ }241+}242+243+func TestGridViewShowsCursorGlyphsOnBlankCells(t *testing.T) {244+ m := Model{245+ puzzle: newPuzzle(2),246+ cursor: game.Cursor{X: 1, Y: 1},247+ }248+ m.recompute()249+250+ view := ansi.Strip(gridView(m))251+ if !strings.Contains(view, "▸ ◂") {252+ t.Fatalf("blank cursor markers missing from view:\n%s", view)253+ }254+}255+256+func TestRecomputeInvalidatesCachedGridOrigin(t *testing.T) {257+ m := Model{258+ puzzle: newPuzzle(2),259+ modeTitle: "Origin Check",260+ termWidth: 120,261+ termHeight: 40,262+ originX: 17,263+ originY: 9,264+ originValid: true,265+ showFullHelp: true,266+ }267+268+ m.recompute()269+270+ if m.originValid {271+ t.Fatal("expected recompute to invalidate cached grid origin")272+ }273+}274+275+func netwalkModesFromRegistry(t *testing.T) []NetwalkMode {276+ t.Helper()277+278+ modes := make([]NetwalkMode, 0, len(Modes))279+ for i, mode := range Modes {280+ netwalkMode, ok := mode.(NetwalkMode)281+ if !ok {282+ t.Fatalf("mode %d has type %T, want NetwalkMode", i, mode)283+ }284+ modes = append(modes, netwalkMode)285+ }286+ return modes287+}288+289+func sampleModeMetrics(290+ t *testing.T,291+ mode NetwalkMode,292+ samples int,293+) (float64, float64) {294+ t.Helper()295+296+ var totalFill float64297+ var totalJunctions float64298+ for i := range samples {299+ rng := rand.New(rand.NewPCG(uint64(1000+i), uint64(7000+i)))300+ puzzle, err := GenerateSeededWithDensity(mode.Size, mode.FillRatio, mode.Profile, rng)301+ if err != nil {302+ t.Fatalf("GenerateSeededWithDensity(%q) error = %v", mode.Title(), err)303+ }304+ totalFill += puzzleFillRatio(puzzle)305+ totalJunctions += float64(puzzleJunctionCount(puzzle))306+ }307+ return totalFill / float64(samples), totalJunctions / float64(samples)308+}309+310+func puzzleFillRatio(p Puzzle) float64 {311+ if p.Size <= 0 {312+ return 0313+ }314+ active := 0315+ for y := range p.Size {316+ for x := range p.Size {317+ if isActive(p.Tiles[y][x]) {318+ active++319+ }320+ }321+ }322+ return float64(active) / float64(p.Size*p.Size)323+}324+325+func puzzleJunctionCount(p Puzzle) int {326+ count := 0327+ for y := range p.Size {328+ for x := range p.Size {329+ if !isActive(p.Tiles[y][x]) {330+ continue331+ }332+ if degree(p.Tiles[y][x].BaseMask) >= 3 {333+ count++334+ }335+ }336+ }337+ return count338+}339+340+func keyPress(value string) tea.KeyPressMsg {341+ switch value {342+ case "enter":343+ return tea.KeyPressMsg{Code: tea.KeyEnter}344+ case "space":345+ return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}346+ default:347+ r := []rune(value)348+ return tea.KeyPressMsg{Code: r[0], Text: value}349+ }350+}
A netwalk/print_adapter.go +147 -0
@@ -0,0 +1,147 @@
1+package netwalk2+3+import (4+ "math"5+6+ "codeberg.org/go-pdf/fpdf"7+ "github.com/FelineStateMachine/puzzletea/pdfexport"8+)9+10+type printAdapter struct{}11+12+var PDFPrintAdapter = printAdapter{}13+14+func (printAdapter) CanonicalGameType() string { return "Netwalk" }15+func (printAdapter) Aliases() []string { return []string{"netwalk", "network"} }16+17+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {18+ return pdfexport.ParseNetwalkPrintData(save)19+}20+21+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {22+ switch data := payload.(type) {23+ case *pdfexport.NetwalkData:24+ renderNetwalkPage(pdf, data)25+ }26+ return nil27+}28+29+func renderNetwalkPage(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData) {30+ if data == nil || data.Size <= 0 {31+ return32+ }33+34+ pageW, pageH := pdf.GetPageSize()35+ pageNo := pdf.PageNo()36+ body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo)37+ rules := []string{"Rotate tiles so every connector matches and the full network reaches the server without loops."}38+ ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules)39+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines)40+ cellSize := pdfexport.FitCompactCellSize(data.Size, data.Size, area)41+ if cellSize <= 0 {42+ return43+ }44+45+ blockW := float64(data.Size) * cellSize46+ blockH := float64(data.Size) * cellSize47+ startX, startY := pdfexport.CenteredOrigin(area, data.Size, data.Size, cellSize)48+49+ drawNetwalkGrid(pdf, startX, startY, blockW, blockH, data.Size, cellSize)50+51+ for y := range data.Size {52+ for x := range data.Size {53+ cellX := startX + float64(x)*cellSize54+ cellY := startY + float64(y)*cellSize55+ drawNetwalkTile(pdf, data, x, y, cellX, cellY, cellSize)56+ }57+ }58+59+ pdf.SetDrawColor(55, 55, 55)60+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)61+ pdf.Rect(startX, startY, blockW, blockH, "D")62+63+ ruleY := pdfexport.InstructionY(startY+blockH, pageH, ruleLines)64+ pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules)65+}66+67+func drawNetwalkGrid(pdf *fpdf.Fpdf, startX, startY, blockW, blockH float64, size int, cellSize float64) {68+ if size <= 1 {69+ return70+ }71+72+ pdf.SetDrawColor(115, 115, 115)73+ pdf.SetLineWidth(math.Max(pdfexport.ThinGridLineMM*0.72, 0.14))74+75+ for i := 1; i < size; i++ {76+ offset := float64(i) * cellSize77+ pdf.Line(startX+offset, startY, startX+offset, startY+blockH)78+ pdf.Line(startX, startY+offset, startX+blockW, startY+offset)79+ }80+}81+82+func drawNetwalkTile(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData, x, y int, cellX, cellY, cellSize float64) {83+ mask := directionMask(data.Masks[y][x])84+ if mask == 0 {85+ return86+ }87+ mask = rotateMask(mask, data.Rotations[y][x])88+89+ centerX := cellX + cellSize/290+ centerY := cellY + cellSize/291+ isRoot := x == data.RootX && y == data.RootY92+ isLeaf := degree(mask) == 193+ pad := cellSize * 0.1694+ if isRoot || isLeaf {95+ pad = math.Max(pad, netwalkMarkerRadius(cellSize, isRoot))96+ }97+ pdf.SetLineWidth(math.Max(cellSize*0.055, 0.26))98+ pdf.SetDrawColor(65, 65, 65)99+100+ if mask&north != 0 {101+ pdf.Line(centerX, centerY, centerX, cellY+pad)102+ }103+ if mask&east != 0 {104+ pdf.Line(centerX, centerY, cellX+cellSize-pad, centerY)105+ }106+ if mask&south != 0 {107+ pdf.Line(centerX, centerY, centerX, cellY+cellSize-pad)108+ }109+ if mask&west != 0 {110+ pdf.Line(centerX, centerY, cellX+pad, centerY)111+ }112+113+ switch {114+ case isRoot:115+ drawNetwalkSourceMarker(pdf, centerX, centerY, cellSize)116+ return117+ case isLeaf:118+ drawNetwalkSinkMarker(pdf, centerX, centerY, cellSize)119+ }120+}121+122+func netwalkMarkerRadius(cellSize float64, root bool) float64 {123+ scale := 0.16124+ if root {125+ scale = 0.20126+ }127+ return math.Max(1.2, math.Min(2.6, cellSize*scale))128+}129+130+func drawNetwalkSinkMarker(pdf *fpdf.Fpdf, centerX, centerY, cellSize float64) {131+ radius := netwalkMarkerRadius(cellSize, false)132+ pdf.SetDrawColor(65, 65, 65)133+ pdf.SetFillColor(255, 255, 255)134+ pdf.SetLineWidth(math.Max(cellSize*0.045, 0.22))135+ pdf.Circle(centerX, centerY, radius, "DF")136+}137+138+func drawNetwalkSourceMarker(pdf *fpdf.Fpdf, centerX, centerY, cellSize float64) {139+ radius := netwalkMarkerRadius(cellSize, true)140+ pdf.SetDrawColor(65, 65, 65)141+ pdf.SetFillColor(255, 255, 255)142+ pdf.SetLineWidth(math.Max(cellSize*0.05, 0.24))143+ pdf.Circle(centerX, centerY, radius, "DF")144+145+ pdf.SetFillColor(65, 65, 65)146+ pdf.Circle(centerX, centerY, radius*0.36, "F")147+}
A netwalk/style.go +180 -0
@@ -0,0 +1,180 @@
1+package netwalk2+3+import (4+ "image/color"5+ "strconv"6+ "strings"7+8+ "charm.land/lipgloss/v2"9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/theme"11+)12+13+const (14+ cellWidth = 515+ cellHeight = 316+)17+18+func gridView(m Model) string {19+ colors := game.DefaultBorderColors()20+ rows := make([]string, 0, m.puzzle.Size*cellHeight+2)21+ rows = append(rows, game.HBorderRow(m.puzzle.Size, -1, cellWidth, "┌", "┐", colors, m.state.solved))22+ for y := range m.puzzle.Size {23+ for inner := range cellHeight {24+ rows = append(rows, gridContentRow(m, y, inner, colors))25+ }26+ }27+ rows = append(rows, game.HBorderRow(m.puzzle.Size, -1, cellWidth, "└", "┘", colors, m.state.solved))28+ return strings.Join(rows, "\n")29+}30+31+func gridContentRow(m Model, y, inner int, colors game.GridBorderColors) string {32+ var b strings.Builder33+ b.WriteString(game.BorderChar("│", colors, m.state.solved, false))34+ for x := range m.puzzle.Size {35+ b.WriteString(cellRowView(m, x, y, inner))36+ }37+ b.WriteString(game.BorderChar("│", colors, m.state.solved, false))38+ return b.String()39+}40+41+func cellRowView(m Model, x, y, inner int) string {42+ rows := cellRows(m, x, y)43+ t := m.puzzle.Tiles[y][x]44+ if x == m.cursor.X && y == m.cursor.Y && !isActive(t) {45+ rows = blankCursorRows()46+ }47+ style := lipgloss.NewStyle().48+ Width(cellWidth).49+ Background(cellBackground(m, x, y)).50+ Foreground(cellForeground(m, x, y))51+52+ if x == m.cursor.X && y == m.cursor.Y && isActive(t) {53+ style = style.Bold(true)54+ }55+ if t.Kind == serverCell || degree(m.state.rotatedMasks[y][x]) == 1 || m.state.tileHasDangling[y][x] {56+ style = style.Bold(true)57+ }58+59+ return style.Render(rows[inner])60+}61+62+func cellRows(m Model, x, y int) [cellHeight]string {63+ t := m.puzzle.Tiles[y][x]64+ if !isActive(t) {65+ return [cellHeight]string{" ", " ", " "}66+ }67+68+ mask := m.state.rotatedMasks[y][x]69+ center := centerGlyph(m, x, y, t.Kind, mask)70+71+ return [cellHeight]string{72+ verticalCellRow(mask&north != 0),73+ horizontalCellRow(mask&west != 0, center, mask&east != 0),74+ verticalCellRow(mask&south != 0),75+ }76+}77+78+func centerGlyph(m Model, x, y int, kind cellKind, mask directionMask) string {79+ switch {80+ case kind == serverCell:81+ return "◆"82+ case degree(mask) == 1:83+ return "●"84+ default:85+ return maskGlyph(mask)86+ }87+}88+89+func blankCursorRows() [cellHeight]string {90+ return [cellHeight]string{91+ " ",92+ game.CursorLeft + " " + game.CursorRight,93+ " ",94+ }95+}96+97+func verticalCellRow(on bool) string {98+ if !on {99+ return " "100+ }101+ return " │ "102+}103+104+func horizontalCellRow(left bool, center string, right bool) string {105+ leftArm := " "106+ if left {107+ leftArm = "──"108+ }109+ rightArm := " "110+ if right {111+ rightArm = "──"112+ }113+ return leftArm + center + rightArm114+}115+116+func cellBackground(m Model, x, y int) color.Color {117+ t := m.puzzle.Tiles[y][x]118+ if m.state.solved {119+ return theme.Current().SuccessBG120+ }121+ if x == m.cursor.X && y == m.cursor.Y {122+ return theme.Blend(theme.Current().BG, theme.Current().Accent, 0.18)123+ }124+ if !isActive(t) {125+ return theme.Current().BG126+ }127+ if t.Locked {128+ return theme.Blend(theme.Current().BG, theme.Current().Surface, 0.60)129+ }130+ return theme.Current().BG131+}132+133+func cellForeground(m Model, x, y int) color.Color {134+ return pipeForeground(m, point{X: x, Y: y})135+}136+137+func pipeForeground(m Model, cells ...point) color.Color {138+ if m.state.solved {139+ return theme.Current().SolvedFG140+ }141+142+ hasConnected := false143+ for _, cell := range cells {144+ if !m.puzzle.activeAt(cell.X, cell.Y) {145+ continue146+ }147+ tile := m.puzzle.Tiles[cell.Y][cell.X]148+ if tile.Kind == serverCell {149+ return theme.Current().AccentSoft150+ }151+ if stateBoolAt(m.state.tileHasDangling, cell.X, cell.Y) {152+ return theme.Current().Error153+ }154+ if stateBoolAt(m.state.connectedToRoot, cell.X, cell.Y) {155+ hasConnected = true156+ }157+ }158+ if hasConnected {159+ return theme.Current().Secondary160+ }161+ return theme.Current().FG162+}163+164+func hasStateCell(cells [][]bool, x, y int) bool {165+ return y >= 0 && y < len(cells) && x >= 0 && x < len(cells[y])166+}167+168+func stateBoolAt(cells [][]bool, x, y int) bool {169+ return hasStateCell(cells, x, y) && cells[y][x]170+}171+172+func statusBarView(m Model, full bool) string {173+ info := "connected " + strconv.Itoa(m.state.connected) + "/" + strconv.Itoa(m.state.nonEmpty) +174+ " dangling " + strconv.Itoa(m.state.dangling) +175+ " locks " + strconv.Itoa(m.state.locked)176+ if !full {177+ return game.StatusBarStyle().Render(info + "\nspace: rotate enter: lock")178+ }179+ return game.StatusBarStyle().Render(info + "\nspace: rotate backspace: reverse\nenter: toggle lock ctrl+r: reset")180+}
A netwalk/testdata/visual_states.jsonl +10 -0
@@ -0,0 +1,10 @@
1+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":1,"name":"cursor-root-horizontal","game":"Netwalk","mode":"Visual Fixture","save":{"size":4,"masks":"2800\n0000\n0004\n0001","rotations":"0000\n0000\n0000\n0000","initial_rotations":"0000\n0000\n0000\n0000","kinds":"S#..\n....\n...#\n...#","locks":"....\n....\n....\n....","mode_title":"Visual Fixture"}}}2+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":2,"name":"leaf-gallery","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"28008\n00001\n00000\n40000\n20021","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S#..#\n....#\n.....\n#....\n#..##","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}}3+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":3,"name":"straight-and-corner-gallery","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"28ac0\n00050\n0063c\n00109\n00000","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S###.\n...#.\n..###\n..#.#\n.....","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}}4+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":4,"name":"tee-and-cross-gallery","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"28040\n00078\n002f8\n00010\n00000","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S#.#.\n...##\n..###\n...#.\n.....","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}}5+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":5,"name":"connected-horizontal-bridge","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"2a800\n00000\n00000\n00004\n00001","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S##..\n.....\n.....\n....#\n....#","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}}6+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":6,"name":"connected-vertical-bridge","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"40000\n50000\n10000\n00004\n00001","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S....\n#....\n#....\n....#\n....#","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}}7+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":7,"name":"disconnected-default-foreground","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"28000\n0006c\n00039\n00000\n00000","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S#...\n...##\n...##\n.....\n.....","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}}8+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":8,"name":"dangling-error-state","game":"Netwalk","mode":"Visual Fixture","save":{"size":4,"masks":"2800\n0000\n0020\n0000","rotations":"0000\n0000\n0000\n0000","initial_rotations":"0000\n0000\n0000\n0000","kinds":"S#..\n....\n..#.\n....","locks":"....\n....\n....\n....","mode_title":"Visual Fixture"}}}9+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":9,"name":"locked-root-cursor","game":"Netwalk","mode":"Visual Fixture","save":{"size":4,"masks":"2800\n0000\n0004\n0001","rotations":"0000\n0000\n0000\n0000","initial_rotations":"0000\n0000\n0000\n0000","kinds":"S#..\n....\n...#\n...#","locks":"#...\n....\n....\n....","mode_title":"Visual Fixture"}}}10+{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":10,"name":"solved-with-empty-cells","game":"Netwalk","mode":"Visual Fixture","save":{"size":4,"masks":"0000\n0680\n0100\n0000","rotations":"0000\n0000\n0000\n0000","initial_rotations":"0000\n0000\n0000\n0000","kinds":"....\n.S#.\n.#..\n....","locks":"....\n....\n....\n....","mode_title":"Visual Fixture"}}}
M nonogram/export.go +13 -10
M nonogram/print_adapter.go +8 -17
@@ -47,10 +47,14 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *pdfexport.NonogramData) {
4747 colHintRows = 14848 }4949 50+ body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo)51+ rules := []string{"Use row/column hints to fill blocks in order; groups are separated by at least one blank cell."}52+ ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules)5053 layout := layoutNonogram(5154 pageW,5255 pageH,5356 pageNo,57+ ruleLines,5458 data.Width,5559 data.Height,5660 rowHintCols,
@@ -94,22 +98,8 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *pdfexport.NonogramData) {
9498 pdf.SetLineWidth(pdfexport.OuterBorderLineMM)9599 pdf.Rect(xSep, ySep, gridW, gridH, "D")96100 97- ruleY := ySep + gridH + 3.598- ruleY = pdfexport.InstructionY(ruleY-3.5, pageH, 1)99- body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo)100- pdfexport.SetInstructionStyle(pdf)101- pdf.SetXY(body.X, ruleY)102- pdf.CellFormat(103- body.W,104- pdfexport.InstructionLineHMM,105- "Use row/column hints to fill blocks in order; groups are separated by at least one blank cell.",106- "",107- 0,108- "C",109- false,110- 0,111- "",112- )101+ ruleY := pdfexport.InstructionY(ySep+gridH, pageH, ruleLines)102+ pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules)113103 }114104 115105 func drawNonogramPuzzleGrid(
@@ -164,6 +154,7 @@ func layoutNonogram(
164154 pageW,165155 pageH float64,166156 pageNo,157+ ruleLines,167158 gridCols,168159 gridRows,169160 rowHintCols,
@@ -171,7 +162,7 @@ func layoutNonogram(
171162 ) nonogramLayout {172163 totalCols := rowHintCols + gridCols173164 totalRows := colHintRows + gridRows174- area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)165+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines)175166 cellSize := pdfexport.FitNonogramCellSize(totalCols, totalRows, area)176167 if cellSize <= 0 {177168 return nonogramLayout{}
M nonogram/print_adapter_test.go +1 -0
@@ -97,6 +97,7 @@ func TestLayoutNonogramCentersGrid(t *testing.T) {
9797 pageW,9898 pageH,9999 pageNo,100+ 1,100101 10,101102 10,102103 tt.rowHintCol,
M nonogram/style.go +1 -1
A nonogram/testdata/visual_states.jsonl +3 -0
M nurikabe/print_adapter.go +6 -15
M nurikabe/style.go +1 -1
A nurikabe/testdata/visual_states.jsonl +3 -0
M pdfexport/font.go +4 -3
M pdfexport/font_test.go +6 -6
A pdfexport/parse_netwalk.go +104 -0
@@ -0,0 +1,104 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+)8+9+type netwalkSave struct {10+ Size int `json:"size"`11+ Masks string `json:"masks"`12+ InitialRotations string `json:"initial_rotations"`13+ Kinds string `json:"kinds"`14+}15+16+func ParseNetwalkPrintData(saveData []byte) (*NetwalkData, error) {17+ if len(strings.TrimSpace(string(saveData))) == 0 {18+ return nil, nil19+ }20+21+ var save netwalkSave22+ if err := json.Unmarshal(saveData, &save); err != nil {23+ return nil, fmt.Errorf("decode netwalk save: %w", err)24+ }25+ if save.Size <= 0 {26+ return nil, nil27+ }28+29+ maskRows, err := parseNetwalkRows(save.Size, save.Masks)30+ if err != nil {31+ return nil, fmt.Errorf("parse netwalk masks: %w", err)32+ }33+ rotationRows, err := parseNetwalkRows(save.Size, save.InitialRotations)34+ if err != nil {35+ return nil, fmt.Errorf("parse netwalk rotations: %w", err)36+ }37+ kindRows, err := parseNetwalkRows(save.Size, save.Kinds)38+ if err != nil {39+ return nil, fmt.Errorf("parse netwalk kinds: %w", err)40+ }41+42+ data := &NetwalkData{43+ Size: save.Size,44+ Masks: make([][]uint8, save.Size),45+ Rotations: make([][]uint8, save.Size),46+ RootX: -1,47+ RootY: -1,48+ }49+50+ for y := 0; y < save.Size; y++ {51+ data.Masks[y] = make([]uint8, save.Size)52+ data.Rotations[y] = make([]uint8, save.Size)53+ for x := 0; x < save.Size; x++ {54+ mask, ok := parseHexNibble(maskRows[y][x])55+ if !ok {56+ return nil, fmt.Errorf("invalid mask value %q at (%d,%d)", maskRows[y][x], x, y)57+ }58+ if rotationRows[y][x] < '0' || rotationRows[y][x] > '3' {59+ return nil, fmt.Errorf("invalid rotation value %q at (%d,%d)", rotationRows[y][x], x, y)60+ }61+ data.Masks[y][x] = mask62+ data.Rotations[y][x] = rotationRows[y][x] - '0'63+ if kindRows[y][x] == 'S' {64+ data.RootX = x65+ data.RootY = y66+ }67+ }68+ }69+70+ if data.RootX < 0 || data.RootY < 0 {71+ return nil, fmt.Errorf("netwalk print data missing root")72+ }73+74+ return data, nil75+}76+77+func parseNetwalkRows(size int, raw string) ([][]byte, error) {78+ rows := splitNormalizedLines(raw)79+ if len(rows) != size {80+ return nil, fmt.Errorf("row count = %d, want %d", len(rows), size)81+ }82+83+ result := make([][]byte, size)84+ for y, row := range rows {85+ if len(row) != size {86+ return nil, fmt.Errorf("row %d width = %d, want %d", y, len(row), size)87+ }88+ result[y] = []byte(row)89+ }90+ return result, nil91+}92+93+func parseHexNibble(value byte) (uint8, bool) {94+ switch {95+ case value >= '0' && value <= '9':96+ return value - '0', true97+ case value >= 'a' && value <= 'f':98+ return 10 + value - 'a', true99+ case value >= 'A' && value <= 'F':100+ return 10 + value - 'A', true101+ default:102+ return 0, false103+ }104+}
M pdfexport/render.go +185 -66
@@ -4,11 +4,11 @@ import (
44 "fmt"55 "os"66 "path/filepath"7- "strconv"87 "strings"98 "time"109 1110 "codeberg.org/go-pdf/fpdf"11+ "codeberg.org/go-pdf/fpdf/contrib/gofpdi"1212 )1313 1414 func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) error {
@@ -37,73 +37,74 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
3737 cfg.AdvertText = "Find more puzzles at github.com/FelineStateMachine/puzzletea"3838 }3939 40- pdf := fpdf.NewCustom(&fpdf.InitType{40+ logicalPages := buildLogicalPages(len(printablePuzzles), sheetLayoutIncludesCover(cfg.SheetLayout))41+42+ dir := filepath.Dir(outputPath)43+ if dir != "" && dir != "." {44+ if err := os.MkdirAll(dir, 0o755); err != nil {45+ return fmt.Errorf("create output directory: %w", err)46+ }47+ }48+49+ switch cfg.SheetLayout {50+ case SheetLayoutHalfLetter:51+ pdf, err := newConfiguredPDF(SheetLayoutHalfLetter, cfg.Title, true)52+ if err != nil {53+ return err54+ }55+ if err := renderLogicalPages(pdf, logicalPages, docs, printablePuzzles, cfg); err != nil {56+ return err57+ }58+ if got, want := pdf.PageNo(), len(logicalPages); got != want {59+ return fmt.Errorf("rendered %d physical pages, want %d", got, want)60+ }61+ if err := pdf.OutputFileAndClose(outputPath); err != nil {62+ return fmt.Errorf("write pdf file: %w", err)63+ }64+ case SheetLayoutDuplexBooklet:65+ if err := writeDuplexBookletPDF(outputPath, logicalPages, docs, printablePuzzles, cfg); err != nil {66+ return err67+ }68+ default:69+ return fmt.Errorf("unsupported sheet layout %d", cfg.SheetLayout)70+ }71+ return nil72+}73+74+func newRenderPDF(layout SheetLayout) *fpdf.Fpdf {75+ init := &fpdf.InitType{4176 OrientationStr: "P",4277 UnitStr: "mm",4378 Size: fpdf.SizeType{4479 Wd: halfLetterWidthMM,4580 Ht: halfLetterHeightMM,4681 },47- })48- if err := registerPDFFonts(pdf); err != nil {49- return err5082 }51- pdf.SetAutoPageBreak(false, 0)52- pdf.SetCreator("PuzzleTea", true)53- pdf.SetAuthor("PuzzleTea", true)54- pdf.SetTitle(cfg.Title, true)55- footerExcludedPages := map[int]struct{}{}56- pdf.SetFooterFunc(func() {57- pageNo := pdf.PageNo()58- if _, skip := footerExcludedPages[pageNo]; skip {59- return60- }61- pdf.SetY(-8)62- pdf.SetFont(sansFontFamily, "", 8)63- pdf.SetTextColor(footerTextGray, footerTextGray, footerTextGray)64- pdf.CellFormat(0, 4, strconv.Itoa(pageNo), "", 0, "C", false, 0, "")65- })66-67- includeCover := cfg.CoverColor != nil68- if includeCover {69- renderCoverPage(pdf, printablePuzzles, cfg, *cfg.CoverColor)70- footerExcludedPages[pdf.PageNo()] = struct{}{}71- }72-73- renderTitlePage(pdf, docs, printablePuzzles, cfg)74- footerExcludedPages[pdf.PageNo()] = struct{}{}75-76- for _, puzzle := range printablePuzzles {77- if err := renderPuzzlePage(pdf, puzzle); err != nil {78- return fmt.Errorf("render puzzle %q (%s #%d): %w", puzzle.Name, puzzle.Category, puzzle.Index, err)83+ if layout == SheetLayoutDuplexBooklet {84+ init.Size = fpdf.SizeType{85+ Wd: letterWidthMM,86+ Ht: letterHeightMM,7987 }8088 }89+ return fpdf.NewCustom(init)90+}8191 82- totalPagesWithoutPadding := pdf.PageNo()83- if includeCover {84- totalPagesWithoutPadding++ // include upcoming back cover85- }86- for range saddleStitchPadCount(totalPagesWithoutPadding) {87- renderPadPage(pdf)88- footerExcludedPages[pdf.PageNo()] = struct{}{}89- }90-91- if includeCover {92- renderBackCoverPage(pdf, cfg, *cfg.CoverColor)93- footerExcludedPages[pdf.PageNo()] = struct{}{}94- }95-96- dir := filepath.Dir(outputPath)97- if dir != "" && dir != "." {98- if err := os.MkdirAll(dir, 0o755); err != nil {99- return fmt.Errorf("create output directory: %w", err)92+func newConfiguredPDF(layout SheetLayout, title string, registerFonts bool) (*fpdf.Fpdf, error) {93+ pdf := newRenderPDF(layout)94+ if registerFonts {95+ if err := registerPDFFonts(pdf); err != nil {96+ return nil, err10097 }10198 }99+ pdf.SetAutoPageBreak(false, 0)100+ pdf.SetCreator("PuzzleTea", true)101+ pdf.SetAuthor("PuzzleTea", true)102+ pdf.SetTitle(title, true)103+ return pdf, nil104+}102105 103- if err := pdf.OutputFileAndClose(outputPath); err != nil {104- return fmt.Errorf("write pdf file: %w", err)105- }106- return nil106+func sheetLayoutIncludesCover(layout SheetLayout) bool {107+ return layout == SheetLayoutDuplexBooklet107108 }108109 109110 func saddleStitchPadCount(totalPages int) int {
@@ -132,8 +133,130 @@ func filterPrintablePuzzles(puzzles []Puzzle) []Puzzle {
132133 return printable133134 }134135 135-func renderPadPage(pdf *fpdf.Fpdf) {136- pdf.AddPage()136+func renderLogicalPages(137+ pdf *fpdf.Fpdf,138+ pages []logicalPage,139+ docs []PackDocument,140+ puzzles []Puzzle,141+ cfg RenderConfig,142+) error {143+ for _, page := range pages {144+ pdf.AddPage()145+ if err := drawLogicalPage(pdf, page, docs, puzzles, cfg); err != nil {146+ return err147+ }148+ }149+ return nil150+}151+152+func drawLogicalPage(153+ pdf *fpdf.Fpdf,154+ page logicalPage,155+ docs []PackDocument,156+ puzzles []Puzzle,157+ cfg RenderConfig,158+) error {159+ return withLogicalPageNumber(page.Number, func() error {160+ switch page.Kind {161+ case logicalPageCoverOutside:162+ renderOutsideCoverPage(pdf, cfg, page.OutsideSlice)163+ case logicalPageCoverBlank:164+ renderCoverBlankPage(pdf)165+ case logicalPageTitle:166+ renderTitlePage(pdf, docs, puzzles, cfg)167+ case logicalPagePuzzle:168+ if page.PuzzleIndex < 0 || page.PuzzleIndex >= len(puzzles) {169+ return fmt.Errorf("logical page %d references puzzle index %d", page.Number, page.PuzzleIndex)170+ }171+ puzzle := puzzles[page.PuzzleIndex]172+ if err := renderPuzzlePage(pdf, puzzle); err != nil {173+ return fmt.Errorf("render puzzle %q (%s #%d): %w", puzzle.Name, puzzle.Category, puzzle.Index, err)174+ }175+ case logicalPagePad:176+ renderPadPage(pdf)177+ default:178+ return fmt.Errorf("unsupported logical page kind %d", page.Kind)179+ }180+181+ if page.ShowFooter {182+ drawPageFooter(pdf, page.Number)183+ }184+ return nil185+ })186+}187+188+func renderPadPage(_ *fpdf.Fpdf) {}189+190+func writeDuplexBookletPDF(191+ outputPath string,192+ logicalPages []logicalPage,193+ docs []PackDocument,194+ puzzles []Puzzle,195+ cfg RenderConfig,196+) error {197+ tempFile, err := os.CreateTemp("", "puzzletea-booklet-*.pdf")198+ if err != nil {199+ return fmt.Errorf("create temporary booklet pdf: %w", err)200+ }201+ tempPath := tempFile.Name()202+ if err := tempFile.Close(); err != nil {203+ return fmt.Errorf("close temporary booklet pdf handle: %w", err)204+ }205+ defer os.Remove(tempPath)206+207+ sourcePDF, err := newConfiguredPDF(SheetLayoutHalfLetter, cfg.Title, true)208+ if err != nil {209+ return err210+ }211+ if err := renderLogicalPages(sourcePDF, logicalPages, docs, puzzles, cfg); err != nil {212+ return err213+ }214+ if got, want := sourcePDF.PageNo(), len(logicalPages); got != want {215+ return fmt.Errorf("rendered %d logical pages, want %d", got, want)216+ }217+ if err := sourcePDF.OutputFileAndClose(tempPath); err != nil {218+ return fmt.Errorf("write temporary booklet pdf: %w", err)219+ }220+221+ imposedPDF, err := newConfiguredPDF(SheetLayoutDuplexBooklet, cfg.Title, false)222+ if err != nil {223+ return err224+ }225+ importer := gofpdi.NewImporter()226+ for _, sheet := range duplexBookletSheets(len(logicalPages)) {227+ imposedPDF.AddPage()228+ drawImportedPage(imposedPDF, importer, tempPath, sheet.Front.LeftPage, 0, 0)229+ drawImportedPage(imposedPDF, importer, tempPath, sheet.Front.RightPage, halfLetterWidthMM, 0)230+231+ imposedPDF.AddPage()232+ drawImportedPage(imposedPDF, importer, tempPath, sheet.Back.LeftPage, 0, 0)233+ drawImportedPage(imposedPDF, importer, tempPath, sheet.Back.RightPage, halfLetterWidthMM, 0)234+ }235+ if got, want := imposedPDF.PageNo(), len(logicalPages)/2; got != want {236+ return fmt.Errorf("rendered %d physical pages, want %d", got, want)237+ }238+ if err := imposedPDF.OutputFileAndClose(outputPath); err != nil {239+ return fmt.Errorf("write pdf file: %w", err)240+ }241+ return nil242+}243+244+func drawImportedPage(245+ pdf *fpdf.Fpdf,246+ importer *gofpdi.Importer,247+ sourcePath string,248+ pageNumber int,249+ x, y float64,250+) {251+ templateID := importer.ImportPage(pdf, sourcePath, pageNumber, "/MediaBox")252+ importer.UseImportedTemplate(pdf, templateID, x, y, halfLetterWidthMM, halfLetterHeightMM)253+}254+255+func drawPageFooter(pdf *fpdf.Fpdf, pageNo int) {256+ pdf.SetY(-8)257+ pdf.SetFont(sansFontFamily, "", 8+pdfFontSizeDelta)258+ pdf.SetTextColor(footerTextGray, footerTextGray, footerTextGray)259+ pdf.CellFormat(0, 4, fmt.Sprintf("%d", pageNo), "", 0, "C", false, 0, "")137260 }138261 139262 func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error {
@@ -145,22 +268,18 @@ func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error {
145268 return nil146269 }147270 148- pdf.AddPage()149271 pageW, _ := pdf.GetPageSize()150272 151273 setPuzzleTitleStyle(pdf)152274 pdf.SetXY(0, 10)153- title := fmt.Sprintf("%s %d: %s", puzzle.Category, puzzle.Index, puzzle.Name)275+ title := strings.TrimSpace(puzzle.Category)276+ if name := strings.TrimSpace(puzzle.Name); name != "" {277+ title = fmt.Sprintf("%s: %s", title, name)278+ }154279 pdf.CellFormat(pageW, 7, title, "", 0, "C", false, 0, "")155280 156281 setPuzzleSubtitleStyle(pdf)157- pdf.SetXY(0, 17)158- subtitleParts := []string{fmt.Sprintf("Difficulty Score: %d/10", difficultyScoreOutOfTen(puzzle.DifficultyScore))}159- if !isMixedModes(puzzle.ModeSelection) {160- subtitleParts = append([]string{fmt.Sprintf("Mode: %s", puzzle.ModeSelection)}, subtitleParts...)161- }162- subtitle := strings.Join(subtitleParts, " | ")163- pdf.CellFormat(pageW, 5, subtitle, "", 0, "C", false, 0, "")282+ renderPuzzleDifficultySubtitle(pdf, pageW, 17, puzzle)164283 if err := adapter.RenderPDFBody(pdf, puzzle.PrintPayload); err != nil {165284 return err166285 }
M pdfexport/render_cover.go +1213 -60
@@ -2,11 +2,131 @@ package pdfexport
22 33 import (44 "fmt"5+ "math"6+ "reflect"7+ "sort"58 "strings"69 710 "codeberg.org/go-pdf/fpdf"811 )912 13+type coverOutsideSlice int14+15+const (16+ coverOutsideBack coverOutsideSlice = iota17+ coverOutsideFront18+)19+20+type coverCompositionFamily int21+22+const (23+ coverFamilyFrame coverCompositionFamily = iota24+ coverFamilyStack25+ coverFamilyHinge26+ coverFamilyIsland27+)28+29+type coverDirection int30+31+const (32+ coverDirectionVertical coverDirection = iota33+ coverDirectionHorizontal34+ coverDirectionDiagonal35+ coverDirectionClustered36+)37+38+type coverAspectBucket int39+40+const (41+ coverAspectTall coverAspectBucket = iota42+ coverAspectWide43+ coverAspectCompact44+ coverAspectOffset45+)46+47+type coverTextureKind int48+49+const (50+ coverTextureNone coverTextureKind = iota51+ coverTextureStripes52+ coverTextureHatch53+ coverTextureDots54+ coverTextureChecker55+ coverTextureLattice56+)57+58+type coverFillMode int59+60+const (61+ coverFillSolid coverFillMode = iota62+ coverFillSlits63+ coverFillOpenGrid64+ coverFillPunctured65+ coverFillBanded66+)67+68+type coverCutoutKind int69+70+const (71+ coverCutoutRect coverCutoutKind = iota72+ coverCutoutCircle73+)74+75+type coverShapeKind int76+77+const (78+ coverShapeRect coverShapeKind = iota79+ coverShapeCircle80+ coverShapeCroppedCircle81+ coverShapeStepped82+)83+84+type coverShapeEdge int85+86+const (87+ coverEdgeLeft coverShapeEdge = iota88+ coverEdgeRight89+ coverEdgeTop90+ coverEdgeBottom91+)92+93+type coverCutout struct {94+ Kind coverCutoutKind95+ X float6496+ Y float6497+ W float6498+ H float6499+ R float64100+}101+102+type coverShape struct {103+ Kind coverShapeKind104+ Edge coverShapeEdge105+ Bounds rectMM106+ PivotX float64107+ PivotY float64108+ Rotate float64109+ Locked bool110+ Fill coverFillMode111+ Texture coverTextureKind112+ Step float64113+ Weight float64114+ Cutouts []coverCutout115+}116+117+type coverArtLayout struct {118+ ArtArea rectMM119+ LockupExclusion rectMM120+ Family coverCompositionFamily121+ Direction coverDirection122+ Shapes []coverShape123+}124+125+type coverRand interface {126+ Float64() float64127+ IntN(int) int128+}129+10130 func splitCoverTextLines(pdf *fpdf.Fpdf, text string, maxW float64) []string {11131 trimmed := strings.TrimSpace(text)12132 if trimmed == "" {
@@ -43,91 +163,1124 @@ func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxL
43163 return splitClampedTextLines(pdf, subtitle, maxW, maxLines)44164 }45165 46-func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig, coverColor RGB) {47- ink := RGB{R: 8, G: 8, B: 8}166+func renderOutsideCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, slice coverOutsideSlice) {167+ const frameInset = 7.548168 49- pdf.AddPage()50169 pageW, pageH := pdf.GetPageSize()170+ drawOutsideSpreadFrameSlice(pdf, slice, frameInset, pageW, pageH)171+172+ if slice == coverOutsideFront {173+ layout := buildCoverArtLayout(cfg, pageW, pageH)174+ drawCoverArtLayout(pdf, layout)175+ renderFrontCoverLockup(pdf, cfg, pageW, frameInset)176+ return177+ }51178 52- pdf.SetFillColor(int(coverColor.R), int(coverColor.G), int(coverColor.B))53- pdf.Rect(0, 0, pageW, pageH, "F")179+ renderBackCoverImprint(pdf, cfg, pageW, pageH, frameInset)180+}181+182+func renderCoverBlankPage(_ *fpdf.Fpdf) {}183+184+func drawOutsideSpreadFrameSlice(pdf *fpdf.Fpdf, slice coverOutsideSlice, inset, pageW, pageH float64) {185+ drawCoverSpreadBorder(pdf, slice, inset, 1.1, pageW, pageH)186+ drawCoverSpreadBorder(pdf, slice, inset+1.7, 0.28, pageW, pageH)187+}188+189+func drawCoverSpreadBorder(pdf *fpdf.Fpdf, slice coverOutsideSlice, inset, lineW, pageW, pageH float64) {190+ pdf.SetDrawColor(8, 8, 8)191+ pdf.SetLineWidth(lineW)54192 55- frameInset := 7.556- drawCoverFrame(pdf, frameInset, pageW, pageH, ink)193+ leftX, rightX := coverSliceHorizontalSpan(slice, inset, pageW)194+ pdf.Line(leftX, inset, rightX, inset)195+ pdf.Line(leftX, pageH-inset, rightX, pageH-inset)196+197+ if slice == coverOutsideBack {198+ pdf.Line(inset, inset, inset, pageH-inset)199+ return200+ }201+ pdf.Line(pageW-inset, inset, pageW-inset, pageH-inset)202+}203+204+func coverSliceHorizontalSpan(slice coverOutsideSlice, inset, pageW float64) (left, right float64) {205+ if slice == coverOutsideBack {206+ return inset, pageW207+ }208+ return 0, pageW - inset209+}210+211+func renderFrontCoverLockup(pdf *fpdf.Fpdf, cfg RenderConfig, pageW, frameInset float64) {212+ labelW := min(pageW*0.54, 74.0)213+ labelX := pageW - frameInset - 7.0 - labelW57214 58215 subtitle := strings.TrimSpace(cfg.CoverSubtitle)59216 if subtitle == "" {60217 subtitle = "PuzzleTea Collection"61218 }62219 63- labelW := pageW - 2*(frameInset+6.0)64- fontSize := 20.065- for fontSize >= 13.0 {220+ fontSize := 18.0 + pdfFontSizeDelta221+ for fontSize >= 13.5+pdfFontSizeDelta {66222 pdf.SetFont(coverFontFamily, "", fontSize)67- if len(splitCoverTextLines(pdf, subtitle, labelW)) <= 2 {223+ if len(splitCoverSubtitleLines(pdf, subtitle, labelW, 3)) <= 3 {68224 break69225 }70- fontSize -= 1.0226+ fontSize -= 0.871227 }72228 73- pdf.SetFont(sansFontFamily, "B", 9.8)74- pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B))75- pdf.SetXY(frameInset+6.0, frameInset+2.8)76- pdf.CellFormat(labelW, 5.0, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "")229+ pdf.SetTextColor(8, 8, 8)230+ pdf.SetFont(sansFontFamily, "B", 9.6+pdfFontSizeDelta)231+ pdf.SetXY(labelX, frameInset+5.0)232+ pdf.CellFormat(labelW, 4.5, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "R", false, 0, "")77233 78234 pdf.SetFont(coverFontFamily, "", fontSize)79- titleLines := splitCoverSubtitleLines(pdf, subtitle, labelW, 2)80- lineH := fontSize * 0.4581- y := frameInset + 12.082- for _, line := range titleLines {83- pdf.SetXY(frameInset+6.0, y)84- pdf.CellFormat(labelW, lineH, line, "", 0, "L", false, 0, "")235+ lineH := fontSize * 0.44236+ y := frameInset + 13.2237+ for _, line := range splitCoverSubtitleLines(pdf, subtitle, labelW, 3) {238+ pdf.SetXY(labelX, y)239+ pdf.CellFormat(labelW, lineH, line, "", 0, "R", false, 0, "")85240 y += lineH86241 }242+}87243 88- pdf.SetFont(sansFontFamily, "B", 7.8)89- pdf.SetXY(frameInset+6.0, pageH-frameInset-7.0)244+func renderBackCoverImprint(pdf *fpdf.Fpdf, cfg RenderConfig, pageW, pageH, frameInset float64) {245+ labelX := frameInset + 6.0246+ labelW := min(pageW*0.42, 52.0)247+248+ pdf.SetTextColor(8, 8, 8)249+ pdf.SetFont(sansFontFamily, "B", 8.2+pdfFontSizeDelta)250+ pdf.SetXY(labelX, pageH-frameInset-13.4)90251 pdf.CellFormat(labelW, 4.0, "PuzzleTea", "", 0, "L", false, 0, "")252+253+ pdf.SetFont(sansFontFamily, "", 7.3+pdfFontSizeDelta)254+ pdf.SetXY(labelX, pageH-frameInset-8.5)255+ pdf.CellFormat(labelW, 3.8, fmt.Sprintf("Vol. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "")91256 }92257 93-func drawCoverFrame(pdf *fpdf.Fpdf, inset, pageW, pageH float64, ink RGB) {94- pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))95- pdf.SetLineWidth(1.1)96- pdf.Rect(inset, inset, pageW-2*inset, pageH-2*inset, "D")258+func buildCoverArtLayout(cfg RenderConfig, pageW, pageH float64) coverArtLayout {259+ rng := seededRand(fmt.Sprintf("cover:%s:%d", cfg.ShuffleSeed, cfg.VolumeNumber))260+ layout := coverArtLayout{261+ ArtArea: coverArtArea(pageW, pageH),262+ LockupExclusion: coverLockupExclusion(pageW),263+ Family: coverCompositionFamily(rng.IntN(4)),264+ }265+266+ switch layout.Family {267+ case coverFamilyFrame:268+ layout.Direction = buildFrameFamily(&layout, rng)269+ case coverFamilyStack:270+ layout.Direction = buildStackFamily(&layout, rng)271+ case coverFamilyHinge:272+ layout.Direction = buildHingeFamily(&layout, rng)273+ default:274+ layout.Direction = buildIslandFamily(&layout, rng)275+ }97276 98- pdf.SetLineWidth(0.28)99- inner := inset + 1.7100- pdf.Rect(inner, inner, pageW-2*inner, pageH-2*inner, "D")277+ return layout101278 }102279 103-func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) {104- ink := RGB{R: 8, G: 8, B: 8}280+func coverArtArea(pageW, pageH float64) rectMM {281+ return rectMM{282+ x: 12.5,283+ y: 37.0,284+ w: pageW - 25.0,285+ h: pageH - 56.0,286+ }287+}105288 106- pdf.AddPage()107- pageW, pageH := pdf.GetPageSize()108- pdf.SetFillColor(int(coverColor.R), int(coverColor.G), int(coverColor.B))109- pdf.Rect(0, 0, pageW, pageH, "F")110-111- frameInset := 7.5112- drawCoverFrame(pdf, frameInset, pageW, pageH, ink)113-114- labelW := pageW - 2*(frameInset+6.0)115- pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B))116- pdf.SetFont(sansFontFamily, "B", 8.4)117- pdf.SetXY(frameInset+6.0, pageH-frameInset-23.0)118- pdf.CellFormat(labelW, 4.2, "PuzzleTea", "", 0, "L", false, 0, "")119-120- pdf.SetFont(sansFontFamily, "", 8.0)121- advertLines := splitCoverTextLines(pdf, cfg.AdvertText, labelW)122- advertLineH := 4.2123- advertY := pageH - frameInset - 17.0 - float64(len(advertLines)-1)*advertLineH124- for _, line := range advertLines {125- pdf.SetXY(frameInset+6.0, advertY)126- pdf.CellFormat(labelW, advertLineH, line, "", 0, "L", false, 0, "")127- advertY += advertLineH128- }129-130- pdf.SetFont(sansFontFamily, "B", 8.2)131- pdf.SetXY(frameInset+6.0, pageH-frameInset-10.5)132- pdf.CellFormat(labelW, 4.2, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "")289+func coverLockupExclusion(pageW float64) rectMM {290+ return rectMM{291+ x: pageW - 89.0,292+ y: 8.0,293+ w: 78.0,294+ h: 32.0,295+ }296+}297+298+func buildFrameFamily(layout *coverArtLayout, rng coverRand) coverDirection {299+ outer := rectMM{300+ x: layout.ArtArea.x + layout.ArtArea.w*(0.06+rng.Float64()*0.14),301+ y: layout.ArtArea.y + layout.ArtArea.h*(0.08+rng.Float64()*0.10),302+ w: layout.ArtArea.w * (0.42 + rng.Float64()*0.20),303+ h: layout.ArtArea.h * (0.48 + rng.Float64()*0.20),304+ }305+ thickness := min(outer.w, outer.h) * (0.16 + rng.Float64()*0.07)306+ openSide := coverShapeEdge(rng.IntN(4))307+ direction := coverDirectionVertical308+ if outer.w > outer.h {309+ direction = coverDirectionHorizontal310+ }311+312+ add := func(kind coverShapeKind, edge coverShapeEdge, bounds rectMM) {313+ layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, kind, edge, bounds, coverFamilyFrame, true, rng))314+ }315+316+ if openSide != coverEdgeTop {317+ add(shapeFamilyKind(rng, coverShapeRect, coverShapeStepped), coverEdgeTop, rectMM{318+ x: outer.x,319+ y: outer.y,320+ w: outer.w,321+ h: thickness,322+ })323+ }324+ if openSide != coverEdgeLeft {325+ add(shapeFamilyKind(rng, coverShapeRect, coverShapeStepped), coverEdgeLeft, rectMM{326+ x: outer.x,327+ y: outer.y + thickness*0.55,328+ w: thickness * (0.9 + rng.Float64()*0.35),329+ h: outer.h - thickness*0.55,330+ })331+ }332+ if openSide != coverEdgeRight {333+ add(shapeFamilyKind(rng, coverShapeRect, coverShapeStepped), coverEdgeRight, rectMM{334+ x: outer.x + outer.w - thickness*(0.9+rng.Float64()*0.35),335+ y: outer.y + thickness*0.35,336+ w: thickness * (0.9 + rng.Float64()*0.35),337+ h: outer.h - thickness*0.35,338+ })339+ }340+ if openSide != coverEdgeBottom {341+ add(shapeFamilyKind(rng, coverShapeRect, coverShapeStepped), coverEdgeBottom, rectMM{342+ x: outer.x + outer.w*(0.04+rng.Float64()*0.10),343+ y: outer.y + outer.h - thickness*(0.95+rng.Float64()*0.25),344+ w: outer.w * (0.56 + rng.Float64()*0.20),345+ h: thickness * (0.95 + rng.Float64()*0.25),346+ })347+ }348+349+ if rng.IntN(2) == 0 {350+ layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, coverShapeCroppedCircle, oppositeEdge(openSide), rectMM{351+ x: outer.x + outer.w*(0.52+rng.Float64()*0.16),352+ y: outer.y + outer.h*(0.48+rng.Float64()*0.16),353+ w: outer.w * (0.16 + rng.Float64()*0.12),354+ h: outer.w * (0.16 + rng.Float64()*0.12),355+ }, coverFamilyFrame, false, rng))356+ }357+358+ return direction359+}360+361+func buildStackFamily(layout *coverArtLayout, rng coverRand) coverDirection {362+ vertical := rng.IntN(2) == 0363+ if vertical {364+ x := layout.ArtArea.x + layout.ArtArea.w*(0.06+rng.Float64()*0.18)365+ y := layout.ArtArea.y + layout.ArtArea.h*(0.08+rng.Float64()*0.10)366+ widths := []float64{367+ layout.ArtArea.w * (0.26 + rng.Float64()*0.10),368+ layout.ArtArea.w * (0.48 + rng.Float64()*0.16),369+ layout.ArtArea.w * (0.34 + rng.Float64()*0.16),370+ }371+ heights := []float64{372+ layout.ArtArea.h * (0.18 + rng.Float64()*0.08),373+ layout.ArtArea.h * (0.22 + rng.Float64()*0.10),374+ layout.ArtArea.h * (0.16 + rng.Float64()*0.08),375+ }376+ gap := 4.0 + rng.Float64()*4.0377+ curY := y378+ for i := range widths {379+ bounds := rectMM{x: x, y: curY, w: widths[i], h: heights[i]}380+ kind := coverShapeRect381+ if i == 0 && rng.IntN(2) == 0 {382+ kind = coverShapeStepped383+ }384+ layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, kind, coverEdgeLeft, bounds, coverFamilyStack, false, rng))385+ curY += heights[i] + gap386+ }387+ layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, coverShapeCircle, coverEdgeRight, rectMM{388+ x: x + layout.ArtArea.w*(0.44+rng.Float64()*0.12),389+ y: y + layout.ArtArea.h*(0.18+rng.Float64()*0.18),390+ w: layout.ArtArea.w * (0.16 + rng.Float64()*0.10),391+ h: layout.ArtArea.w * (0.16 + rng.Float64()*0.10),392+ }, coverFamilyStack, false, rng))393+ return coverDirectionVertical394+ }395+396+ x := layout.ArtArea.x + layout.ArtArea.w*(0.06+rng.Float64()*0.10)397+ y := layout.ArtArea.y + layout.ArtArea.h*(0.14+rng.Float64()*0.16)398+ widths := []float64{399+ layout.ArtArea.w * (0.18 + rng.Float64()*0.10),400+ layout.ArtArea.w * (0.30 + rng.Float64()*0.12),401+ layout.ArtArea.w * (0.20 + rng.Float64()*0.08),402+ }403+ heights := []float64{404+ layout.ArtArea.h * (0.28 + rng.Float64()*0.12),405+ layout.ArtArea.h * (0.18 + rng.Float64()*0.08),406+ layout.ArtArea.h * (0.32 + rng.Float64()*0.10),407+ }408+ gap := 4.0 + rng.Float64()*4.0409+ curX := x410+ for i := range widths {411+ bounds := rectMM{x: curX, y: y, w: widths[i], h: heights[i]}412+ kind := coverShapeRect413+ if i == 1 && rng.IntN(2) == 0 {414+ kind = coverShapeStepped415+ }416+ layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, kind, coverEdgeBottom, bounds, coverFamilyStack, false, rng))417+ curX += widths[i] + gap418+ }419+ layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, coverShapeCroppedCircle, coverEdgeBottom, rectMM{420+ x: x + layout.ArtArea.w*(0.30+rng.Float64()*0.18),421+ y: y + layout.ArtArea.h*(0.42+rng.Float64()*0.10),422+ w: layout.ArtArea.w * (0.18 + rng.Float64()*0.09),423+ h: layout.ArtArea.w * (0.18 + rng.Float64()*0.09),424+ }, coverFamilyStack, false, rng))425+ return coverDirectionHorizontal426+}427+428+func buildHingeFamily(layout *coverArtLayout, rng coverRand) coverDirection {429+ baseX := layout.ArtArea.x + layout.ArtArea.w*(0.06+rng.Float64()*0.08)430+ baseY := layout.ArtArea.y + layout.ArtArea.h*(0.42+rng.Float64()*0.12)431+ upperX := layout.ArtArea.x + layout.ArtArea.w*(0.48+rng.Float64()*0.10)432+ upperY := layout.ArtArea.y + layout.ArtArea.h*(0.08+rng.Float64()*0.10)433+434+ layout.Shapes = append(layout.Shapes,435+ decoratedCoverShape(layout, shapeFamilyKind(rng, coverShapeRect, coverShapeStepped), coverEdgeBottom, rectMM{436+ x: baseX,437+ y: baseY,438+ w: layout.ArtArea.w * (0.30 + rng.Float64()*0.10),439+ h: layout.ArtArea.h * (0.26 + rng.Float64()*0.10),440+ }, coverFamilyHinge, false, rng),441+ )442+ layout.Shapes = append(layout.Shapes,443+ decoratedCoverShape(layout, shapeFamilyKind(rng, coverShapeRect, coverShapeCroppedCircle), coverEdgeTop, rectMM{444+ x: upperX,445+ y: upperY,446+ w: layout.ArtArea.w * (0.24 + rng.Float64()*0.12),447+ h: layout.ArtArea.h * (0.26 + rng.Float64()*0.10),448+ }, coverFamilyHinge, false, rng),449+ )450+ layout.Shapes = append(layout.Shapes,451+ decoratedCoverShape(layout, coverShapeRect, coverEdgeRight, rectMM{452+ x: layout.ArtArea.x + layout.ArtArea.w*(0.38+rng.Float64()*0.08),453+ y: layout.ArtArea.y + layout.ArtArea.h*(0.28+rng.Float64()*0.10),454+ w: layout.ArtArea.w * (0.09 + rng.Float64()*0.04),455+ h: layout.ArtArea.h * (0.34 + rng.Float64()*0.12),456+ }, coverFamilyHinge, true, rng),457+ )458+ layout.Shapes = append(layout.Shapes,459+ decoratedCoverShape(layout, coverShapeCircle, coverEdgeRight, rectMM{460+ x: layout.ArtArea.x + layout.ArtArea.w*(0.34+rng.Float64()*0.10),461+ y: layout.ArtArea.y + layout.ArtArea.h*(0.22+rng.Float64()*0.12),462+ w: layout.ArtArea.w * (0.12 + rng.Float64()*0.08),463+ h: layout.ArtArea.w * (0.12 + rng.Float64()*0.08),464+ }, coverFamilyHinge, false, rng),465+ )466+ return coverDirectionDiagonal467+}468+469+func buildIslandFamily(layout *coverArtLayout, rng coverRand) coverDirection {470+ clustered := rng.IntN(2) == 0471+ layout.Shapes = append(layout.Shapes,472+ decoratedCoverShape(layout, shapeFamilyKind(rng, coverShapeStepped, coverShapeCircle), coverEdgeLeft, rectMM{473+ x: layout.ArtArea.x + layout.ArtArea.w*(0.06+rng.Float64()*0.14),474+ y: layout.ArtArea.y + layout.ArtArea.h*(0.34+rng.Float64()*0.16),475+ w: layout.ArtArea.w * (0.30 + rng.Float64()*0.14),476+ h: layout.ArtArea.h * (0.24 + rng.Float64()*0.14),477+ }, coverFamilyIsland, false, rng),478+ )479+480+ if clustered {481+ layout.Shapes = append(layout.Shapes,482+ decoratedCoverShape(layout, coverShapeCircle, coverEdgeTop, rectMM{483+ x: layout.ArtArea.x + layout.ArtArea.w*(0.40+rng.Float64()*0.14),484+ y: layout.ArtArea.y + layout.ArtArea.h*(0.18+rng.Float64()*0.14),485+ w: layout.ArtArea.w * (0.14 + rng.Float64()*0.10),486+ h: layout.ArtArea.w * (0.14 + rng.Float64()*0.10),487+ }, coverFamilyIsland, false, rng),488+ decoratedCoverShape(layout, coverShapeRect, coverEdgeBottom, rectMM{489+ x: layout.ArtArea.x + layout.ArtArea.w*(0.46+rng.Float64()*0.14),490+ y: layout.ArtArea.y + layout.ArtArea.h*(0.48+rng.Float64()*0.12),491+ w: layout.ArtArea.w * (0.18 + rng.Float64()*0.10),492+ h: layout.ArtArea.h * (0.18 + rng.Float64()*0.10),493+ }, coverFamilyIsland, false, rng),494+ decoratedCoverShape(layout, coverShapeCroppedCircle, coverEdgeRight, rectMM{495+ x: layout.ArtArea.x + layout.ArtArea.w*(0.28+rng.Float64()*0.18),496+ y: layout.ArtArea.y + layout.ArtArea.h*(0.06+rng.Float64()*0.10),497+ w: layout.ArtArea.w * (0.12 + rng.Float64()*0.08),498+ h: layout.ArtArea.w * (0.12 + rng.Float64()*0.08),499+ }, coverFamilyIsland, false, rng),500+ )501+ return coverDirectionClustered502+ }503+504+ layout.Shapes = append(layout.Shapes,505+ decoratedCoverShape(layout, coverShapeCircle, coverEdgeTop, rectMM{506+ x: layout.ArtArea.x + layout.ArtArea.w*(0.34+rng.Float64()*0.10),507+ y: layout.ArtArea.y + layout.ArtArea.h*(0.18+rng.Float64()*0.08),508+ w: layout.ArtArea.w * (0.16 + rng.Float64()*0.10),509+ h: layout.ArtArea.w * (0.16 + rng.Float64()*0.10),510+ }, coverFamilyIsland, false, rng),511+ decoratedCoverShape(layout, coverShapeRect, coverEdgeBottom, rectMM{512+ x: layout.ArtArea.x + layout.ArtArea.w*(0.56+rng.Float64()*0.10),513+ y: layout.ArtArea.y + layout.ArtArea.h*(0.48+rng.Float64()*0.08),514+ w: layout.ArtArea.w * (0.18 + rng.Float64()*0.10),515+ h: layout.ArtArea.h * (0.20 + rng.Float64()*0.10),516+ }, coverFamilyIsland, false, rng),517+ )518+ return coverDirectionDiagonal519+}520+521+func shapeFamilyKind(rng coverRand, a, b coverShapeKind) coverShapeKind {522+ if rng.IntN(2) == 0 {523+ return a524+ }525+ return b526+}527+528+func oppositeEdge(edge coverShapeEdge) coverShapeEdge {529+ switch edge {530+ case coverEdgeLeft:531+ return coverEdgeRight532+ case coverEdgeRight:533+ return coverEdgeLeft534+ case coverEdgeTop:535+ return coverEdgeBottom536+ default:537+ return coverEdgeTop538+ }539+}540+541+func decoratedCoverShape(542+ layout *coverArtLayout,543+ kind coverShapeKind,544+ edge coverShapeEdge,545+ bounds rectMM,546+ family coverCompositionFamily,547+ locked bool,548+ rng coverRand,549+) coverShape {550+ shape := coverShape{551+ Kind: kind,552+ Edge: edge,553+ Bounds: bounds,554+ Locked: locked,555+ Fill: pickCoverFillMode(family, rng),556+ Texture: pickCoverTexture(family, rng),557+ Step: 4.0 + rng.Float64()*3.8,558+ Weight: 0.7 + rng.Float64()*0.8,559+ }560+ shape.Rotate = pickCoverRotationDeg(locked, rng)561+ shape = sanitizeCoverShape(shape, layout.ArtArea, layout.LockupExclusion)562+ shape.Cutouts = buildFamilyCutouts(family, shape, rng)563+ return shape564+}565+566+func pickCoverRotationDeg(locked bool, rng coverRand) float64 {567+ minDeg := 8.0568+ maxDeg := 20.0569+ if locked {570+ minDeg = 4.0571+ maxDeg = 10.0572+ }573+ angle := minDeg + rng.Float64()*(maxDeg-minDeg)574+ if rng.IntN(2) == 0 {575+ return -angle576+ }577+ return angle578+}579+580+func sanitizeCoverShape(shape coverShape, artArea, exclusion rectMM) coverShape {581+ if shape.Bounds.w < 8 {582+ shape.Bounds.w = 8583+ }584+ if shape.Bounds.h < 8 {585+ shape.Bounds.h = 8586+ }587+ if shape.Bounds.w > artArea.w {588+ shape.Bounds.w = artArea.w589+ }590+ if shape.Bounds.h > artArea.h {591+ shape.Bounds.h = artArea.h592+ }593+594+ if shape.Bounds.x < artArea.x {595+ shape.Bounds.x = artArea.x596+ }597+ if shape.Bounds.y < artArea.y {598+ shape.Bounds.y = artArea.y599+ }600+ if shape.Bounds.x+shape.Bounds.w > artArea.x+artArea.w {601+ shape.Bounds.x = artArea.x + artArea.w - shape.Bounds.w602+ }603+ if shape.Bounds.y+shape.Bounds.h > artArea.y+artArea.h {604+ shape.Bounds.y = artArea.y + artArea.h - shape.Bounds.h605+ }606+607+ shape = withCoverShapePivot(shape)608+609+ for range 4 {610+ env := coverShapeEnvelope(shape)611+ dx := 0.0612+ dy := 0.0613+614+ if env.x < artArea.x {615+ dx += artArea.x - env.x616+ }617+ if env.x+env.w > artArea.x+artArea.w {618+ dx -= env.x + env.w - (artArea.x + artArea.w)619+ }620+ if env.y < artArea.y {621+ dy += artArea.y - env.y622+ }623+ if env.y+env.h > artArea.y+artArea.h {624+ dy -= env.y + env.h - (artArea.y + artArea.h)625+ }626+627+ if dx != 0 || dy != 0 {628+ shape.Bounds.x += dx629+ shape.Bounds.y += dy630+ shape = withCoverShapePivot(shape)631+ continue632+ }633+634+ if rectsIntersect(env, exclusion) {635+ clearBelow := exclusion.y + exclusion.h + 5.0636+ if shape.Bounds.y < clearBelow {637+ shape.Bounds.y += clearBelow - shape.Bounds.y638+ }639+ shape = withCoverShapePivot(shape)640+ env = coverShapeEnvelope(shape)641+ if rectsIntersect(env, exclusion) {642+ targetRight := exclusion.x - 5.0643+ shape.Bounds.x -= env.x + env.w - targetRight644+ shape = withCoverShapePivot(shape)645+ }646+ continue647+ }648+649+ break650+ }651+652+ return shape653+}654+655+func withCoverShapePivot(shape coverShape) coverShape {656+ shape.PivotX = shape.Bounds.x + shape.Bounds.w*0.5657+ shape.PivotY = shape.Bounds.y + shape.Bounds.h*0.5658+ return shape659+}660+661+func pickCoverFillMode(family coverCompositionFamily, rng coverRand) coverFillMode {662+ switch family {663+ case coverFamilyFrame:664+ return []coverFillMode{coverFillSolid, coverFillSlits, coverFillOpenGrid, coverFillBanded}[rng.IntN(4)]665+ case coverFamilyStack:666+ return []coverFillMode{coverFillSolid, coverFillBanded, coverFillSlits, coverFillOpenGrid}[rng.IntN(4)]667+ case coverFamilyHinge:668+ return []coverFillMode{coverFillSolid, coverFillBanded, coverFillPunctured, coverFillSlits}[rng.IntN(4)]669+ default:670+ return []coverFillMode{coverFillPunctured, coverFillOpenGrid, coverFillSolid, coverFillBanded}[rng.IntN(4)]671+ }672+}673+674+func pickCoverTexture(family coverCompositionFamily, rng coverRand) coverTextureKind {675+ switch family {676+ case coverFamilyFrame:677+ return []coverTextureKind{coverTextureNone, coverTextureStripes, coverTextureChecker, coverTextureDots, coverTextureLattice}[rng.IntN(5)]678+ case coverFamilyStack:679+ return []coverTextureKind{coverTextureNone, coverTextureStripes, coverTextureHatch, coverTextureChecker, coverTextureLattice}[rng.IntN(5)]680+ case coverFamilyHinge:681+ return []coverTextureKind{coverTextureNone, coverTextureHatch, coverTextureStripes, coverTextureDots, coverTextureLattice}[rng.IntN(5)]682+ default:683+ return []coverTextureKind{coverTextureNone, coverTextureDots, coverTextureChecker, coverTextureHatch, coverTextureStripes}[rng.IntN(5)]684+ }685+}686+687+func buildFamilyCutouts(family coverCompositionFamily, shape coverShape, rng coverRand) []coverCutout {688+ switch family {689+ case coverFamilyFrame:690+ return buildFrameCutouts(shape, rng)691+ case coverFamilyStack:692+ return buildStackCutouts(shape, rng)693+ case coverFamilyHinge:694+ return buildHingeCutouts(shape, rng)695+ default:696+ return buildIslandCutouts(shape, rng)697+ }698+}699+700+func buildFrameCutouts(shape coverShape, rng coverRand) []coverCutout {701+ cutouts := []coverCutout{702+ {703+ Kind: coverCutoutRect,704+ X: shape.Bounds.x + shape.Bounds.w*(0.14+rng.Float64()*0.32),705+ Y: shape.Bounds.y + shape.Bounds.h*(0.18+rng.Float64()*0.40),706+ W: shape.Bounds.w * (0.12 + rng.Float64()*0.18),707+ H: shape.Bounds.h * (0.12 + rng.Float64()*0.22),708+ },709+ }710+ if rng.IntN(2) == 0 {711+ cutouts = append(cutouts, coverCutout{712+ Kind: coverCutoutCircle,713+ X: shape.Bounds.x + shape.Bounds.w*(0.26+rng.Float64()*0.48),714+ Y: shape.Bounds.y + shape.Bounds.h*(0.22+rng.Float64()*0.48),715+ R: min(shape.Bounds.w, shape.Bounds.h) * (0.08 + rng.Float64()*0.10),716+ })717+ }718+ return cutouts719+}720+721+func buildStackCutouts(shape coverShape, rng coverRand) []coverCutout {722+ if rng.IntN(2) == 0 {723+ return []coverCutout{724+ {725+ Kind: coverCutoutRect,726+ X: shape.Bounds.x + shape.Bounds.w*(0.10+rng.Float64()*0.12),727+ Y: shape.Bounds.y + shape.Bounds.h*(0.36+rng.Float64()*0.18),728+ W: shape.Bounds.w * (0.42 + rng.Float64()*0.22),729+ H: shape.Bounds.h * (0.10 + rng.Float64()*0.10),730+ },731+ }732+ }733+ return []coverCutout{734+ {735+ Kind: coverCutoutRect,736+ X: shape.Bounds.x + shape.Bounds.w*(0.36+rng.Float64()*0.18),737+ Y: shape.Bounds.y + shape.Bounds.h*(0.08+rng.Float64()*0.12),738+ W: shape.Bounds.w * (0.12 + rng.Float64()*0.12),739+ H: shape.Bounds.h * (0.34 + rng.Float64()*0.20),740+ },741+ }742+}743+744+func buildHingeCutouts(shape coverShape, rng coverRand) []coverCutout {745+ cutouts := []coverCutout{746+ {747+ Kind: coverCutoutCircle,748+ X: shape.Bounds.x + shape.Bounds.w*(0.34+rng.Float64()*0.24),749+ Y: shape.Bounds.y + shape.Bounds.h*(0.30+rng.Float64()*0.26),750+ R: min(shape.Bounds.w, shape.Bounds.h) * (0.08 + rng.Float64()*0.08),751+ },752+ }753+ if rng.IntN(2) == 0 {754+ cutouts = append(cutouts, coverCutout{755+ Kind: coverCutoutRect,756+ X: shape.Bounds.x + shape.Bounds.w*(0.58+rng.Float64()*0.10),757+ Y: shape.Bounds.y + shape.Bounds.h*(0.56+rng.Float64()*0.10),758+ W: shape.Bounds.w * (0.18 + rng.Float64()*0.12),759+ H: shape.Bounds.h * (0.14 + rng.Float64()*0.10),760+ })761+ }762+ return cutouts763+}764+765+func buildIslandCutouts(shape coverShape, rng coverRand) []coverCutout {766+ return []coverCutout{767+ {768+ Kind: coverCutoutCircle,769+ X: shape.Bounds.x + shape.Bounds.w*(0.18+rng.Float64()*0.54),770+ Y: shape.Bounds.y + shape.Bounds.h*(0.18+rng.Float64()*0.54),771+ R: min(shape.Bounds.w, shape.Bounds.h) * (0.10 + rng.Float64()*0.10),772+ },773+ }774+}775+776+func drawCoverArtLayout(pdf *fpdf.Fpdf, layout coverArtLayout) {777+ for _, shape := range layout.Shapes {778+ drawCoverShape(pdf, shape)779+ }780+}781+782+func drawCoverShape(pdf *fpdf.Fpdf, shape coverShape) {783+ pdf.TransformBegin()784+ pdf.TransformRotate(shape.Rotate, shape.PivotX, shape.PivotY)785+ switch shape.Kind {786+ case coverShapeCircle:787+ drawCircleCoverShape(pdf, shape)788+ case coverShapeCroppedCircle:789+ drawCroppedCircleCoverShape(pdf, shape)790+ case coverShapeStepped:791+ drawSteppedCoverShape(pdf, shape)792+ default:793+ drawRectCoverShape(pdf, shape)794+ }795+ pdf.TransformEnd()796+}797+798+func drawRectCoverShape(pdf *fpdf.Fpdf, shape coverShape) {799+ pdf.SetFillColor(8, 8, 8)800+ pdf.Rect(shape.Bounds.x, shape.Bounds.y, shape.Bounds.w, shape.Bounds.h, "F")801+ pdf.ClipRect(shape.Bounds.x, shape.Bounds.y, shape.Bounds.w, shape.Bounds.h, false)802+ drawCoverFill(pdf, shape)803+ drawCoverTexture(pdf, shape)804+ pdf.ClipEnd()805+ drawCoverCutouts(pdf, shape.Cutouts)806+}807+808+func drawCircleCoverShape(pdf *fpdf.Fpdf, shape coverShape) {809+ cx, cy, r := coverCircleMetrics(shape.Bounds)810+ pdf.SetFillColor(8, 8, 8)811+ pdf.Circle(cx, cy, r, "F")812+ pdf.ClipCircle(cx, cy, r, false)813+ drawCoverFill(pdf, shape)814+ drawCoverTexture(pdf, shape)815+ pdf.ClipEnd()816+ drawCoverCutouts(pdf, shape.Cutouts)817+}818+819+func drawCroppedCircleCoverShape(pdf *fpdf.Fpdf, shape coverShape) {820+ cx, cy, r := croppedCircleMetrics(shape)821+ pdf.SetFillColor(8, 8, 8)822+ pdf.ClipRect(shape.Bounds.x, shape.Bounds.y, shape.Bounds.w, shape.Bounds.h, false)823+ pdf.Circle(cx, cy, r, "F")824+ pdf.ClipCircle(cx, cy, r, false)825+ drawCoverFill(pdf, shape)826+ drawCoverTexture(pdf, shape)827+ pdf.ClipEnd()828+ pdf.ClipEnd()829+ drawCoverCutouts(pdf, shape.Cutouts)830+}831+832+func drawSteppedCoverShape(pdf *fpdf.Fpdf, shape coverShape) {833+ points := steppedShapePoints(shape)834+ pdf.SetFillColor(8, 8, 8)835+ pdf.Polygon(points, "F")836+ pdf.ClipPolygon(points, false)837+ drawCoverFill(pdf, shape)838+ drawCoverTexture(pdf, shape)839+ pdf.ClipEnd()840+ drawCoverCutouts(pdf, shape.Cutouts)841+}842+843+func coverCircleMetrics(bounds rectMM) (cx, cy, r float64) {844+ r = min(bounds.w, bounds.h) * 0.5845+ cx = bounds.x + bounds.w*0.5846+ cy = bounds.y + bounds.h*0.5847+ return cx, cy, r848+}849+850+func croppedCircleMetrics(shape coverShape) (cx, cy, r float64) {851+ cx, cy, _ = coverCircleMetrics(shape.Bounds)852+ r = max(shape.Bounds.w, shape.Bounds.h) * 0.56853+ switch shape.Edge {854+ case coverEdgeLeft:855+ cx -= shape.Bounds.w * 0.18856+ case coverEdgeRight:857+ cx += shape.Bounds.w * 0.18858+ case coverEdgeTop:859+ cy -= shape.Bounds.h * 0.18860+ default:861+ cy += shape.Bounds.h * 0.18862+ }863+ return cx, cy, r864+}865+866+func steppedShapePoints(shape coverShape) []fpdf.PointType {867+ b := shape.Bounds868+ notch := min(b.w, b.h) * 0.18869+ step1 := min(b.w, b.h) * 0.10870+ step2 := min(b.w, b.h) * 0.20871+872+ switch shape.Edge {873+ case coverEdgeLeft:874+ return []fpdf.PointType{875+ {X: b.x + notch, Y: b.y},876+ {X: b.x + b.w, Y: b.y},877+ {X: b.x + b.w, Y: b.y + b.h},878+ {X: b.x + notch, Y: b.y + b.h},879+ {X: b.x + notch, Y: b.y + b.h*0.66},880+ {X: b.x, Y: b.y + b.h*0.66 - step2},881+ {X: b.x, Y: b.y + b.h*0.34 + step2},882+ {X: b.x + notch, Y: b.y + b.h*0.34},883+ }884+ case coverEdgeRight:885+ return []fpdf.PointType{886+ {X: b.x, Y: b.y},887+ {X: b.x + b.w - notch, Y: b.y},888+ {X: b.x + b.w - notch, Y: b.y + b.h*0.34},889+ {X: b.x + b.w, Y: b.y + b.h*0.34 + step1},890+ {X: b.x + b.w, Y: b.y + b.h*0.66 - step1},891+ {X: b.x + b.w - notch, Y: b.y + b.h*0.66},892+ {X: b.x + b.w - notch, Y: b.y + b.h},893+ {X: b.x, Y: b.y + b.h},894+ }895+ case coverEdgeTop:896+ return []fpdf.PointType{897+ {X: b.x, Y: b.y + notch},898+ {X: b.x + b.w*0.34, Y: b.y + notch},899+ {X: b.x + b.w*0.34 + step1, Y: b.y},900+ {X: b.x + b.w*0.66 - step1, Y: b.y},901+ {X: b.x + b.w*0.66, Y: b.y + notch},902+ {X: b.x + b.w, Y: b.y + notch},903+ {X: b.x + b.w, Y: b.y + b.h},904+ {X: b.x, Y: b.y + b.h},905+ }906+ default:907+ return []fpdf.PointType{908+ {X: b.x, Y: b.y},909+ {X: b.x + b.w, Y: b.y},910+ {X: b.x + b.w, Y: b.y + b.h - notch},911+ {X: b.x + b.w*0.66, Y: b.y + b.h - notch},912+ {X: b.x + b.w*0.66 - step1, Y: b.y + b.h},913+ {X: b.x + b.w*0.34 + step1, Y: b.y + b.h},914+ {X: b.x + b.w*0.34, Y: b.y + b.h - notch},915+ {X: b.x, Y: b.y + b.h - notch},916+ }917+ }918+}919+920+func drawCoverFill(pdf *fpdf.Fpdf, shape coverShape) {921+ pdf.SetFillColor(255, 255, 255)922+923+ switch shape.Fill {924+ case coverFillSlits:925+ drawCoverSlits(pdf, shape.Bounds)926+ case coverFillOpenGrid:927+ drawCoverOpenGrid(pdf, shape.Bounds)928+ case coverFillPunctured:929+ drawCoverPunctures(pdf, shape.Bounds)930+ case coverFillBanded:931+ drawCoverBands(pdf, shape.Bounds)932+ default:933+ return934+ }935+}936+937+func drawCoverSlits(pdf *fpdf.Fpdf, bounds rectMM) {938+ if bounds.w >= bounds.h {939+ slitW := max(1.6, bounds.w*0.05)940+ for x := bounds.x + bounds.w*0.12; x < bounds.x+bounds.w*0.88; x += slitW * 1.9 {941+ pdf.Rect(x, bounds.y-1, slitW, bounds.h+2, "F")942+ }943+ return944+ }945+946+ slitH := max(1.6, bounds.h*0.05)947+ for y := bounds.y + bounds.h*0.12; y < bounds.y+bounds.h*0.88; y += slitH * 1.9 {948+ pdf.Rect(bounds.x-1, y, bounds.w+2, slitH, "F")949+ }950+}951+952+func drawCoverOpenGrid(pdf *fpdf.Fpdf, bounds rectMM) {953+ cell := max(3.2, min(bounds.w, bounds.h)*0.12)954+ for row, y := 0, bounds.y; y < bounds.y+bounds.h; row, y = row+1, y+cell {955+ for col, x := 0, bounds.x; x < bounds.x+bounds.w; col, x = col+1, x+cell {956+ if (row+col)%2 == 0 {957+ pdf.Rect(x, y, cell*0.78, cell*0.78, "F")958+ }959+ }960+ }961+}962+963+func drawCoverPunctures(pdf *fpdf.Fpdf, bounds rectMM) {964+ step := max(4.4, min(bounds.w, bounds.h)*0.16)965+ radius := max(1.0, step*0.18)966+ for row, y := 0, bounds.y+radius; y < bounds.y+bounds.h-radius; row, y = row+1, y+step {967+ shift := 0.0968+ if row%2 == 1 {969+ shift = step * 0.45970+ }971+ for x := bounds.x + radius + shift; x < bounds.x+bounds.w-radius; x += step {972+ pdf.Circle(x, y, radius, "F")973+ }974+ }975+}976+977+func drawCoverBands(pdf *fpdf.Fpdf, bounds rectMM) {978+ if bounds.w >= bounds.h {979+ bandH := max(3.4, bounds.h*0.16)980+ for i := 0; i < 2; i++ {981+ y := bounds.y + bounds.h*(0.18+float64(i)*0.34)982+ pdf.Rect(bounds.x-1, y, bounds.w+2, bandH, "F")983+ }984+ return985+ }986+987+ bandW := max(3.4, bounds.w*0.16)988+ for i := 0; i < 2; i++ {989+ x := bounds.x + bounds.w*(0.18+float64(i)*0.34)990+ pdf.Rect(x, bounds.y-1, bandW, bounds.h+2, "F")991+ }992+}993+994+func drawCoverTexture(pdf *fpdf.Fpdf, shape coverShape) {995+ setCoverRemovalStroke(pdf, shape.Weight)996+997+ switch shape.Texture {998+ case coverTextureStripes:999+ barW := max(1.2, shape.Step*0.34)1000+ for x := shape.Bounds.x - shape.Step; x <= shape.Bounds.x+shape.Bounds.w+shape.Step; x += shape.Step {1001+ pdf.SetFillColor(255, 255, 255)1002+ pdf.Rect(x, shape.Bounds.y-1, barW, shape.Bounds.h+2, "F")1003+ }1004+ case coverTextureHatch:1005+ for offset := -shape.Bounds.h; offset <= shape.Bounds.w+shape.Bounds.h; offset += shape.Step {1006+ pdf.Line(shape.Bounds.x+offset, shape.Bounds.y+shape.Bounds.h, shape.Bounds.x+offset+shape.Bounds.h, shape.Bounds.y)1007+ }1008+ case coverTextureDots:1009+ radius := max(0.9, shape.Step*0.16)1010+ rowStep := shape.Step * 0.941011+ for row, y := 0, shape.Bounds.y+radius; y <= shape.Bounds.y+shape.Bounds.h-radius; row, y = row+1, y+rowStep {1012+ shift := 0.01013+ if row%2 == 1 {1014+ shift = shape.Step * 0.421015+ }1016+ for x := shape.Bounds.x + radius + shift; x <= shape.Bounds.x+shape.Bounds.w-radius; x += shape.Step {1017+ pdf.SetFillColor(255, 255, 255)1018+ pdf.Circle(x, y, radius, "F")1019+ }1020+ }1021+ case coverTextureChecker:1022+ cell := max(2.6, shape.Step*0.68)1023+ for row, y := 0, shape.Bounds.y; y <= shape.Bounds.y+shape.Bounds.h; row, y = row+1, y+cell {1024+ for col, x := 0, shape.Bounds.x; x <= shape.Bounds.x+shape.Bounds.w; col, x = col+1, x+cell {1025+ if (row+col)%2 == 0 {1026+ pdf.SetFillColor(255, 255, 255)1027+ pdf.Rect(x, y, cell, cell, "F")1028+ }1029+ }1030+ }1031+ case coverTextureLattice:1032+ barW := max(0.95, shape.Step*0.16)1033+ for x := shape.Bounds.x; x <= shape.Bounds.x+shape.Bounds.w; x += shape.Step {1034+ pdf.SetFillColor(255, 255, 255)1035+ pdf.Rect(x, shape.Bounds.y-1, barW, shape.Bounds.h+2, "F")1036+ }1037+ for y := shape.Bounds.y; y <= shape.Bounds.y+shape.Bounds.h; y += shape.Step * 0.84 {1038+ pdf.SetFillColor(255, 255, 255)1039+ pdf.Rect(shape.Bounds.x-1, y, shape.Bounds.w+2, barW, "F")1040+ }1041+ default:1042+ return1043+ }1044+}1045+1046+func setCoverRemovalStroke(pdf *fpdf.Fpdf, weight float64) {1047+ pdf.SetDrawColor(255, 255, 255)1048+ pdf.SetLineWidth(weight)1049+}1050+1051+func drawCoverCutouts(pdf *fpdf.Fpdf, cutouts []coverCutout) {1052+ pdf.SetFillColor(255, 255, 255)1053+ for _, cutout := range cutouts {1054+ switch cutout.Kind {1055+ case coverCutoutCircle:1056+ pdf.Circle(cutout.X, cutout.Y, cutout.R, "F")1057+ default:1058+ pdf.Rect(cutout.X, cutout.Y, cutout.W, cutout.H, "F")1059+ }1060+ }1061+}1062+1063+func rectsIntersect(a, b rectMM) bool {1064+ return a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y1065+}1066+1067+func rotatePoint(x, y, pivotX, pivotY, angleDeg float64) (float64, float64) {1068+ rad := angleDeg * math.Pi / 180.01069+ sinA, cosA := math.Sincos(rad)1070+ dx := x - pivotX1071+ dy := y - pivotY1072+ return pivotX + dx*cosA - dy*sinA, pivotY + dx*sinA + dy*cosA1073+}1074+1075+func rotatedRectAABB(bounds rectMM, pivotX, pivotY, angleDeg float64) rectMM {1076+ if angleDeg == 0 {1077+ return bounds1078+ }1079+ points := [4][2]float64{1080+ {bounds.x, bounds.y},1081+ {bounds.x + bounds.w, bounds.y},1082+ {bounds.x + bounds.w, bounds.y + bounds.h},1083+ {bounds.x, bounds.y + bounds.h},1084+ }1085+ minX := math.MaxFloat641086+ minY := math.MaxFloat641087+ maxX := -math.MaxFloat641088+ maxY := -math.MaxFloat641089+ for _, pt := range points {1090+ x, y := rotatePoint(pt[0], pt[1], pivotX, pivotY, angleDeg)1091+ minX = min(minX, x)1092+ minY = min(minY, y)1093+ maxX = max(maxX, x)1094+ maxY = max(maxY, y)1095+ }1096+ return rectMM{x: minX, y: minY, w: maxX - minX, h: maxY - minY}1097+}1098+1099+func coverShapeEnvelope(shape coverShape) rectMM {1100+ return rotatedRectAABB(shape.Bounds, shape.PivotX, shape.PivotY, shape.Rotate)1101+}1102+1103+func coverArtLayoutEqual(a, b coverArtLayout) bool {1104+ return reflect.DeepEqual(a, b)1105+}1106+1107+func coverArtStaysWithinArea(layout coverArtLayout) bool {1108+ for _, shape := range layout.Shapes {1109+ env := coverShapeEnvelope(shape)1110+ if env.x < layout.ArtArea.x || env.y < layout.ArtArea.y {1111+ return false1112+ }1113+ if env.x+env.w > layout.ArtArea.x+layout.ArtArea.w {1114+ return false1115+ }1116+ if env.y+env.h > layout.ArtArea.y+layout.ArtArea.h {1117+ return false1118+ }1119+ }1120+ return true1121+}1122+1123+func coverArtRespectsLockup(layout coverArtLayout) bool {1124+ for _, shape := range layout.Shapes {1125+ if rectsIntersect(coverShapeEnvelope(shape), layout.LockupExclusion) {1126+ return false1127+ }1128+ }1129+ return true1130+}1131+1132+func coverArtDiffers(a, b coverArtLayout) bool {1133+ return !reflect.DeepEqual(a, b)1134+}1135+1136+func coverFamilyName(family coverCompositionFamily) string {1137+ switch family {1138+ case coverFamilyFrame:1139+ return "frame"1140+ case coverFamilyStack:1141+ return "stack"1142+ case coverFamilyHinge:1143+ return "hinge"1144+ default:1145+ return "island"1146+ }1147+}1148+1149+func coverDirectionName(direction coverDirection) string {1150+ switch direction {1151+ case coverDirectionVertical:1152+ return "vertical"1153+ case coverDirectionHorizontal:1154+ return "horizontal"1155+ case coverDirectionDiagonal:1156+ return "diagonal"1157+ default:1158+ return "clustered"1159+ }1160+}1161+1162+func coverShapeKindName(kind coverShapeKind) string {1163+ switch kind {1164+ case coverShapeRect:1165+ return "rect"1166+ case coverShapeCircle:1167+ return "circle"1168+ case coverShapeCroppedCircle:1169+ return "cropped-circle"1170+ default:1171+ return "stepped"1172+ }1173+}1174+1175+func coverTextureCount(layout coverArtLayout) int {1176+ seen := map[coverTextureKind]struct{}{}1177+ for _, shape := range layout.Shapes {1178+ if shape.Texture == coverTextureNone {1179+ continue1180+ }1181+ seen[shape.Texture] = struct{}{}1182+ }1183+ return len(seen)1184+}1185+1186+func coverPrimitiveMixSignature(layout coverArtLayout) string {1187+ parts := map[string]struct{}{}1188+ for _, shape := range layout.Shapes {1189+ parts[coverShapeKindName(shape.Kind)] = struct{}{}1190+ }1191+1192+ keys := make([]string, 0, len(parts))1193+ for key := range parts {1194+ keys = append(keys, key)1195+ }1196+ sort.Strings(keys)1197+ return strings.Join(keys, "+")1198+}1199+1200+func coverRotationSignature(layout coverArtLayout) string {1201+ parts := make([]string, 0, len(layout.Shapes))1202+ for _, shape := range layout.Shapes {1203+ parts = append(parts, fmt.Sprintf("%.1f", shape.Rotate))1204+ }1205+ sort.Strings(parts)1206+ return strings.Join(parts, ",")1207+}1208+1209+func coverRotationPolaritySignature(layout coverArtLayout) string {1210+ hasPos := false1211+ hasNeg := false1212+ for _, shape := range layout.Shapes {1213+ if shape.Rotate > 0 {1214+ hasPos = true1215+ }1216+ if shape.Rotate < 0 {1217+ hasNeg = true1218+ }1219+ }1220+ switch {1221+ case hasPos && hasNeg:1222+ return "mixed"1223+ case hasPos:1224+ return "positive"1225+ case hasNeg:1226+ return "negative"1227+ default:1228+ return "flat"1229+ }1230+}1231+1232+func coverArtInkBounds(layout coverArtLayout) rectMM {1233+ if len(layout.Shapes) == 0 {1234+ return rectMM{}1235+ }1236+1237+ minX := math.MaxFloat641238+ minY := math.MaxFloat641239+ maxX := 0.01240+ maxY := 0.01241+ for _, shape := range layout.Shapes {1242+ env := coverShapeEnvelope(shape)1243+ minX = min(minX, env.x)1244+ minY = min(minY, env.y)1245+ maxX = max(maxX, env.x+env.w)1246+ maxY = max(maxY, env.y+env.h)1247+ }1248+ return rectMM{x: minX, y: minY, w: maxX - minX, h: maxY - minY}1249+}1250+1251+func coverInkAspectBucket(layout coverArtLayout) coverAspectBucket {1252+ bounds := coverArtInkBounds(layout)1253+ if bounds.w <= 0 || bounds.h <= 0 {1254+ return coverAspectCompact1255+ }1256+1257+ if bounds.h/bounds.w >= 1.28 {1258+ return coverAspectTall1259+ }1260+ if bounds.w/bounds.h >= 1.28 {1261+ return coverAspectWide1262+ }1263+1264+ centerX := bounds.x + bounds.w*0.51265+ centerY := bounds.y + bounds.h*0.51266+ artCenterX := layout.ArtArea.x + layout.ArtArea.w*0.51267+ artCenterY := layout.ArtArea.y + layout.ArtArea.h*0.51268+ offset := math.Hypot(centerX-artCenterX, centerY-artCenterY)1269+ if offset > min(layout.ArtArea.w, layout.ArtArea.h)*0.12 {1270+ return coverAspectOffset1271+ }1272+ return coverAspectCompact1273+}1274+1275+func coverAspectBucketName(bucket coverAspectBucket) string {1276+ switch bucket {1277+ case coverAspectTall:1278+ return "tall"1279+ case coverAspectWide:1280+ return "wide"1281+ case coverAspectOffset:1282+ return "offset"1283+ default:1284+ return "compact"1285+ }1331286 }
M pdfexport/render_cover_test.go +184 -0
@@ -1,6 +1,7 @@
11 package pdfexport22 33 import (4+ "math"45 "reflect"56 "testing"67
@@ -48,3 +49,186 @@ func TestSplitClampedTextLinesClampsToMaxLines(t *testing.T) {
4849 t.Fatalf("splitClampedTextLines not stable: %v vs %v", got, gotAgain)4950 }5051 }52+53+func TestBuildCoverArtLayoutIsDeterministicForSameSeed(t *testing.T) {54+ cfg := RenderConfig{55+ ShuffleSeed: "zine-seed-01",56+ VolumeNumber: 4,57+ }58+59+ layoutA := buildCoverArtLayout(cfg, halfLetterWidthMM, halfLetterHeightMM)60+ layoutB := buildCoverArtLayout(cfg, halfLetterWidthMM, halfLetterHeightMM)61+ if !coverArtLayoutEqual(layoutA, layoutB) {62+ t.Fatalf("cover layout should be stable for identical input: %#v vs %#v", layoutA, layoutB)63+ }64+ if !coverArtStaysWithinArea(layoutA) {65+ t.Fatalf("cover layout escapes art area: %#v", layoutA)66+ }67+ if !coverArtRespectsLockup(layoutA) {68+ t.Fatalf("cover layout overlaps lockup exclusion: %#v", layoutA)69+ }70+ if coverRotationSignature(layoutA) != coverRotationSignature(layoutB) {71+ t.Fatalf("rotation signature changed: %q vs %q", coverRotationSignature(layoutA), coverRotationSignature(layoutB))72+ }73+}74+75+func TestBuildCoverArtLayoutChangesWhenSeedChanges(t *testing.T) {76+ base := RenderConfig{77+ ShuffleSeed: "zine-seed-01",78+ VolumeNumber: 4,79+ }80+ other := RenderConfig{81+ ShuffleSeed: "zine-seed-02",82+ VolumeNumber: 4,83+ }84+85+ layoutA := buildCoverArtLayout(base, halfLetterWidthMM, halfLetterHeightMM)86+ layoutB := buildCoverArtLayout(other, halfLetterWidthMM, halfLetterHeightMM)87+ if !coverArtDiffers(layoutA, layoutB) {88+ t.Fatalf("cover layout should differ when seed changes: %#v", layoutA)89+ }90+ if coverRotationSignature(layoutA) == coverRotationSignature(layoutB) {91+ t.Fatalf("rotation signature should differ when seed changes: %q", coverRotationSignature(layoutA))92+ }93+}94+95+func TestBuildCoverArtLayoutUsesMultipleTextureFamilies(t *testing.T) {96+ cfg := RenderConfig{97+ ShuffleSeed: "texture-seed",98+ VolumeNumber: 2,99+ }100+101+ layout := buildCoverArtLayout(cfg, halfLetterWidthMM, halfLetterHeightMM)102+ if got := coverTextureCount(layout); got < 1 {103+ t.Fatalf("texture count = %d, want at least 1", got)104+ }105+106+ bounds := coverArtInkBounds(layout)107+ if bounds.w <= 0 || bounds.h <= 0 {108+ t.Fatalf("ink bounds = %#v, want positive extents", bounds)109+ }110+}111+112+func TestCoverSeedCorpusProducesMultipleSilhouetteFamilies(t *testing.T) {113+ seeds := []string{114+ "near-seed-00",115+ "near-seed-01",116+ "near-seed-02",117+ "near-seed-03",118+ "near-seed-04",119+ "near-seed-05",120+ "near-seed-06",121+ "near-seed-07",122+ "near-seed-08",123+ "near-seed-09",124+ }125+126+ families := map[string]struct{}{}127+ aspectBuckets := map[string]struct{}{}128+ directions := map[string]struct{}{}129+ mixes := map[string]struct{}{}130+ rotations := map[string]struct{}{}131+ polarities := map[string]struct{}{}132+133+ for _, seed := range seeds {134+ layout := buildCoverArtLayout(RenderConfig{135+ ShuffleSeed: seed,136+ VolumeNumber: 1,137+ }, halfLetterWidthMM, halfLetterHeightMM)138+139+ if !coverArtStaysWithinArea(layout) {140+ t.Fatalf("layout for %q escapes art area: %#v", seed, layout)141+ }142+ if !coverArtRespectsLockup(layout) {143+ t.Fatalf("layout for %q overlaps lockup exclusion: %#v", seed, layout)144+ }145+146+ families[coverFamilyName(layout.Family)] = struct{}{}147+ aspectBuckets[coverAspectBucketName(coverInkAspectBucket(layout))] = struct{}{}148+ directions[coverDirectionName(layout.Direction)] = struct{}{}149+ mixes[coverPrimitiveMixSignature(layout)] = struct{}{}150+ rotations[coverRotationSignature(layout)] = struct{}{}151+ polarities[coverRotationPolaritySignature(layout)] = struct{}{}152+ }153+154+ if got := len(families); got < 3 {155+ t.Fatalf("family count = %d, want at least 3", got)156+ }157+ if got := len(aspectBuckets); got < 2 {158+ t.Fatalf("aspect bucket count = %d, want at least 2", got)159+ }160+ if got := len(directions); got < 3 {161+ t.Fatalf("direction count = %d, want at least 3", got)162+ }163+ if got := len(mixes); got < 3 {164+ t.Fatalf("primitive mix count = %d, want at least 3", got)165+ }166+ if got := len(rotations); got < 4 {167+ t.Fatalf("rotation signature count = %d, want at least 4", got)168+ }169+ if got := len(polarities); got < 2 {170+ t.Fatalf("rotation polarity count = %d, want at least 2", got)171+ }172+}173+174+func TestRotatedRectAABBNinetyDegreesSwapsExtents(t *testing.T) {175+ bounds := rectMM{x: 10, y: 20, w: 20, h: 10}176+ aabb := rotatedRectAABB(bounds, 20, 25, 90)177+ if math.Abs(aabb.w-10) > 0.001 {178+ t.Fatalf("aabb.w = %.3f, want 10", aabb.w)179+ }180+ if math.Abs(aabb.h-20) > 0.001 {181+ t.Fatalf("aabb.h = %.3f, want 20", aabb.h)182+ }183+ if math.Abs(aabb.x-15) > 0.001 || math.Abs(aabb.y-15) > 0.001 {184+ t.Fatalf("aabb origin = (%.3f, %.3f), want (15, 15)", aabb.x, aabb.y)185+ }186+}187+188+func TestCoverRotationAngleBandRespectsStructuralFlag(t *testing.T) {189+ seeds := []string{190+ "rotation-band-00",191+ "rotation-band-01",192+ "rotation-band-02",193+ "rotation-band-03",194+ "rotation-band-04",195+ "rotation-band-05",196+ }197+198+ seenPositive := false199+ seenNegative := false200+ seenStructural := false201+ for _, seed := range seeds {202+ layout := buildCoverArtLayout(RenderConfig{203+ ShuffleSeed: seed,204+ VolumeNumber: 1,205+ }, halfLetterWidthMM, halfLetterHeightMM)206+207+ for _, shape := range layout.Shapes {208+ absAngle := math.Abs(shape.Rotate)209+ if shape.Rotate > 0 {210+ seenPositive = true211+ }212+ if shape.Rotate < 0 {213+ seenNegative = true214+ }215+ if shape.Locked {216+ seenStructural = true217+ if absAngle < 4 || absAngle > 10 {218+ t.Fatalf("structural angle = %.2f, want within [4,10]", absAngle)219+ }220+ continue221+ }222+ if absAngle < 8 || absAngle > 20 {223+ t.Fatalf("standard angle = %.2f, want within [8,20]", absAngle)224+ }225+ }226+ }227+228+ if !seenStructural {229+ t.Fatal("expected at least one structural shape across seed corpus")230+ }231+ if !seenPositive || !seenNegative {232+ t.Fatalf("expected both positive and negative rotations, got positive=%t negative=%t", seenPositive, seenNegative)233+ }234+}
A pdfexport/render_difficulty.go +165 -0
@@ -0,0 +1,165 @@
1+package pdfexport2+3+import (4+ "math"5+6+ "codeberg.org/go-pdf/fpdf"7+)8+9+type difficultyStarState uint810+11+const (12+ difficultyStarEmpty difficultyStarState = iota13+ difficultyStarHalf14+ difficultyStarFull15+)16+17+type puzzleSubtitleLayout struct {18+ labelText string19+ labelWidth float6420+ starStates []difficultyStarState21+ starsWidth float6422+ totalWidth float6423+}24+25+func scoreToDifficultyStarStates(score float64) []difficultyStarState {26+ score = clampDifficultyScore(score)27+28+ units := int(math.Round(score * 10))29+ states := make([]difficultyStarState, 5)30+ for i := range states {31+ switch {32+ case units >= 2:33+ states[i] = difficultyStarFull34+ units -= 235+ case units == 1:36+ states[i] = difficultyStarHalf37+ units = 038+ default:39+ states[i] = difficultyStarEmpty40+ }41+ }42+ return states43+}44+45+func puzzleDifficultySubtitleLayout(pdf *fpdf.Fpdf, puzzle Puzzle) puzzleSubtitleLayout {46+ label := "Difficulty:"47+ if !isMixedModes(puzzle.ModeSelection) {48+ label = "Mode: " + puzzle.ModeSelection + " | " + label49+ }50+51+ layout := puzzleSubtitleLayout{52+ labelText: label,53+ starStates: scoreToDifficultyStarStates(puzzle.DifficultyScore),54+ }55+ if pdf == nil {56+ return layout57+ }58+59+ setPuzzleSubtitleStyle(pdf)60+ layout.labelWidth = pdf.GetStringWidth(label)61+ layout.starsWidth = difficultyStarsWidth()62+ layout.totalWidth = layout.labelWidth + difficultyTextToStarsGapMM + layout.starsWidth63+ return layout64+}65+66+func renderPuzzleDifficultySubtitle(pdf *fpdf.Fpdf, pageW, y float64, puzzle Puzzle) {67+ if pdf == nil {68+ return69+ }70+71+ layout := puzzleDifficultySubtitleLayout(pdf, puzzle)72+ rowHeight := 5.073+ startX := (pageW - layout.totalWidth) / 274+ if startX < 0 {75+ startX = 076+ }77+78+ setPuzzleSubtitleStyle(pdf)79+ pdf.SetXY(startX, y)80+ pdf.CellFormat(layout.labelWidth, rowHeight, layout.labelText, "", 0, "L", false, 0, "")81+82+ starX := startX + layout.labelWidth + difficultyTextToStarsGapMM83+ starCenterY := y + rowHeight/284+ renderDifficultyStars(pdf, starX, starCenterY, layout.starStates)85+}86+87+func renderDifficultyStars(pdf *fpdf.Fpdf, x, centerY float64, states []difficultyStarState) {88+ if pdf == nil {89+ return90+ }91+92+ textR, textG, textB := pdf.GetTextColor()93+ emptyOutline := 13294+ pdf.SetLineWidth(difficultyStarOutlineMM)95+96+ for i, state := range states {97+ starX := x + float64(i)*(difficultyStarSizeMM+difficultyStarGapMM)98+ points := difficultyStarPoints(starX, centerY, difficultyStarSizeMM)99+ top := centerY - difficultyStarSizeMM/2100+101+ switch state {102+ case difficultyStarFull:103+ pdf.SetDrawColor(textR, textG, textB)104+ fillDifficultyStar(pdf, points, starX, top, difficultyStarSizeMM, difficultyStarSizeMM, textR, textG, textB)105+ pdf.Polygon(points, "D")106+ case difficultyStarHalf:107+ pdf.SetDrawColor(emptyOutline, emptyOutline, emptyOutline)108+ pdf.Polygon(points, "D")109+ fillDifficultyStar(pdf, points, starX, top, difficultyStarSizeMM/2, difficultyStarSizeMM, textR, textG, textB)110+ default:111+ pdf.SetDrawColor(emptyOutline, emptyOutline, emptyOutline)112+ pdf.Polygon(points, "D")113+ }114+ }115+}116+117+func fillDifficultyStar(118+ pdf *fpdf.Fpdf,119+ points []fpdf.PointType,120+ x,121+ y,122+ w,123+ h float64,124+ r,125+ g,126+ b int,127+) {128+ if pdf == nil || len(points) == 0 || w <= 0 || h <= 0 {129+ return130+ }131+132+ pdf.ClipPolygon(points, false)133+ pdf.ClipRect(x, y, w, h, false)134+ pdf.SetFillColor(r, g, b)135+ pdf.Rect(x, y, w, h, "F")136+ pdf.ClipEnd()137+ pdf.ClipEnd()138+}139+140+func difficultyStarsWidth() float64 {141+ return float64(5)*difficultyStarSizeMM + float64(4)*difficultyStarGapMM142+}143+144+func difficultyStarPoints(x, centerY, size float64) []fpdf.PointType {145+ cx := x + size/2146+ cy := centerY147+ outer := size / 2148+ inner := outer * 0.48149+ points := make([]fpdf.PointType, 0, 10)150+151+ for i := range 10 {152+ radius := outer153+ if i%2 == 1 {154+ radius = inner155+ }156+157+ angle := -math.Pi/2 + float64(i)*math.Pi/5158+ points = append(points, fpdf.PointType{159+ X: cx + math.Cos(angle)*radius,160+ Y: cy + math.Sin(angle)*radius,161+ })162+ }163+164+ return points165+}
A pdfexport/render_difficulty_test.go +143 -0
@@ -0,0 +1,143 @@
1+package pdfexport2+3+import (4+ "reflect"5+ "testing"6+7+ "codeberg.org/go-pdf/fpdf"8+)9+10+func newDifficultyTestPDF(t *testing.T) *fpdf.Fpdf {11+ t.Helper()12+13+ pdf := fpdf.NewCustom(&fpdf.InitType{14+ OrientationStr: "P",15+ UnitStr: "mm",16+ Size: fpdf.SizeType{17+ Wd: halfLetterWidthMM,18+ Ht: halfLetterHeightMM,19+ },20+ })21+ if err := registerPDFFonts(pdf); err != nil {22+ t.Fatalf("registerPDFFonts error: %v", err)23+ }24+ pdf.AddPage()25+ return pdf26+}27+28+func TestScoreToDifficultyStarStates(t *testing.T) {29+ tests := []struct {30+ name string31+ score float6432+ want []difficultyStarState33+ }{34+ {35+ name: "zero score is empty",36+ score: 0.0,37+ want: []difficultyStarState{difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty},38+ },39+ {40+ name: "one tenth is half then empty",41+ score: 0.1,42+ want: []difficultyStarState{difficultyStarHalf, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty},43+ },44+ {45+ name: "three tenths is full half then empty",46+ score: 0.3,47+ want: []difficultyStarState{difficultyStarFull, difficultyStarHalf, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty},48+ },49+ {50+ name: "five tenths is two and a half stars",51+ score: 0.5,52+ want: []difficultyStarState{difficultyStarFull, difficultyStarFull, difficultyStarHalf, difficultyStarEmpty, difficultyStarEmpty},53+ },54+ {55+ name: "eight tenths is four full stars",56+ score: 0.8,57+ want: []difficultyStarState{difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarEmpty},58+ },59+ {60+ name: "one is five full stars",61+ score: 1.0,62+ want: []difficultyStarState{difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarFull},63+ },64+ {65+ name: "negative clamps low",66+ score: -1.0,67+ want: []difficultyStarState{difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty},68+ },69+ {70+ name: "above one clamps high",71+ score: 2.0,72+ want: []difficultyStarState{difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarFull},73+ },74+ }75+76+ for _, tt := range tests {77+ t.Run(tt.name, func(t *testing.T) {78+ got := scoreToDifficultyStarStates(tt.score)79+ if !reflect.DeepEqual(got, tt.want) {80+ t.Fatalf("states = %v, want %v", got, tt.want)81+ }82+ })83+ }84+}85+86+func TestPuzzleDifficultySubtitleLayoutWidthNormalMode(t *testing.T) {87+ pdf := newDifficultyTestPDF(t)88+ layout := puzzleDifficultySubtitleLayout(pdf, Puzzle{89+ ModeSelection: "Expert",90+ DifficultyScore: 0.5,91+ })92+ if layout.labelText != "Mode: Expert | Difficulty:" {93+ t.Fatalf("label = %q", layout.labelText)94+ }95+ if layout.totalWidth <= 0 {96+ t.Fatalf("total width = %.3f, want positive", layout.totalWidth)97+ }98+ if layout.totalWidth > halfLetterWidthMM {99+ t.Fatalf("total width = %.3f, want <= %.3f", layout.totalWidth, halfLetterWidthMM)100+ }101+}102+103+func TestPuzzleDifficultySubtitleLayoutWidthMixedMode(t *testing.T) {104+ pdf := newDifficultyTestPDF(t)105+ layout := puzzleDifficultySubtitleLayout(pdf, Puzzle{106+ ModeSelection: "Mixed Modes",107+ DifficultyScore: 0.3,108+ })109+ if layout.labelText != "Difficulty:" {110+ t.Fatalf("label = %q", layout.labelText)111+ }112+ if layout.totalWidth <= 0 {113+ t.Fatalf("total width = %.3f, want positive", layout.totalWidth)114+ }115+ if layout.totalWidth > halfLetterWidthMM {116+ t.Fatalf("total width = %.3f, want <= %.3f", layout.totalWidth, halfLetterWidthMM)117+ }118+}119+120+func TestPuzzleDifficultySubtitleLayoutStatesSequence(t *testing.T) {121+ pdf := newDifficultyTestPDF(t)122+ layout := puzzleDifficultySubtitleLayout(pdf, Puzzle{123+ ModeSelection: "Tricky",124+ DifficultyScore: 0.3,125+ })126+ want := []difficultyStarState{127+ difficultyStarFull,128+ difficultyStarHalf,129+ difficultyStarEmpty,130+ difficultyStarEmpty,131+ difficultyStarEmpty,132+ }133+ if !reflect.DeepEqual(layout.starStates, want) {134+ t.Fatalf("states = %v, want %v", layout.starStates, want)135+ }136+}137+138+func TestDifficultyStarPointsReturnsTenVertices(t *testing.T) {139+ points := difficultyStarPoints(10, 20, difficultyStarSizeMM)140+ if len(points) != 10 {141+ t.Fatalf("vertex count = %d, want 10", len(points))142+ }143+}
A pdfexport/render_instructions.go +64 -0
A pdfexport/render_instructions_test.go +86 -0
@@ -0,0 +1,86 @@
1+package pdfexport2+3+import (4+ "testing"5+6+ "codeberg.org/go-pdf/fpdf"7+)8+9+func TestWrapInstructionLinesWrapsToSafeWidth(t *testing.T) {10+ pdf := fpdf.NewCustom(&fpdf.InitType{11+ OrientationStr: "P",12+ UnitStr: "mm",13+ Size: fpdf.SizeType{14+ Wd: halfLetterWidthMM,15+ Ht: halfLetterHeightMM,16+ },17+ })18+ if err := registerPDFFonts(pdf); err != nil {19+ t.Fatalf("registerPDFFonts error: %v", err)20+ }21+ pdf.AddPage()22+23+ body := PuzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 2)24+ lines := wrapInstructionLines(25+ pdf,26+ body.W,27+ []string{"Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected."},28+ )29+ if len(lines) < 2 {30+ t.Fatalf("line count = %d, want at least 2 (%v)", len(lines), lines)31+ }32+33+ setInstructionStyle(pdf)34+ maxWidth := body.W - instructionWrapInsetMM*235+ for _, line := range lines {36+ if got := pdf.GetStringWidth(line); got > maxWidth+0.01 {37+ t.Fatalf("line %q width = %.2f, want <= %.2f", line, got, maxWidth)38+ }39+ }40+}41+42+func TestWrapInstructionLinesKeepsShortRulesSingleLine(t *testing.T) {43+ pdf := fpdf.NewCustom(&fpdf.InitType{44+ OrientationStr: "P",45+ UnitStr: "mm",46+ Size: fpdf.SizeType{47+ Wd: halfLetterWidthMM,48+ Ht: halfLetterHeightMM,49+ },50+ })51+ if err := registerPDFFonts(pdf); err != nil {52+ t.Fatalf("registerPDFFonts error: %v", err)53+ }54+ pdf.AddPage()55+56+ body := PuzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 2)57+ lines := wrapInstructionLines(pdf, body.W, []string{"Fill rows, columns, and 3x3 boxes with 1-9"})58+ if len(lines) != 1 {59+ t.Fatalf("line count = %d, want 1 (%v)", len(lines), lines)60+ }61+}62+63+func TestTakuzuPlusRuleCopyWrapsToThreePhysicalLines(t *testing.T) {64+ pdf := fpdf.NewCustom(&fpdf.InitType{65+ OrientationStr: "P",66+ UnitStr: "mm",67+ Size: fpdf.SizeType{68+ Wd: halfLetterWidthMM,69+ Ht: halfLetterHeightMM,70+ },71+ })72+ if err := registerPDFFonts(pdf); err != nil {73+ t.Fatalf("registerPDFFonts error: %v", err)74+ }75+ pdf.AddPage()76+77+ body := PuzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 2)78+ lines := wrapInstructionLines(pdf, body.W, []string{79+ "No three equal adjacent in any row or column.",80+ "Rows/columns have equal 0s and 1s, and all rows/columns are unique.",81+ "= means same; x means different.",82+ })83+ if len(lines) != 3 {84+ t.Fatalf("line count = %d, want 3 (%v)", len(lines), lines)85+ }86+}
M pdfexport/render_kit.go +2 -1
M pdfexport/render_layout_test.go +151 -4
@@ -98,14 +98,14 @@ func TestSaddleStitchPadCountForStandardPackLayout(t *testing.T) {
9898 puzzleRows int9999 wantPad int100100 }{101- {name: "single puzzle", puzzleRows: 1, wantPad: 0},102- {name: "two puzzles", puzzleRows: 2, wantPad: 3},103- {name: "thirty-two puzzles", puzzleRows: 32, wantPad: 1},101+ {name: "single puzzle", puzzleRows: 1, wantPad: 2},102+ {name: "two puzzles", puzzleRows: 2, wantPad: 1},103+ {name: "thirty-two puzzles", puzzleRows: 32, wantPad: 3},104104 }105105 106106 for _, tt := range tests {107107 t.Run(tt.name, func(t *testing.T) {108- totalWithoutPad := tt.puzzleRows + 3 // cover + title + back cover108+ totalWithoutPad := tt.puzzleRows + 5 // outside front + inside front + title + inside back + outside back109109 got := saddleStitchPadCount(totalWithoutPad)110110 if got != tt.wantPad {111111 t.Fatalf("pad pages = %d, want %d", got, tt.wantPad)
@@ -117,6 +117,153 @@ func TestSaddleStitchPadCountForStandardPackLayout(t *testing.T) {
117117 }118118 }119119 120+func TestBookletRenderPlanWithCoverUsesFirstTwoAndLastTwoPages(t *testing.T) {121+ plan := newBookletRenderPlan(32, true)122+123+ if got, want := plan.titlePageNumber(), 3; got != want {124+ t.Fatalf("title page = %d, want %d", got, want)125+ }126+ if got, want := plan.totalPages(), 40; got != want {127+ t.Fatalf("total pages = %d, want %d", got, want)128+ }129+ if got, want := plan.padPages, 3; got != want {130+ t.Fatalf("pad pages = %d, want %d", got, want)131+ }132+133+ frontOutside, frontInside, backInside, backOutside, ok := plan.coverPageNumbers()134+ if !ok {135+ t.Fatal("expected cover page numbers")136+ }137+ if frontOutside != 1 || frontInside != 2 || backInside != 39 || backOutside != 40 {138+ t.Fatalf("cover pages = (%d,%d,%d,%d), want (1,2,39,40)", frontOutside, frontInside, backInside, backOutside)139+ }140+141+ excluded := plan.footerExcludedPages()142+ for _, page := range []int{1, 2, 3, 36, 37, 38, 39, 40} {143+ if _, ok := excluded[page]; !ok {144+ t.Fatalf("expected page %d to be footer-excluded", page)145+ }146+ }147+ for _, page := range []int{4, 20, 35} {148+ if _, ok := excluded[page]; ok {149+ t.Fatalf("did not expect puzzle page %d to be footer-excluded", page)150+ }151+ }152+}153+154+func TestBookletRenderPlanWithoutCoverKeepsTitleFirst(t *testing.T) {155+ plan := newBookletRenderPlan(2, false)156+157+ if got, want := plan.titlePageNumber(), 1; got != want {158+ t.Fatalf("title page = %d, want %d", got, want)159+ }160+ if got, want := plan.totalPages(), 4; got != want {161+ t.Fatalf("total pages = %d, want %d", got, want)162+ }163+164+ excluded := plan.footerExcludedPages()165+ for _, page := range []int{1, 4} {166+ if _, ok := excluded[page]; !ok {167+ t.Fatalf("expected page %d to be footer-excluded", page)168+ }169+ }170+ if _, ok := excluded[2]; ok {171+ t.Fatal("did not expect first puzzle page to be footer-excluded")172+ }173+}174+175+func TestBuildLogicalPagesWithCoverUsesExpectedSequence(t *testing.T) {176+ pages := buildLogicalPages(32, true)177+ if got, want := len(pages), 40; got != want {178+ t.Fatalf("logical pages = %d, want %d", got, want)179+ }180+181+ if pages[0].Kind != logicalPageCoverOutside || pages[0].OutsideSlice != coverOutsideFront {182+ t.Fatal("page 1 should be front outside cover")183+ }184+ if pages[1].Kind != logicalPageCoverBlank {185+ t.Fatal("page 2 should be inside front blank")186+ }187+ if pages[2].Kind != logicalPageTitle {188+ t.Fatal("page 3 should be title")189+ }190+ if pages[len(pages)-2].Kind != logicalPageCoverBlank {191+ t.Fatal("second-last page should be inside back blank")192+ }193+ if pages[len(pages)-1].Kind != logicalPageCoverOutside || pages[len(pages)-1].OutsideSlice != coverOutsideBack {194+ t.Fatal("last page should be back outside cover")195+ }196+}197+198+func TestDuplexBookletSheetsForFourPageBooklet(t *testing.T) {199+ sheets := duplexBookletSheets(4)200+ if got, want := len(sheets), 1; got != want {201+ t.Fatalf("sheet count = %d, want %d", got, want)202+ }203+ if sheets[0].Front.LeftPage != 4 || sheets[0].Front.RightPage != 1 {204+ t.Fatalf("front pair = (%d,%d), want (4,1)", sheets[0].Front.LeftPage, sheets[0].Front.RightPage)205+ }206+ if sheets[0].Back.LeftPage != 2 || sheets[0].Back.RightPage != 3 {207+ t.Fatalf("back pair = (%d,%d), want (2,3)", sheets[0].Back.LeftPage, sheets[0].Back.RightPage)208+ }209+}210+211+func TestDuplexBookletSheetsForEightPageBooklet(t *testing.T) {212+ sheets := duplexBookletSheets(8)213+ if got, want := len(sheets), 2; got != want {214+ t.Fatalf("sheet count = %d, want %d", got, want)215+ }216+ if sheets[0].Front.LeftPage != 8 || sheets[0].Front.RightPage != 1 {217+ t.Fatalf("outer front pair = (%d,%d), want (8,1)", sheets[0].Front.LeftPage, sheets[0].Front.RightPage)218+ }219+ if sheets[1].Back.LeftPage != 4 || sheets[1].Back.RightPage != 5 {220+ t.Fatalf("inner back pair = (%d,%d), want (4,5)", sheets[1].Back.LeftPage, sheets[1].Back.RightPage)221+ }222+}223+224+func TestDuplexBookletSheetsWithCoverOuterSheetPairs(t *testing.T) {225+ pages := buildLogicalPages(32, true)226+ sheets := duplexBookletSheets(len(pages))227+ if got, want := len(sheets), 10; got != want {228+ t.Fatalf("sheet count = %d, want %d", got, want)229+ }230+ if sheets[0].Front.LeftPage != 40 || sheets[0].Front.RightPage != 1 {231+ t.Fatalf("outer front pair = (%d,%d), want (40,1)", sheets[0].Front.LeftPage, sheets[0].Front.RightPage)232+ }233+ if sheets[0].Back.LeftPage != 2 || sheets[0].Back.RightPage != 39 {234+ t.Fatalf("outer back pair = (%d,%d), want (2,39)", sheets[0].Back.LeftPage, sheets[0].Back.RightPage)235+ }236+}237+238+func TestDuplexBookletPhysicalPageCountIsHalfLogicalPages(t *testing.T) {239+ for _, total := range []int{4, 8, 40} {240+ sheets := duplexBookletSheets(total)241+ got := len(sheets) * 2242+ want := total / 2243+ if got != want {244+ t.Fatalf("physical pages for %d logical pages = %d, want %d", total, got, want)245+ }246+ }247+}248+249+func TestNewRenderPDFDuplexBookletUsesLandscapeLetterCanvas(t *testing.T) {250+ pdf := newRenderPDF(SheetLayoutDuplexBooklet)251+ pageW, pageH := pdf.GetPageSize()252+253+ if pageW <= pageH {254+ t.Fatalf("duplex-booklet canvas = %.1fx%.1f, want landscape", pageW, pageH)255+ }256+ if math.Abs(pageW-letterWidthMM) > 0.01 || math.Abs(pageH-letterHeightMM) > 0.01 {257+ t.Fatalf(258+ "duplex-booklet canvas = %.1fx%.1f, want %.1fx%.1f",259+ pageW,260+ pageH,261+ letterWidthMM,262+ letterHeightMM,263+ )264+ }265+}266+120267 func TestSaddleStitchPadCountForTitleOnlyPackLayout(t *testing.T) {121268 tests := []struct {122269 name string
M pdfexport/render_metadata.go +4 -6
A pdfexport/render_plan.go +164 -0
@@ -0,0 +1,164 @@
1+package pdfexport2+3+type bookletRenderPlan struct {4+ puzzlePages int5+ includeCover bool6+ padPages int7+}8+9+type logicalPageKind int10+11+const (12+ logicalPageCoverOutside logicalPageKind = iota13+ logicalPageCoverBlank14+ logicalPageTitle15+ logicalPagePuzzle16+ logicalPagePad17+)18+19+type logicalPage struct {20+ Number int21+ Kind logicalPageKind22+ PuzzleIndex int23+ OutsideSlice coverOutsideSlice24+ ShowFooter bool25+}26+27+type duplexBookletSpread struct {28+ LeftPage int29+ RightPage int30+}31+32+type duplexBookletSheet struct {33+ Front duplexBookletSpread34+ Back duplexBookletSpread35+}36+37+func newBookletRenderPlan(puzzlePages int, includeCover bool) bookletRenderPlan {38+ extraPages := 1 // title page39+ if includeCover {40+ extraPages += 441+ }42+43+ return bookletRenderPlan{44+ puzzlePages: puzzlePages,45+ includeCover: includeCover,46+ padPages: saddleStitchPadCount(puzzlePages + extraPages),47+ }48+}49+50+func (p bookletRenderPlan) totalPagesWithoutPadding() int {51+ total := p.puzzlePages + 1 // title page52+ if p.includeCover {53+ total += 454+ }55+ return total56+}57+58+func (p bookletRenderPlan) totalPages() int {59+ return p.totalPagesWithoutPadding() + p.padPages60+}61+62+func (p bookletRenderPlan) titlePageNumber() int {63+ if p.includeCover {64+ return 365+ }66+ return 167+}68+69+func (p bookletRenderPlan) puzzleStartPage() int {70+ return p.titlePageNumber() + 171+}72+73+func (p bookletRenderPlan) firstPadPage() int {74+ return p.puzzleStartPage() + p.puzzlePages75+}76+77+func (p bookletRenderPlan) coverPageNumbers() (frontOutside, frontInside, backInside, backOutside int, ok bool) {78+ if !p.includeCover {79+ return 0, 0, 0, 0, false80+ }81+82+ total := p.totalPages()83+ return 1, 2, total - 1, total, true84+}85+86+func (p bookletRenderPlan) footerExcludedPages() map[int]struct{} {87+ excluded := map[int]struct{}{88+ p.titlePageNumber(): {},89+ }90+91+ for page := p.firstPadPage(); page < p.firstPadPage()+p.padPages; page++ {92+ excluded[page] = struct{}{}93+ }94+95+ frontOutside, frontInside, backInside, backOutside, ok := p.coverPageNumbers()96+ if ok {97+ excluded[frontOutside] = struct{}{}98+ excluded[frontInside] = struct{}{}99+ excluded[backInside] = struct{}{}100+ excluded[backOutside] = struct{}{}101+ }102+103+ return excluded104+}105+106+func buildLogicalPages(puzzlePages int, includeCover bool) []logicalPage {107+ plan := newBookletRenderPlan(puzzlePages, includeCover)108+ excluded := plan.footerExcludedPages()109+ pages := make([]logicalPage, 0, plan.totalPages())110+111+ appendPage := func(kind logicalPageKind, puzzleIndex int, slice coverOutsideSlice) {112+ pageNo := len(pages) + 1113+ _, skipFooter := excluded[pageNo]114+ pages = append(pages, logicalPage{115+ Number: pageNo,116+ Kind: kind,117+ PuzzleIndex: puzzleIndex,118+ OutsideSlice: slice,119+ ShowFooter: !skipFooter,120+ })121+ }122+123+ if includeCover {124+ appendPage(logicalPageCoverOutside, -1, coverOutsideFront)125+ appendPage(logicalPageCoverBlank, -1, coverOutsideFront)126+ }127+128+ appendPage(logicalPageTitle, -1, coverOutsideFront)129+130+ for i := range puzzlePages {131+ appendPage(logicalPagePuzzle, i, coverOutsideFront)132+ }133+ for range plan.padPages {134+ appendPage(logicalPagePad, -1, coverOutsideFront)135+ }136+137+ if includeCover {138+ appendPage(logicalPageCoverBlank, -1, coverOutsideBack)139+ appendPage(logicalPageCoverOutside, -1, coverOutsideBack)140+ }141+142+ return pages143+}144+145+func duplexBookletSheets(totalPages int) []duplexBookletSheet {146+ if totalPages <= 0 || totalPages%4 != 0 {147+ return nil148+ }149+150+ sheets := make([]duplexBookletSheet, 0, totalPages/4)151+ for sheetIndex := 0; sheetIndex < totalPages/4; sheetIndex++ {152+ sheets = append(sheets, duplexBookletSheet{153+ Front: duplexBookletSpread{154+ LeftPage: totalPages - 2*sheetIndex,155+ RightPage: 1 + 2*sheetIndex,156+ },157+ Back: duplexBookletSpread{158+ LeftPage: 2 + 2*sheetIndex,159+ RightPage: totalPages - 1 - 2*sheetIndex,160+ },161+ })162+ }163+ return sheets164+}
M pdfexport/render_title.go +9 -10
@@ -10,25 +10,24 @@ import (
1010 )1111 1212 func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) {13- pdf.AddPage()1413 pageW, pageH := pdf.GetPageSize()1514 margin := 12.01615 contentWidth := pageW - 2*margin1716 categoryTotals := summarizeCategoryTotals(puzzles)1817 1918 pdf.SetTextColor(20, 20, 20)20- pdf.SetFont(sansFontFamily, "B", 22)19+ pdf.SetFont(sansFontFamily, "B", 22+pdfFontSizeDelta)2120 pdf.SetXY(0, 24)2221 pdf.CellFormat(pageW, 10, fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber), "", 0, "C", false, 0, "")2322 2423 pdf.SetTextColor(50, 50, 50)25- pdf.SetFont(coverFontFamily, "", 16)24+ pdf.SetFont(coverFontFamily, "", 16+pdfFontSizeDelta)2625 pdf.SetXY(0, 35)2726 pdf.CellFormat(pageW, 8, cfg.CoverSubtitle, "", 0, "C", false, 0, "")2827 2928 bodyY := 49.03029 if header := strings.TrimSpace(cfg.HeaderText); header != "" {31- pdf.SetFont(sansFontFamily, "", 9.2)30+ pdf.SetFont(sansFontFamily, "", 9.2+pdfFontSizeDelta)3231 pdf.SetTextColor(74, 74, 74)3332 wrappedHeader := pdf.SplitLines([]byte(header), contentWidth-20)3433 if len(wrappedHeader) == 0 {
@@ -45,7 +44,7 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
4544 introLines := splitCoverTextLines(pdf, cfg.AdvertText, contentWidth)4645 if len(introLines) > 0 {4746 pdf.SetTextColor(58, 58, 58)48- pdf.SetFont(sansFontFamily, "", 9.3)47+ pdf.SetFont(sansFontFamily, "", 9.3+pdfFontSizeDelta)4948 for _, line := range introLines {5049 pdf.SetXY(margin, bodyY)5150 pdf.CellFormat(contentWidth, 4.8, line, "", 0, "C", false, 0, "")
@@ -55,12 +54,12 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
5554 }5655 5756 pdf.SetTextColor(40, 40, 40)58- pdf.SetFont(sansFontFamily, "B", 9.6)57+ pdf.SetFont(sansFontFamily, "B", 9.6+pdfFontSizeDelta)5958 pdf.SetXY(margin, bodyY)6059 pdf.CellFormat(contentWidth, 5.4, fmt.Sprintf("%d puzzles across %d categories", len(puzzles), len(categoryTotals)), "", 0, "C", false, 0, "")6160 bodyY += 7.66261 63- pdf.SetFont(sansFontFamily, "B", 10)62+ pdf.SetFont(sansFontFamily, "B", 10+pdfFontSizeDelta)6463 pdf.SetTextColor(45, 45, 45)6564 pdf.SetXY(margin, bodyY)6665 pdf.CellFormat(contentWidth, 6, "Inside This Volume", "", 0, "C", false, 0, "")
@@ -69,14 +68,14 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
6968 renderCategoryOverview(pdf, categoryTotals, margin, bodyY, contentWidth, pageH-32)7069 7170 pdf.SetTextColor(50, 50, 50)72- pdf.SetFont(sansFontFamily, "B", 12)71+ pdf.SetFont(sansFontFamily, "B", 12+pdfFontSizeDelta)7372 footerTitleY := pageH - 31.07473 pdf.SetXY(margin, footerTitleY)7574 pdf.CellFormat(contentWidth, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "")7675 7776 colophon := titlePageColophon(docs, cfg.GeneratedAt)7877 if colophon != "" {79- pdf.SetFont(sansFontFamily, "", 7.8)78+ pdf.SetFont(sansFontFamily, "", 7.8+pdfFontSizeDelta)8079 pdf.SetTextColor(112, 112, 112)8180 pdf.SetXY(margin, footerTitleY+8.0)8281 pdf.CellFormat(contentWidth, 4.0, colophon, "", 0, "C", false, 0, "")
@@ -165,7 +164,7 @@ func renderCategoryOverview(
165164 }166165 167166 pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray)168- pdf.SetFont(sansFontFamily, "", 9.0)167+ pdf.SetFont(sansFontFamily, "", 9.0+pdfFontSizeDelta)169168 170169 containerWidth := min(width, min(maxWidth, width*widthScale))171170 containerX := x + (width-containerWidth)/2
M pdfexport/render_tokens.go +30 -5
@@ -9,6 +9,8 @@ import (
99 const (1010 halfLetterWidthMM = 139.71111 halfLetterHeightMM = 215.912+ letterWidthMM = 279.413+ letterHeightMM = 215.91214 1315 footerTextGray = 781416 secondaryTextGray = 60
@@ -29,12 +31,18 @@ const (
2931 outerBorderLineMM = 0.623032 )3133 34+var logicalPageNumberOverride int35+3236 const (33- puzzleTitleFontSize = 13.034- puzzleSubtitleFontSize = 9.035- puzzleInstructionFontSize = 8.236- puzzleWordBankFontSize = 8.837- puzzleWordBankHeadSize = 9.237+ puzzleTitleFontSize = 13.0 + pdfFontSizeDelta38+ puzzleSubtitleFontSize = 9.0 + pdfFontSizeDelta39+ puzzleInstructionFontSize = 7.0 + pdfFontSizeDelta40+ puzzleWordBankFontSize = 8.8 + pdfFontSizeDelta41+ puzzleWordBankHeadSize = 9.2 + pdfFontSizeDelta42+ difficultyStarSizeMM = 3.543+ difficultyStarGapMM = 0.944+ difficultyTextToStarsGapMM = 2.145+ difficultyStarOutlineMM = 0.263846 )3947 4048 type (
@@ -84,6 +92,7 @@ func puzzleBoardRect(pageW, pageH float64, pageNo, ruleLines int) rectMM {
8492 }8593 8694 func puzzleHorizontalMargins(pageNo int) (left, right float64) {95+ pageNo = effectiveLogicalPageNumber(pageNo)8796 left = pageMarginXMM8897 right = pageMarginXMM8998 if pageNo <= 1 {
@@ -99,6 +108,22 @@ func puzzleHorizontalMargins(pageNo int) (left, right float64) {
99108 return left, right100109 }101110 111+func effectiveLogicalPageNumber(pageNo int) int {112+ if logicalPageNumberOverride > 0 {113+ return logicalPageNumberOverride114+ }115+ return pageNo116+}117+118+func withLogicalPageNumber(pageNo int, fn func() error) error {119+ previous := logicalPageNumberOverride120+ logicalPageNumberOverride = pageNo121+ defer func() {122+ logicalPageNumberOverride = previous123+ }()124+ return fn()125+}126+102127 func centeredOrigin(area rectMM, cols, rows int, cellSize float64) (float64, float64) {103128 boardW := float64(cols) * cellSize104129 boardH := float64(rows) * cellSize
M pdfexport/types.go +16 -3
M registry/registry.go +2 -0
M registry/registry_test.go +1 -0
M rippleeffect/print_adapter.go +6 -15
A rippleeffect/testdata/visual_states.jsonl +3 -0

Not shown: this page already shows 6000 lines of diff.

M shikaku/print_adapter.go +6 -15

Not shown: this page already shows 6000 lines of diff.

M shikaku/style.go +2 -2

Not shown: this page already shows 6000 lines of diff.

A shikaku/testdata/visual_states.jsonl +3 -0

Not shown: this page already shows 6000 lines of diff.

M spellpuzzle/print_adapter.go +6 -15

Not shown: this page already shows 6000 lines of diff.

M sudoku/model.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M sudoku/print_adapter.go +6 -15

Not shown: this page already shows 6000 lines of diff.

M sudoku/style.go +5 -2

Not shown: this page already shows 6000 lines of diff.

A sudoku/testdata/visual_states.jsonl +4 -0

Not shown: this page already shows 6000 lines of diff.

M sudokurgb/print_adapter.go +6 -15

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/testdata/visual_states.jsonl +4 -0

Not shown: this page already shows 6000 lines of diff.

M takuzu/print_adapter.go +41 -37

Not shown: this page already shows 6000 lines of diff.

M takuzu/print_adapter_test.go +52 -35

Not shown: this page already shows 6000 lines of diff.

M takuzu/style.go +67 -1

Not shown: this page already shows 6000 lines of diff.

M takuzu/takuzu_test.go +4 -4

Not shown: this page already shows 6000 lines of diff.

A takuzu/testdata/visual_states.jsonl +4 -0

Not shown: this page already shows 6000 lines of diff.

M takuzuplus/print_adapter.go +2 -2

Not shown: this page already shows 6000 lines of diff.

M takuzuplus/style.go +124 -6

Not shown: this page already shows 6000 lines of diff.

M takuzuplus/takuzuplus_test.go +4 -4

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/testdata/visual_states.jsonl +4 -0

Not shown: this page already shows 6000 lines of diff.

A vhs/netwalk.gif +0 -0

Binary file.

A vhs/netwalk.tape +66 -0

Not shown: this page already shows 6000 lines of diff.

M wordsearch/print_adapter.go +8 -3

Not shown: this page already shows 6000 lines of diff.