clone

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

Merge pull request #45 from FelineStateMachine/feat/export-pdf

FelineStateMachine committed e6ca123
Puzzle together
commit
e6ca1238f49c5f1f807a83d2d3b3b3f8e2cd9550
parent
d260f2e00012bf3d2207281fc8b6b2c3d1ca48ed
parent
db27b8913f992160fb3b7f1c7f23eb720387ba10
author
FelineStateMachine <dami.etoile@gmail.com>
50 files changed, +7722 -218
DAGENTS.md216-----
MREADME.md32+
Acmd/export_pdf.go245++++++
Acmd/export_pdf_test.go284+++++++
Mcmd/new.go8+
Acmd/new_export.go224+++++
Acmd/new_export_test.go221+++++
Mcmd/root.go2+-
Agame/print_adapter.go64++
Agame/print_adapter_test.go63++
Mgo.mod3+-
Mgo.sum2+
Ahashiwokakero/PrintAdapter.go140+++
Ahitori/PrintAdapter.go106+++
Anonogram/PrintAdapter.go284+++++++
Anonogram/print_adapter_test.go139+++
Anurikabe/PrintAdapter.go102++
Apdfexport/cover_art.go1241++++++++++++++++++++++++++++++
Apdfexport/cover_art_test.go203+++++
Apdfexport/font.go20+
Apdfexport/font_test.go73++
Apdfexport/fonts.go34+
Apdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttfbin
Apdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt93++
Apdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttfbin
Apdfexport/fonts/DMSerifDisplay-OFL.txt93++
Apdfexport/fonts/DMSerifDisplay-Regular.ttfbin
Apdfexport/jsonl.go152++++
Apdfexport/jsonl_test.go494++++++++++++
Apdfexport/order.go104+++
Apdfexport/order_test.go36+
Apdfexport/parse.go496++++++++++++
Apdfexport/parse_test.go134+++
Apdfexport/printdata.go569++++++++++++++
Apdfexport/printdata_test.go183++++
Apdfexport/render.go169++++
Apdfexport/render_cover.go148++++
Apdfexport/render_cover_test.go29+
Apdfexport/render_error_test.go48+
Apdfexport/render_kit.go92++
Apdfexport/render_layout_test.go143+++
Apdfexport/render_metadata.go17+
Apdfexport/render_title.go284+++++++
Apdfexport/render_tokens.go177++++
Apdfexport/types.go116+++
Ashikaku/PrintAdapter.go102++
Asudoku/PrintAdapter.go112+++
Atakuzu/PrintAdapter.go138+++
Atakuzu/print_adapter_test.go44+
Awordsearch/PrintAdapter.go261++++++
D AGENTS.md +0 -216
@@ -1,216 +0,0 @@
1-# AGENTS.md - PuzzleTea Development Guide2-3-## Build, Test, and Quality Commands4-5-### Build & Run6-```bash7-just # build with version from git tags8-just run # build and run9-just install # install to $GOPATH/bin10-just clean # remove binary and dist/11-```12-Without `just`: `go build -ldflags "-X github.com/FelineStateMachine/puzzletea/cmd.Version=$(git describe --tags --always --dirty)" -o puzzletea`13-14-### CLI Seed Flags15-```bash16-puzzletea new --set-seed myseed # deterministic game/mode/puzzle selection17-puzzletea new nonogram epic --with-seed s1 # deterministic puzzle in selected game/mode18-```19-- `--set-seed` cannot be combined with positional game/mode arguments.20-- `--with-seed` is used with explicit game/mode arguments for mode-local reproducibility.21-22-### Testing23-```bash24-just test # go test ./...25-just test-short # go test -short ./... (skips slow generator tests)26-go test ./nonogram/ # single package27-go test ./sudoku/ -run TestGenerateGrid # specific test28-go test ./resolve/ -run TestCategory -v # specific test, verbose29-```30-31-### Linting & Formatting32-```bash33-just lint # golangci-lint run ./...34-just fmt # gofumpt -w .35-just tidy # go mod tidy36-```37-38-**Always run `just fmt` and `just lint` before committing.**39-40-Enabled linters (`.golangci.yml`): errcheck, gofumpt (extra-rules), gosimple, govet, ineffassign, misspell (US locale), staticcheck, unused.41-42----43-44-## Project Structure45-```46-puzzletea/47-├── main.go # Entry point: wires cmd package48-├── app/ # Root TUI model (Elm architecture)49-│ ├── model.go, update.go, view.go, keys.go, spawn.go, debug.go50-├── cmd/ # CLI commands (Cobra)51-│ ├── root.go, new.go, continue.go, list.go52-├── config/ # Persistent JSON config (~/.puzzletea/config.json)53-├── theme/ # Color theming (WCAG-compliant palettes, contrast utils)54-├── stats/ # XP/level math, streaks, card rendering55-├── game/ # Plugin interfaces, cursor, keys, style, border helpers56-├── store/ # SQLite persistence (~/.puzzletea/history.db)57-├── ui/ # Shared UI: menu list, main menu, table, panel, styles58-├── daily/ # Daily puzzle seeding, RNG, mode selection59-├── resolve/ # CLI argument resolution (category/mode name matching)60-├── namegen/ # Adjective-noun name generator61-├── hashiwokakero/, hitori/, lightsout/, nonogram/62-├── shikaku/, sudoku/, takuzu/, wordsearch/ # Puzzle game packages63-└── vhs/ # VHS tape files for GIF previews64-```65-66-Each puzzle package follows a consistent file structure:67-- **Capitalized**: `Model.go`, `Gamemode.go`, `Export.go`68-- **Lowercase**: `grid.go`, `keys.go`, `style.go`, `generator.go`, `mouse.go`, `<game>_test.go`69-- **Docs**: `help.md` (embedded via `//go:embed`), `README.md`70-71----72-73-## Code Style Guidelines74-75-### Formatting76-- Use `gofumpt` (stricter than gofmt, extra-rules enabled) -- run `just fmt`77-- No comments required unless explaining non-obvious logic78-- Keep lines under ~100 characters79-- US English spelling enforced by misspell linter80-81-### Imports82-Two groups separated by a blank line: stdlib, then everything else (internal + external sorted together). When there are many internal imports, a third group separating internal from external is acceptable.83-```go84-import (85- "errors"86- "fmt"87-88- "github.com/FelineStateMachine/puzzletea/game"89- "charm.land/bubbles/v2/key"90- tea "charm.land/bubbletea/v2"91- "charm.land/lipgloss/v2"92-)93-```94-Note: always alias bubbletea as `tea`.95-96-### Naming Conventions97-- **Types**: PascalCase (`Model`, `NonogramMode`, `Entry`)98-- **Unexported types**: camelCase or lowercase (`grid`, `state`, `menuItem`)99-- **Variables/Fields**: camelCase (`rowHints`, `currentHints`)100-- **Constants**: camelCase (`mainMenuView`, `gameSelectView`)101-- **Interfaces**: PascalCase (`Gamer`, `Spawner`, `Mode`)102-103-### Type Declarations104-Prefer grouped type blocks: `type ( grid [][]rune; state string )`105-106-### Interface Compliance107-Use compile-time checks in grouped var blocks:108-```go109-var (110- _ game.Mode = NonogramMode{}111- _ game.Spawner = NonogramMode{}112- _ game.SeededSpawner = NonogramMode{}113-)114-```115-116-### Error Handling117-- Return descriptive errors: `errors.New("puzzle width does not support row tomography definition")`118-- Check errors immediately; use `fmt.Errorf` with `%w` only when wrapping adds context119-- No assertion libraries in tests -- use `t.Errorf` / `t.Fatalf` only120-121-### Styling122-Use the `theme` package for colors (`theme.Current().Accent`, etc.) and `game/style.go` shared accessors (`CursorFG()`, `CursorBG()`, `ConflictFG()`). Use `compat.AdaptiveColor` from `charm.land/lipgloss/v2/compat` for adaptive light/dark colors.123-124----125-126-## Plugin Architecture127-128-### Gamer Interface (game/gamer.go)129-Every puzzle `Model` must implement:130-```go131-type Gamer interface {132- Init() tea.Cmd133- Update(msg tea.Msg) (Gamer, tea.Cmd)134- View() string135- GetDebugInfo() string136- GetFullHelp() [][]key.Binding137- GetSave() ([]byte, error)138- IsSolved() bool139- Reset() Gamer140- SetTitle(string) Gamer141-}142-```143-144-### Mode/Spawner Interfaces145-```go146-type Spawner interface { Spawn() (Gamer, error) }147-type SeededSpawner interface {148- Spawner149- SpawnSeeded(rng *rand.Rand) (Gamer, error)150-}151-```152-153-Every mode type embeds `game.BaseMode` via `game.NewBaseMode(title, description)`.154-155-### Puzzle Package Exports156-Every puzzle package exports: `Modes`, `DailyModes` (`[]list.Item`), `HelpContent` (`string`, from `//go:embed help.md`), `NewMode(...)`, `New(...)`, `ImportModel([]byte) (*Model, error)`, and `DefaultKeyMap`.157-158-Each package registers itself via `init()` in `Gamemode.go`:159-```go160-func init() {161- game.Register("Nonogram", func(data []byte) (game.Gamer, error) {162- return ImportModel(data)163- })164-}165-```166-167----168-169-## Testing Conventions170-171-### Section Comments with Priority172-```go173-// --- generateTomography (P0) --- // P0 = critical174-// --- Grid serialization (P1) --- // P1 = important175-// --- generateRandomState (P2) --- // P2 = generators/slow176-// --- TitleBarView (P3) --- // P3 = low-priority UI177-```178-179-### Table-Driven Tests with Subtests180-```go181-func TestGenerateTomography(t *testing.T) {182- tests := []struct {183- name string184- grid grid185- wantRows TomographyDefinition186- }{187- {name: "all filled row", grid: grid{...}, wantRows: ...},188- }189- for _, tt := range tests {190- t.Run(tt.name, func(t *testing.T) {191- // test logic using t.Errorf / t.Fatalf only (no assertion libraries)192- })193- }194-}195-```196-197-### Save/Load Round-Trip Pattern198-```go199-data, err := m.GetSave()200-if err != nil { t.Fatal(err) }201-loaded, err := ImportModel(data)202-if err != nil { t.Fatal(err) }203-// verify state preserved204-```205-206-### Slow Test Gating207-```go208-if testing.Short() {209- t.Skip("skipping slow generator test in short mode")210-}211-```212-213----214-215-## Global Keybindings216-`Ctrl+N` Main menu | `Ctrl+C` Quit (saves abandoned) | `Ctrl+E` Debug overlay | `Ctrl+H` Full help | `Ctrl+R` Reset puzzle | `Enter` Select | `Escape` Back
M README.md +32 -0
@@ -106,6 +106,38 @@ puzzletea new --set-seed myseed
106106 puzzletea new nonogram epic --with-seed myseed107107 ```108108 109+Export printable puzzle sets to JSONL:110+111+```bash112+# Stream JSONL to stdout (redirect if desired)113+puzzletea new nonogram mini --export 2 > nonogram-mini-set.jsonl114+115+# Single mode export116+puzzletea new nonogram mini -e 6 -o nonogram-mini-set.jsonl117+118+# Mixed modes within a category (deterministic with --with-seed)119+puzzletea new sudoku --export 10 -o sudoku-mixed.jsonl --with-seed zine-issue-01120+```121+122+Render one or more JSONL packs into a half-letter print PDF:123+124+```bash125+puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01 --volume 1 --title "Catacombs & Pines"126+```127+128+`--title` sets the pack subtitle (title page, and cover pages when enabled), and `--volume` sets the volume number.129+By default, covers are not included. Use `--cover-color` to include front/back cover pages.130+Page count is always auto-padded to a multiple of 4 for half-letter booklet printing.131+132+Font license note (Atkinson Hyperlegible Next):133+134+- Follow the SIL OFL 1.1 requirements in `pdfexport/fonts/OFL.txt`.135+- Do not sell the font files by themselves.136+- If redistributing fonts with software, include the copyright notice and OFL text.137+- Modified font versions must keep OFL terms, and modified names must respect Reserved Font Name rules.138+139+`Lights Out` is currently excluded from export because it does not translate cleanly to paper workflows.140+109141 Override the color theme:110142 111143 ```bash
A cmd/export_pdf.go +245 -0
@@ -0,0 +1,245 @@
1+package cmd2+3+import (4+ "fmt"5+ "path/filepath"6+ "strconv"7+ "strings"8+ "time"9+10+ "github.com/FelineStateMachine/puzzletea/app"11+ "github.com/FelineStateMachine/puzzletea/game"12+ "github.com/FelineStateMachine/puzzletea/pdfexport"13+14+ "github.com/spf13/cobra"15+)16+17+var (18+ flagPDFOutput string19+ flagPDFTitle string20+ flagPDFHeader string21+ flagPDFVolume int22+ flagPDFAdvert string23+ flagPDFShuffleSeed string24+ flagPDFCoverColor string25+)26+27+var exportPDFCmd = &cobra.Command{28+ Use: "export-pdf <input.jsonl> [more.jsonl ...]",29+ Short: "Convert one or more PuzzleTea JSONL exports into a half-letter printable PDF",30+ 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.",31+ Args: cobra.MinimumNArgs(1),32+ RunE: runExportPDF,33+}34+35+func init() {36+ exportPDFCmd.Flags().StringVarP(&flagPDFOutput, "output", "o", "", "write output PDF path (defaults to <first-input>-print.pdf)")37+ exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "subtitle shown on the title page (and on covers when enabled)")38+ exportPDFCmd.Flags().StringVar(&flagPDFHeader, "header", "", "optional intro paragraph shown on the title page under 'PuzzleTea Puzzle Pack'")39+ exportPDFCmd.Flags().IntVar(&flagPDFVolume, "volume", 1, "volume number shown on the title page (and on covers when enabled) (must be >= 1)")40+ exportPDFCmd.Flags().StringVar(&flagPDFAdvert, "advert", "Find more puzzles at github.com/FelineStateMachine/puzzletea", "advert text shown on the title page")41+ exportPDFCmd.Flags().StringVar(&flagPDFShuffleSeed, "shuffle-seed", "", "seed for deterministic within-band difficulty mixing")42+ exportPDFCmd.Flags().StringVar(&flagPDFCoverColor, "cover-color", "", `accent color for optional front/back covers: hex "#RRGGBB" or decimal "R,G,B" (omit for no covers)`)43+}44+45+func runExportPDF(cmd *cobra.Command, args []string) error {46+ docs, err := pdfexport.ParseJSONLFiles(args)47+ if err != nil {48+ return err49+ }50+51+ puzzles := flattenPuzzles(docs)52+ if len(puzzles) == 0 {53+ return nil54+ }55+56+ lookup := buildModeDifficultyLookup(app.Categories)57+ annotateDifficulty(puzzles, lookup)58+59+ shuffleSeed := strings.TrimSpace(flagPDFShuffleSeed)60+ if shuffleSeed == "" {61+ shuffleSeed = time.Now().Format(time.RFC3339Nano)62+ }63+ ordered := pdfexport.OrderPuzzlesForPrint(puzzles, shuffleSeed)64+65+ output := strings.TrimSpace(flagPDFOutput)66+ if output == "" {67+ base := filepath.Base(args[0])68+ output = strings.TrimSuffix(base, filepath.Ext(base)) + "-print.pdf"69+ }70+ if !strings.EqualFold(filepath.Ext(output), ".pdf") {71+ return fmt.Errorf("--output must use a .pdf extension")72+ }73+74+ cfg, err := buildRenderConfigForPDF(docs, shuffleSeed, time.Now())75+ if err != nil {76+ return err77+ }78+ if err := pdfexport.WritePDF(output, docs, ordered, cfg); err != nil {79+ return err80+ }81+82+ _, err = fmt.Fprintf(cmd.OutOrStdout(), "wrote %s with %d puzzles\n", output, len(ordered))83+ return err84+}85+86+func flattenPuzzles(docs []pdfexport.PackDocument) []pdfexport.Puzzle {87+ puzzles := []pdfexport.Puzzle{}88+ for _, doc := range docs {89+ puzzles = append(puzzles, doc.Puzzles...)90+ }91+ return puzzles92+}93+94+func defaultPDFTitle(docs []pdfexport.PackDocument) string {95+ if len(docs) == 1 {96+ category := strings.TrimSpace(docs[0].Metadata.Category)97+ if category != "" {98+ return fmt.Sprintf("%s Puzzle Pack", category)99+ }100+ }101+ return "PuzzleTea Mixed Puzzle Pack"102+}103+104+func validatePDFVolume(volume int) error {105+ if volume < 1 {106+ return fmt.Errorf("--volume must be >= 1")107+ }108+ return nil109+}110+111+func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string, now time.Time) (pdfexport.RenderConfig, error) {112+ if err := validatePDFVolume(flagPDFVolume); err != nil {113+ return pdfexport.RenderConfig{}, err114+ }115+116+ subtitle := strings.TrimSpace(flagPDFTitle)117+ if subtitle == "" {118+ subtitle = defaultPDFTitle(docs)119+ }120+121+ coverColor, err := parseCoverColor(flagPDFCoverColor)122+ if err != nil {123+ return pdfexport.RenderConfig{}, fmt.Errorf("--cover-color: %w", err)124+ }125+126+ cfg := pdfexport.RenderConfig{127+ CoverSubtitle: subtitle,128+ HeaderText: strings.TrimSpace(flagPDFHeader),129+ VolumeNumber: flagPDFVolume,130+ AdvertText: flagPDFAdvert,131+ GeneratedAt: now,132+ ShuffleSeed: shuffleSeed,133+ CoverColor: coverColor,134+ }135+ return cfg, nil136+}137+138+func buildModeDifficultyLookup(categories []game.Category) map[string]map[string]float64 {139+ lookup := make(map[string]map[string]float64, len(categories))140+141+ for _, cat := range categories {142+ titles := []string{}143+ for _, item := range cat.Modes {144+ mode, ok := item.(game.Mode)145+ if !ok {146+ continue147+ }148+ title := strings.TrimSpace(mode.Title())149+ if title == "" {150+ continue151+ }152+ titles = append(titles, title)153+ }154+155+ if len(titles) == 0 {156+ continue157+ }158+159+ scores := make(map[string]float64, len(titles))160+ if len(titles) == 1 {161+ scores[normalizeDifficultyToken(titles[0])] = 0.5162+ } else {163+ for i, title := range titles {164+ scores[normalizeDifficultyToken(title)] = float64(i) / float64(len(titles)-1)165+ }166+ }167+168+ lookup[normalizeDifficultyToken(cat.Name)] = scores169+ }170+171+ return lookup172+}173+174+func annotateDifficulty(puzzles []pdfexport.Puzzle, lookup map[string]map[string]float64) {175+ for i := range puzzles {176+ mode := normalizeDifficultyToken(puzzles[i].ModeSelection)177+ if mode == "" || strings.Contains(mode, "mixed modes") {178+ puzzles[i].DifficultyScore = 0.5179+ puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium180+ puzzles[i].DifficultySource = "mixed-mode fallback"181+ continue182+ }183+184+ category := normalizeDifficultyToken(puzzles[i].Category)185+ modes, ok := lookup[category]186+ if !ok {187+ puzzles[i].DifficultyScore = 0.5188+ puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium189+ puzzles[i].DifficultySource = "category lookup fallback"190+ continue191+ }192+193+ score, ok := modes[mode]194+ if !ok {195+ puzzles[i].DifficultyScore = 0.5196+ puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium197+ puzzles[i].DifficultySource = "mode lookup fallback"198+ continue199+ }200+201+ puzzles[i].DifficultyScore = score202+ puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceHigh203+ puzzles[i].DifficultySource = "mode-order"204+ }205+}206+207+func normalizeDifficultyToken(s string) string {208+ s = strings.ToLower(strings.TrimSpace(s))209+ s = strings.ReplaceAll(s, "-", " ")210+ s = strings.ReplaceAll(s, "_", " ")211+ return strings.Join(strings.Fields(s), " ")212+}213+214+// parseCoverColor parses a cover color string in hex ("#RRGGBB") or215+// decimal ("R,G,B") format. Returns nil if s is empty (no cover pages).216+func parseCoverColor(s string) (*pdfexport.RGB, error) {217+ s = strings.TrimSpace(s)218+ if s == "" {219+ return nil, nil220+ }221+222+ // Hex format: #RRGGBB or RRGGBB223+ hex := strings.TrimPrefix(s, "#")224+ if len(hex) == 6 {225+ r, errR := strconv.ParseUint(hex[0:2], 16, 8)226+ g, errG := strconv.ParseUint(hex[2:4], 16, 8)227+ b, errB := strconv.ParseUint(hex[4:6], 16, 8)228+ if errR == nil && errG == nil && errB == nil {229+ return &pdfexport.RGB{R: uint8(r), G: uint8(g), B: uint8(b)}, nil230+ }231+ }232+233+ // Decimal format: R,G,B234+ parts := strings.Split(s, ",")235+ if len(parts) == 3 {236+ r, errR := strconv.ParseUint(strings.TrimSpace(parts[0]), 10, 8)237+ g, errG := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 8)238+ b, errB := strconv.ParseUint(strings.TrimSpace(parts[2]), 10, 8)239+ if errR == nil && errG == nil && errB == nil {240+ return &pdfexport.RGB{R: uint8(r), G: uint8(g), B: uint8(b)}, nil241+ }242+ }243+244+ return nil, fmt.Errorf("invalid color %q — use hex \"#RRGGBB\" or decimal \"R,G,B\"", s)245+}
A cmd/export_pdf_test.go +284 -0
@@ -0,0 +1,284 @@
1+package cmd2+3+import (4+ "bytes"5+ "encoding/json"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+ "time"11+12+ "github.com/FelineStateMachine/puzzletea/pdfexport"13+14+ "github.com/spf13/cobra"15+)16+17+func TestExportPDFVolumeFlagDefault(t *testing.T) {18+ flag := exportPDFCmd.Flags().Lookup("volume")19+ if flag == nil {20+ t.Fatal("expected --volume flag")21+ }22+ if flag.DefValue != "1" {23+ t.Fatalf("default volume = %q, want %q", flag.DefValue, "1")24+ }25+}26+27+func TestValidatePDFVolume(t *testing.T) {28+ tests := []struct {29+ name string30+ volume int31+ wantOK bool32+ }{33+ {name: "default volume", volume: 1, wantOK: true},34+ {name: "positive volume", volume: 7, wantOK: true},35+ {name: "zero volume rejected", volume: 0, wantOK: false},36+ {name: "negative volume rejected", volume: -3, wantOK: false},37+ }38+39+ for _, tt := range tests {40+ t.Run(tt.name, func(t *testing.T) {41+ err := validatePDFVolume(tt.volume)42+ if tt.wantOK && err != nil {43+ t.Fatalf("validatePDFVolume(%d) error = %v, want nil", tt.volume, err)44+ }45+ if !tt.wantOK {46+ if err == nil {47+ t.Fatalf("validatePDFVolume(%d) expected error", tt.volume)48+ }49+ if !strings.Contains(err.Error(), "--volume") {50+ t.Fatalf("validatePDFVolume(%d) error = %q, want mention of --volume", tt.volume, err.Error())51+ }52+ }53+ })54+ }55+}56+57+func TestBuildRenderConfigForPDFUsesTitleAsCoverSubtitle(t *testing.T) {58+ reset := snapshotExportPDFFlags()59+ defer reset()60+61+ flagPDFTitle = "Catacombs & Pines"62+ flagPDFHeader = "Custom heading paragraph"63+ flagPDFVolume = 764+ flagPDFAdvert = "Custom advert"65+ flagPDFCoverColor = ""66+67+ now := time.Date(2026, 2, 22, 11, 0, 0, 0, time.UTC)68+ docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Nonogram"}}}69+ cfg, err := buildRenderConfigForPDF(docs, "seed-1", now)70+ if err != nil {71+ t.Fatalf("buildRenderConfigForPDF error = %v", err)72+ }73+ if cfg.CoverSubtitle != "Catacombs & Pines" {74+ t.Fatalf("CoverSubtitle = %q, want %q", cfg.CoverSubtitle, "Catacombs & Pines")75+ }76+ if cfg.VolumeNumber != 7 {77+ t.Fatalf("VolumeNumber = %d, want %d", cfg.VolumeNumber, 7)78+ }79+ if cfg.HeaderText != "Custom heading paragraph" {80+ t.Fatalf("HeaderText = %q, want %q", cfg.HeaderText, "Custom heading paragraph")81+ }82+ if cfg.AdvertText != "Custom advert" {83+ t.Fatalf("AdvertText = %q, want %q", cfg.AdvertText, "Custom advert")84+ }85+ if !cfg.GeneratedAt.Equal(now) {86+ t.Fatalf("GeneratedAt = %v, want %v", cfg.GeneratedAt, now)87+ }88+}89+90+func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) {91+ reset := snapshotExportPDFFlags()92+ defer reset()93+94+ flagPDFTitle = ""95+ flagPDFVolume = 196+ flagPDFAdvert = "Find more puzzles"97+ flagPDFCoverColor = ""98+99+ docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Sudoku"}}}100+ cfg, err := buildRenderConfigForPDF(docs, "seed-2", time.Now())101+ if err != nil {102+ t.Fatalf("buildRenderConfigForPDF error = %v", err)103+ }104+ if cfg.CoverSubtitle != "Sudoku Puzzle Pack" {105+ t.Fatalf("CoverSubtitle = %q, want %q", cfg.CoverSubtitle, "Sudoku Puzzle Pack")106+ }107+}108+109+func TestBuildRenderConfigForPDFCoverColorControlsCoverPages(t *testing.T) {110+ reset := snapshotExportPDFFlags()111+ defer reset()112+113+ flagPDFTitle = "Issue 01"114+ flagPDFVolume = 1115+ flagPDFAdvert = "Find more puzzles"116+ docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Sudoku"}}}117+118+ flagPDFCoverColor = ""119+ cfgNoCover, err := buildRenderConfigForPDF(docs, "seed-3", time.Now())120+ if err != nil {121+ t.Fatalf("buildRenderConfigForPDF (no cover color) error = %v", err)122+ }123+ if cfgNoCover.CoverColor != nil {124+ t.Fatalf("CoverColor = %+v, want nil when --cover-color is omitted", cfgNoCover.CoverColor)125+ }126+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")134+ }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)137+ }138+}139+140+func TestRunExportPDFSilentlyNoOpsWhenAllPuzzlesUnsupported(t *testing.T) {141+ reset := snapshotExportPDFFlags()142+ defer reset()143+144+ dir := t.TempDir()145+ input := filepath.Join(dir, "lights.jsonl")146+ output := filepath.Join(dir, "lights.pdf")147+148+ record := pdfexport.JSONLRecord{149+ Schema: pdfexport.ExportSchemaV1,150+ Pack: pdfexport.JSONLPackMeta{151+ Generated: "2026-02-22T10:00:00Z",152+ Version: "v-test",153+ Category: "Lights Out",154+ ModeSelection: "Standard",155+ Count: 1,156+ },157+ Puzzle: pdfexport.JSONLPuzzle{158+ Index: 1,159+ Name: "glow-shore",160+ Game: "Lights Out",161+ Mode: "Standard",162+ Save: json.RawMessage(`{"size":5}`),163+ },164+ }165+ data, err := json.Marshal(record)166+ if err != nil {167+ t.Fatal(err)168+ }169+ if err := os.WriteFile(input, append(data, '\n'), 0o644); err != nil {170+ t.Fatal(err)171+ }172+173+ flagPDFOutput = output174+ flagPDFVolume = 1175+176+ var out bytes.Buffer177+ cmd := &cobra.Command{}178+ cmd.SetOut(&out)179+180+ if err := runExportPDF(cmd, []string{input}); err != nil {181+ t.Fatalf("expected no-op success, got %v", err)182+ }183+ if out.String() != "" {184+ t.Fatalf("expected no stdout output, got %q", out.String())185+ }186+ if _, err := os.Stat(output); !os.IsNotExist(err) {187+ t.Fatalf("expected no output file, stat err = %v", err)188+ }189+}190+191+func TestRunExportPDFSkipsUnsupportedRecordsWhenMixed(t *testing.T) {192+ reset := snapshotExportPDFFlags()193+ defer reset()194+195+ dir := t.TempDir()196+ input := filepath.Join(dir, "mixed.jsonl")197+ output := filepath.Join(dir, "mixed.pdf")198+199+ records := []pdfexport.JSONLRecord{200+ {201+ Schema: pdfexport.ExportSchemaV1,202+ Pack: pdfexport.JSONLPackMeta{203+ Generated: "2026-02-22T10:00:00Z",204+ Version: "v-test",205+ Category: "Lights Out",206+ ModeSelection: "Standard",207+ Count: 2,208+ },209+ Puzzle: pdfexport.JSONLPuzzle{210+ Index: 1,211+ Name: "glow-shore",212+ Game: "Lights Out",213+ Mode: "Standard",214+ Save: json.RawMessage(`{"size":5}`),215+ },216+ },217+ {218+ Schema: pdfexport.ExportSchemaV1,219+ Pack: pdfexport.JSONLPackMeta{220+ Generated: "2026-02-22T10:00:00Z",221+ Version: "v-test",222+ Category: "Sudoku",223+ ModeSelection: "Easy",224+ Count: 2,225+ },226+ Puzzle: pdfexport.JSONLPuzzle{227+ Index: 2,228+ Name: "moss-pine",229+ Game: "Sudoku",230+ Mode: "Easy",231+ Save: json.RawMessage(`{"provided":[{"x":0,"y":0,"v":5}]}`),232+ },233+ },234+ }235+ var lines []byte236+ for _, record := range records {237+ line, err := json.Marshal(record)238+ if err != nil {239+ t.Fatal(err)240+ }241+ lines = append(lines, line...)242+ lines = append(lines, '\n')243+ }244+ if err := os.WriteFile(input, lines, 0o644); err != nil {245+ t.Fatal(err)246+ }247+248+ flagPDFOutput = output249+ flagPDFVolume = 1250+251+ cmd := &cobra.Command{}252+ cmd.SetOut(&bytes.Buffer{})253+ if err := runExportPDF(cmd, []string{input}); err != nil {254+ t.Fatalf("expected mixed export success, got %v", err)255+ }256+257+ info, err := os.Stat(output)258+ if err != nil {259+ t.Fatalf("expected output file, got stat error: %v", err)260+ }261+ if info.Size() == 0 {262+ t.Fatal("expected non-empty output PDF")263+ }264+}265+266+func snapshotExportPDFFlags() func() {267+ oldTitle := flagPDFTitle268+ oldHeader := flagPDFHeader269+ oldVolume := flagPDFVolume270+ oldAdvert := flagPDFAdvert271+ oldCoverColor := flagPDFCoverColor272+ oldOutput := flagPDFOutput273+ oldShuffle := flagPDFShuffleSeed274+275+ return func() {276+ flagPDFTitle = oldTitle277+ flagPDFHeader = oldHeader278+ flagPDFVolume = oldVolume279+ flagPDFAdvert = oldAdvert280+ flagPDFCoverColor = oldCoverColor281+ flagPDFOutput = oldOutput282+ flagPDFShuffleSeed = oldShuffle283+ }284+}
M cmd/new.go +8 -0
A cmd/new_export.go +224 -0
@@ -0,0 +1,224 @@
1+package cmd2+3+import (4+ "encoding/json"5+ "fmt"6+ "io"7+ "math/rand/v2"8+ "os"9+ "path/filepath"10+ "strings"11+ "time"12+13+ "github.com/FelineStateMachine/puzzletea/app"14+ "github.com/FelineStateMachine/puzzletea/game"15+ "github.com/FelineStateMachine/puzzletea/namegen"16+ "github.com/FelineStateMachine/puzzletea/pdfexport"17+ "github.com/FelineStateMachine/puzzletea/resolve"18+19+ "github.com/spf13/cobra"20+)21+22+var exportNow = time.Now23+24+type exportModeEntry struct {25+ spawner game.Spawner26+ mode string27+}28+29+func runNewExport(cmd *cobra.Command, args []string) error {30+ if err := validateNewExportFlags(cmd, args); err != nil {31+ return err32+ }33+34+ cat, err := resolve.Category(args[0], app.GameCategories)35+ if err != nil {36+ return err37+ }38+ if !game.HasPrintAdapter(cat.Name) {39+ return nil40+ }41+42+ modeArg := ""43+ if len(args) > 1 {44+ modeArg = args[1]45+ }46+47+ entries, modeSelection, err := collectExportModes(cat, modeArg)48+ if err != nil {49+ return err50+ }51+52+ generatedAt := exportNow()53+ records, err := buildExportRecords(cat.Name, modeSelection, entries, flagExport, flagWithSeed, generatedAt)54+ if err != nil {55+ return err56+ }57+58+ if err := writeExportJSONL(cmd, flagOutput, records); err != nil {59+ return err60+ }61+62+ return nil63+}64+65+func validateNewExportFlags(cmd *cobra.Command, args []string) error {66+ if flagExport < 1 {67+ return fmt.Errorf("--export must be at least 1")68+ }69+ if strings.TrimSpace(flagOutput) != "" && !strings.EqualFold(filepath.Ext(flagOutput), ".jsonl") {70+ return fmt.Errorf("--output must use a .jsonl extension")71+ }72+ if flagSetSeed != "" {73+ return fmt.Errorf("--set-seed cannot be combined with export (--export/--output)")74+ }75+ if len(args) == 0 {76+ return fmt.Errorf("requires at least 1 arg(s), only received 0")77+ }78+ if len(args) > 2 {79+ return fmt.Errorf("accepts at most 2 arg(s), received %d", len(args))80+ }81+ return nil82+}83+84+func collectExportModes(cat game.Category, modeArg string) ([]exportModeEntry, string, error) {85+ if modeArg != "" {86+ spawner, modeTitle, err := resolve.Mode(cat, modeArg)87+ if err != nil {88+ return nil, "", err89+ }90+ return []exportModeEntry{{spawner: spawner, mode: modeTitle}}, modeTitle, nil91+ }92+93+ entries := make([]exportModeEntry, 0, len(cat.Modes))94+ for _, item := range cat.Modes {95+ mode, ok := item.(game.Mode)96+ if !ok {97+ continue98+ }99+ spawner, ok := item.(game.Spawner)100+ if !ok {101+ continue102+ }103+ entries = append(entries, exportModeEntry{104+ spawner: spawner,105+ mode: mode.Title(),106+ })107+ }108+ if len(entries) == 0 {109+ return nil, "", fmt.Errorf("game %q has no exportable modes", cat.Name)110+ }111+112+ return entries, "mixed modes", nil113+}114+115+func buildExportRecords(116+ gameType, modeSelection string,117+ entries []exportModeEntry,118+ count int,119+ seed string,120+ generatedAt time.Time,121+) ([]pdfexport.JSONLRecord, error) {122+ var rng *rand.Rand123+ if seed != "" {124+ rng = resolve.RNGFromString(seed)125+ }126+127+ nameSeed := seed128+ if strings.TrimSpace(nameSeed) == "" {129+ nameSeed = generatedAt.Format(time.RFC3339Nano)130+ }131+ nameRNG := resolve.RNGFromString("export-names:" + nameSeed)132+133+ records := make([]pdfexport.JSONLRecord, 0, count)134+ for i := 0; i < count; i++ {135+ entry := entries[0]136+ if len(entries) > 1 {137+ var modeIndex int138+ if rng != nil {139+ modeIndex = rng.IntN(len(entries))140+ } else {141+ modeIndex = rand.IntN(len(entries))142+ }143+ entry = entries[modeIndex]144+ }145+146+ puzzle, err := spawnExportPuzzle(entry.spawner, rng)147+ if err != nil {148+ return nil, fmt.Errorf("generate puzzle %d: %w", i+1, err)149+ }150+151+ save, err := puzzle.GetSave()152+ if err != nil {153+ return nil, fmt.Errorf("serialize puzzle %d: %w", i+1, err)154+ }155+ if !json.Valid(save) {156+ return nil, fmt.Errorf("serialize puzzle %d: save payload is not valid JSON", i+1)157+ }158+159+ records = append(records, pdfexport.JSONLRecord{160+ Schema: pdfexport.ExportSchemaV1,161+ Pack: pdfexport.JSONLPackMeta{162+ Generated: generatedAt.Format(time.RFC3339),163+ Version: Version,164+ Category: gameType,165+ ModeSelection: modeSelection,166+ Count: count,167+ Seed: seed,168+ },169+ Puzzle: pdfexport.JSONLPuzzle{170+ Index: i + 1,171+ Name: namegen.GenerateSeeded(nameRNG),172+ Game: gameType,173+ Mode: entry.mode,174+ Save: json.RawMessage(save),175+ },176+ })177+ }178+179+ return records, nil180+}181+182+func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error) {183+ if rng == nil {184+ return spawner.Spawn()185+ }186+187+ seeded, ok := spawner.(game.SeededSpawner)188+ if !ok {189+ return nil, fmt.Errorf("mode does not support deterministic spawning")190+ }191+ return seeded.SpawnSeeded(rng)192+}193+194+func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error {195+ var b strings.Builder196+ for _, record := range records {197+ data, err := json.Marshal(record)198+ if err != nil {199+ return fmt.Errorf("encode jsonl record: %w", err)200+ }201+ b.Write(data)202+ b.WriteByte('\n')203+ }204+205+ content := b.String()206+ if strings.TrimSpace(path) == "" {207+ if _, err := io.WriteString(cmd.OutOrStdout(), content); err != nil {208+ return fmt.Errorf("write export jsonl to stdout: %w", err)209+ }210+ return nil211+ }212+213+ dir := filepath.Dir(path)214+ if dir != "" && dir != "." {215+ if err := os.MkdirAll(dir, 0o755); err != nil {216+ return fmt.Errorf("create output directory: %w", err)217+ }218+ }219+220+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {221+ return fmt.Errorf("write output jsonl: %w", err)222+ }223+ return nil224+}
A cmd/new_export_test.go +221 -0
@@ -0,0 +1,221 @@
1+package cmd2+3+import (4+ "bytes"5+ "encoding/json"6+ "os"7+ "path/filepath"8+ "strconv"9+ "strings"10+ "testing"11+ "time"12+13+ "github.com/FelineStateMachine/puzzletea/pdfexport"14+15+ "github.com/spf13/cobra"16+)17+18+func TestRunNewExportRejectsUnsupportedGame(t *testing.T) {19+ withExportFlagReset(t)20+ output := filepath.Join(t.TempDir(), "lights.jsonl")21+ flagOutput = output22+23+ cmd, _ := newExportTestCmd(t, false)24+ err := runNewExport(cmd, []string{"lights-out"})25+ if err != nil {26+ t.Fatalf("expected silent no-op for unsupported game, got: %v", err)27+ }28+ if _, statErr := os.Stat(output); !os.IsNotExist(statErr) {29+ t.Fatalf("expected no output file, stat err = %v", statErr)30+ }31+}32+33+func TestRunNewExportValidation(t *testing.T) {34+ t.Run("writes jsonl to stdout when output omitted", func(t *testing.T) {35+ withExportFlagReset(t)36+ flagExport = 237+38+ cmd, out := newExportTestCmd(t, true)39+ err := runNewExport(cmd, []string{"nonogram", "mini"})40+ if err != nil {41+ t.Fatalf("expected stdout export success, got error: %v", err)42+ }43+44+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")45+ if got, want := len(lines), 2; got != want {46+ t.Fatalf("jsonl lines = %d, want %d", got, want)47+ }48+ for i, line := range lines {49+ var record pdfexport.JSONLRecord50+ if err := json.Unmarshal([]byte(line), &record); err != nil {51+ t.Fatalf("line %d is not valid jsonl: %v", i+1, err)52+ }53+ if record.Schema != pdfexport.ExportSchemaV1 {54+ t.Fatalf("line %d schema = %q, want %q", i+1, record.Schema, pdfexport.ExportSchemaV1)55+ }56+ }57+ })58+59+ t.Run("output extension must be jsonl", func(t *testing.T) {60+ withExportFlagReset(t)61+ flagOutput = filepath.Join(t.TempDir(), "out.txt")62+63+ cmd, _ := newExportTestCmd(t, false)64+ err := runNewExport(cmd, []string{"nonogram", "mini"})65+ if err == nil {66+ t.Fatal("expected extension validation error")67+ }68+ if !strings.Contains(err.Error(), ".jsonl extension") {69+ t.Fatalf("unexpected error: %v", err)70+ }71+ })72+73+ t.Run("set-seed cannot be combined with output", func(t *testing.T) {74+ withExportFlagReset(t)75+ flagSetSeed = "abc"76+ flagOutput = filepath.Join(t.TempDir(), "out.jsonl")77+78+ cmd, _ := newExportTestCmd(t, false)79+ err := runNewExport(cmd, []string{"nonogram", "mini"})80+ if err == nil {81+ t.Fatal("expected set-seed validation error")82+ }83+ if !strings.Contains(err.Error(), "--set-seed cannot be combined with export (--export/--output)") {84+ t.Fatalf("unexpected error: %v", err)85+ }86+ })87+}88+89+func TestRunNewExportReproducibleWithSeed(t *testing.T) {90+ withExportFlagReset(t)91+92+ fixedNow := time.Date(2026, 2, 22, 11, 0, 0, 0, time.UTC)93+ previousNow := exportNow94+ exportNow = func() time.Time { return fixedNow }95+ t.Cleanup(func() { exportNow = previousNow })96+97+ fileA := filepath.Join(t.TempDir(), "a.jsonl")98+ fileB := filepath.Join(t.TempDir(), "b.jsonl")99+100+ flagExport = 3101+ flagWithSeed = "zine-seed-01"102+ flagOutput = fileA103+ cmdA, _ := newExportTestCmd(t, false)104+ if err := runNewExport(cmdA, []string{"nonogram", "mini"}); err != nil {105+ t.Fatalf("first export failed: %v", err)106+ }107+108+ flagOutput = fileB109+ cmdB, _ := newExportTestCmd(t, false)110+ if err := runNewExport(cmdB, []string{"nonogram", "mini"}); err != nil {111+ t.Fatalf("second export failed: %v", err)112+ }113+114+ a, err := os.ReadFile(fileA)115+ if err != nil {116+ t.Fatal(err)117+ }118+ b, err := os.ReadFile(fileB)119+ if err != nil {120+ t.Fatal(err)121+ }122+ if string(a) != string(b) {123+ t.Fatal("expected deterministic jsonl output for identical seed and args")124+ }125+}126+127+func TestRunNewExportOverwritesOutputFile(t *testing.T) {128+ withExportFlagReset(t)129+130+ file := filepath.Join(t.TempDir(), "out.jsonl")131+ if err := os.WriteFile(file, []byte("old"), 0o644); err != nil {132+ t.Fatal(err)133+ }134+135+ flagExport = 1136+ flagWithSeed = "overwrite-seed"137+ flagOutput = file138+139+ cmd, _ := newExportTestCmd(t, false)140+ if err := runNewExport(cmd, []string{"nonogram", "mini"}); err != nil {141+ t.Fatalf("export failed: %v", err)142+ }143+144+ data, err := os.ReadFile(file)145+ if err != nil {146+ t.Fatal(err)147+ }148+ if string(data) == "old" {149+ t.Fatal("expected output file to be overwritten")150+ }151+ if !strings.Contains(string(data), pdfexport.ExportSchemaV1) {152+ t.Fatal("expected jsonl export schema marker")153+ }154+}155+156+func TestRunNewExportOmitsPrintPayload(t *testing.T) {157+ withExportFlagReset(t)158+ flagExport = 1159+160+ cmd, out := newExportTestCmd(t, true)161+ if err := runNewExport(cmd, []string{"sudoku", "easy"}); err != nil {162+ t.Fatalf("expected sudoku export success, got error: %v", err)163+ }164+165+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")166+ if len(lines) != 1 {167+ t.Fatalf("jsonl lines = %d, want 1", len(lines))168+ }169+170+ var record map[string]any171+ if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {172+ t.Fatal(err)173+ }174+ if _, ok := record["print"]; ok {175+ t.Fatal("did not expect print payload in export record")176+ }177+178+ puzzle, ok := record["puzzle"].(map[string]any)179+ if !ok {180+ t.Fatalf("expected puzzle object, got %T", record["puzzle"])181+ }182+ if _, ok := puzzle["snippet"]; ok {183+ t.Fatal("did not expect markdown snippet in export record")184+ }185+}186+187+func withExportFlagReset(t *testing.T) {188+ t.Helper()189+190+ prevSetSeed := flagSetSeed191+ prevWithSeed := flagWithSeed192+ prevExport := flagExport193+ prevOutput := flagOutput194+195+ flagSetSeed = ""196+ flagWithSeed = ""197+ flagExport = 1198+ flagOutput = ""199+200+ t.Cleanup(func() {201+ flagSetSeed = prevSetSeed202+ flagWithSeed = prevWithSeed203+ flagExport = prevExport204+ flagOutput = prevOutput205+ })206+}207+208+func newExportTestCmd(t *testing.T, exportChanged bool) (*cobra.Command, *bytes.Buffer) {209+ t.Helper()210+211+ cmd := &cobra.Command{}212+ var out bytes.Buffer213+ cmd.SetOut(&out)214+ cmd.Flags().Int("export", 1, "")215+ if exportChanged {216+ if err := cmd.Flags().Set("export", strconv.Itoa(flagExport)); err != nil {217+ t.Fatal(err)218+ }219+ }220+ return cmd, &out221+}
M cmd/root.go +1 -1
A game/print_adapter.go +64 -0
@@ -0,0 +1,64 @@
1+package game2+3+import (4+ "reflect"5+ "strings"6+7+ "codeberg.org/go-pdf/fpdf"8+)9+10+type PrintAdapter interface {11+ CanonicalGameType() string12+ Aliases() []string13+ BuildPDFPayload(save []byte) (any, error)14+ RenderPDFBody(pdf *fpdf.Fpdf, payload any) error15+}16+17+var printAdapterRegistry = map[string]PrintAdapter{}18+19+func RegisterPrintAdapter(adapter PrintAdapter) {20+ canonical := normalizeGameTypeToken(adapter.CanonicalGameType())21+ if canonical == "" {22+ return23+ }24+25+ printAdapterRegistry[canonical] = adapter26+ for _, alias := range adapter.Aliases() {27+ normalized := normalizeGameTypeToken(alias)28+ if normalized == "" {29+ continue30+ }31+ printAdapterRegistry[normalized] = adapter32+ }33+}34+35+func LookupPrintAdapter(gameType string) (PrintAdapter, bool) {36+ adapter, ok := printAdapterRegistry[normalizeGameTypeToken(gameType)]37+ return adapter, ok38+}39+40+func HasPrintAdapter(gameType string) bool {41+ _, ok := LookupPrintAdapter(gameType)42+ return ok43+}44+45+func IsNilPrintPayload(payload any) bool {46+ if payload == nil {47+ return true48+ }49+50+ v := reflect.ValueOf(payload)51+ switch v.Kind() {52+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:53+ return v.IsNil()54+ default:55+ return false56+ }57+}58+59+func normalizeGameTypeToken(s string) string {60+ s = strings.ToLower(strings.TrimSpace(s))61+ s = strings.ReplaceAll(s, "-", " ")62+ s = strings.ReplaceAll(s, "_", " ")63+ return strings.Join(strings.Fields(s), " ")64+}
A game/print_adapter_test.go +63 -0
@@ -0,0 +1,63 @@
1+package game2+3+import (4+ "testing"5+6+ "codeberg.org/go-pdf/fpdf"7+)8+9+type testPrintAdapter struct {10+ canonical string11+ aliases []string12+}13+14+func (a testPrintAdapter) CanonicalGameType() string { return a.canonical }15+func (a testPrintAdapter) Aliases() []string { return a.aliases }16+func (a testPrintAdapter) BuildPDFPayload([]byte) (any, error) { return nil, nil }17+func (a testPrintAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil }18+19+func TestPrintAdapterRegistryLookupAndAliases(t *testing.T) {20+ original := printAdapterRegistry21+ printAdapterRegistry = map[string]PrintAdapter{}22+ t.Cleanup(func() { printAdapterRegistry = original })23+24+ adapter := testPrintAdapter{25+ canonical: "Word Search",26+ aliases: []string{"wordsearch", "word-search"},27+ }28+ RegisterPrintAdapter(adapter)29+30+ if !HasPrintAdapter("Word Search") {31+ t.Fatal("expected canonical lookup to be supported")32+ }33+ if !HasPrintAdapter("word_search") {34+ t.Fatal("expected underscore alias lookup to be supported")35+ }36+ if !HasPrintAdapter("wordsearch") {37+ t.Fatal("expected compact alias lookup to be supported")38+ }39+ if HasPrintAdapter("lights out") {40+ t.Fatal("expected unknown type to be unsupported")41+ }42+}43+44+func TestRegisterPrintAdapterSkipsBlankCanonical(t *testing.T) {45+ original := printAdapterRegistry46+ printAdapterRegistry = map[string]PrintAdapter{}47+ t.Cleanup(func() { printAdapterRegistry = original })48+49+ RegisterPrintAdapter(testPrintAdapter{canonical: " "})50+ if len(printAdapterRegistry) != 0 {51+ t.Fatalf("registry size = %d, want 0", len(printAdapterRegistry))52+ }53+}54+55+func TestIsNilPrintPayload(t *testing.T) {56+ var ptr *int57+ if !IsNilPrintPayload(ptr) {58+ t.Fatal("expected typed nil pointer to be treated as nil payload")59+ }60+ if IsNilPrintPayload(5) {61+ t.Fatal("expected concrete payload to be non-nil")62+ }63+}
M go.mod +2 -1
M go.sum +2 -0
A hashiwokakero/PrintAdapter.go +140 -0
@@ -0,0 +1,140 @@
1+package hashiwokakero2+3+import (4+ "math"5+ "strconv"6+ "unicode/utf8"7+8+ "codeberg.org/go-pdf/fpdf"9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/pdfexport"11+)12+13+type printAdapter struct{}14+15+func (printAdapter) CanonicalGameType() string { return "Hashiwokakero" }16+func (printAdapter) Aliases() []string {17+ return []string{"hashi", "hashiwokakero", "hashi wokakero"}18+}19+20+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {21+ return pdfexport.ParseHashiPrintData(save)22+}23+24+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {25+ switch data := payload.(type) {26+ case *pdfexport.HashiData:27+ renderHashiPage(pdf, data)28+ }29+ return nil30+}31+32+func renderHashiPage(pdf *fpdf.Fpdf, data *pdfexport.HashiData) {33+ if data == nil || data.Width <= 0 || data.Height <= 0 {34+ return35+ }36+37+ pageW, pageH := pdf.GetPageSize()38+ pageNo := pdf.PageNo()39+40+ spanX := max(data.Width-1, 1)41+ spanY := max(data.Height-1, 1)42+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)43+ step := pdfexport.FitHashiCellSize(spanX, spanY, area)44+ if step <= 0 {45+ return46+ }47+48+ boardW := float64(spanX) * step49+ boardH := float64(spanY) * step50+ originX, originY := pdfexport.CenteredOrigin(area, spanX, spanY, step)51+ islandRadius := hashiIslandRadius(step)52+53+ drawHashiGuideDots(pdf, originX, originY, data.Width, data.Height, step)54+ drawHashiBoardBorder(pdf, originX, originY, boardW, boardH, islandRadius)55+ drawHashiIslands(pdf, originX, originY, step, islandRadius, data.Islands)56+57+ ruleY := pdfexport.InstructionY(originY+boardH+pdfexport.InstructionLineHMM, pageH, 1)58+ pdfexport.SetInstructionStyle(pdf)59+ pdf.SetXY(area.X, ruleY)60+ pdf.CellFormat(61+ area.W,62+ pdfexport.InstructionLineHMM,63+ "Connect islands horizontally/vertically with up to two bridges and no crossings.",64+ "",65+ 0,66+ "C",67+ false,68+ 0,69+ "",70+ )71+}72+73+func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height int, step float64) {74+ pdf.SetFillColor(230, 230, 230)75+ r := math.Max(0.20, math.Min(0.55, step*0.035))76+ for y := 0; y < height; y++ {77+ for x := 0; x < width; x++ {78+ cx := originX + float64(x)*step79+ cy := originY + float64(y)*step80+ pdf.Circle(cx, cy, r, "F")81+ }82+ }83+}84+85+func drawHashiBoardBorder(pdf *fpdf.Fpdf, originX, originY, boardW, boardH, islandRadius float64) {86+ if boardW <= 0 || boardH <= 0 {87+ return88+ }89+ borderPad := islandRadius + 1.290+ pdf.SetDrawColor(35, 35, 35)91+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)92+ pdf.Rect(originX-borderPad, originY-borderPad, boardW+2*borderPad, boardH+2*borderPad, "D")93+}94+95+func drawHashiIslands(96+ pdf *fpdf.Fpdf,97+ originX,98+ originY,99+ step,100+ radius float64,101+ islands []pdfexport.HashiIsland,102+) {103+ pdf.SetDrawColor(20, 20, 20)104+ pdf.SetFillColor(255, 255, 255)105+ pdf.SetLineWidth(pdfexport.MajorGridLineMM)106+107+ for _, island := range islands {108+ cx := originX + float64(island.X)*step109+ cy := originY + float64(island.Y)*step110+ pdf.Circle(cx, cy, radius, "DF")111+ drawHashiIslandNumber(pdf, cx, cy, radius, island.Required)112+ }113+}114+115+func hashiIslandRadius(step float64) float64 {116+ return math.Max(1.4, math.Min(3.2, step*0.23))117+}118+119+func drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int) {120+ text := strconv.Itoa(required)121+ fontSize := pdfexport.StandardCellFontSize(radius*2.0, 0.95)122+ runeCount := utf8.RuneCountInString(text)123+ switch {124+ case runeCount >= 3:125+ fontSize *= 0.70126+ case runeCount == 2:127+ fontSize *= 0.82128+ }129+ fontSize = pdfexport.ClampStandardCellFontSize(fontSize)130+131+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)132+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)133+ lineH := fontSize * 0.88134+ pdf.SetXY(cx-radius, cy-lineH/2)135+ pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "")136+}137+138+func init() {139+ game.RegisterPrintAdapter(printAdapter{})140+}
A hitori/PrintAdapter.go +106 -0
@@ -0,0 +1,106 @@
1+package hitori2+3+import (4+ "strings"5+ "unicode/utf8"6+7+ "codeberg.org/go-pdf/fpdf"8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/pdfexport"10+)11+12+type printAdapter struct{}13+14+func (printAdapter) CanonicalGameType() string { return "Hitori" }15+func (printAdapter) Aliases() []string { return []string{"hitori"} }16+17+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {18+ return pdfexport.ParseHitoriPrintData(save)19+}20+21+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {22+ switch data := payload.(type) {23+ case *pdfexport.HitoriData:24+ renderHitoriPage(pdf, data)25+ }26+ return nil27+}28+29+func renderHitoriPage(pdf *fpdf.Fpdf, data *pdfexport.HitoriData) {30+ if data == nil || data.Size <= 0 {31+ return32+ }33+34+ size := data.Size35+ pageW, pageH := pdf.GetPageSize()36+ pageNo := pdf.PageNo()37+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)38+ cellSize := pdfexport.FitCompactCellSize(size, size, area)39+ if cellSize <= 0 {40+ return41+ }42+43+ blockW := float64(size) * cellSize44+ blockH := float64(size) * cellSize45+ startX, startY := pdfexport.CenteredOrigin(area, size, size, cellSize)46+47+ pdf.SetDrawColor(55, 55, 55)48+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)49+ for y := 0; y < size; y++ {50+ for x := 0; x < size; x++ {51+ cellX := startX + float64(x)*cellSize52+ cellY := startY + float64(y)*cellSize53+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")54+55+ text := ""56+ if y < len(data.Numbers) && x < len(data.Numbers[y]) {57+ text = strings.TrimSpace(data.Numbers[y][x])58+ }59+ if text == "" || text == "." {60+ continue61+ }62+ drawHitoriCellNumber(pdf, cellX, cellY, cellSize, text)63+ }64+ }65+66+ pdf.SetDrawColor(35, 35, 35)67+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)68+ pdf.Rect(startX, startY, blockW, blockH, "D")69+70+ ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1)71+ pdfexport.SetInstructionStyle(pdf)72+ pdf.SetXY(area.X, ruleY)73+ pdf.CellFormat(74+ area.W,75+ pdfexport.InstructionLineHMM,76+ "Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected.",77+ "",78+ 0,79+ "C",80+ false,81+ 0,82+ "",83+ )84+}85+86+func drawHitoriCellNumber(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) {87+ fontSize := pdfexport.StandardCellFontSize(cellSize, 0.58)88+ runeCount := utf8.RuneCountInString(text)89+ switch {90+ case runeCount >= 3:91+ fontSize *= 0.792+ case runeCount == 2:93+ fontSize *= 0.8294+ }95+ fontSize = pdfexport.ClampStandardCellFontSize(fontSize)96+97+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)98+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)99+ lineH := fontSize * 0.92100+ pdf.SetXY(x, y+(cellSize-lineH)/2)101+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")102+}103+104+func init() {105+ game.RegisterPrintAdapter(printAdapter{})106+}
A nonogram/PrintAdapter.go +284 -0
@@ -0,0 +1,284 @@
1+package nonogram2+3+import (4+ "strconv"5+ "strings"6+7+ "codeberg.org/go-pdf/fpdf"8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/pdfexport"10+)11+12+type printAdapter struct{}13+14+func (printAdapter) CanonicalGameType() string { return "Nonogram" }15+func (printAdapter) Aliases() []string { return []string{"nonogram"} }16+17+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {18+ return pdfexport.ParseNonogramPrintData(save)19+}20+21+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {22+ switch data := payload.(type) {23+ case *pdfexport.NonogramData:24+ renderNonogramPage(pdf, data)25+ }26+ return nil27+}28+29+func renderNonogramPage(pdf *fpdf.Fpdf, data *pdfexport.NonogramData) {30+ if data == nil || data.Width <= 0 || data.Height <= 0 {31+ return32+ }33+34+ pageW, pageH := pdf.GetPageSize()35+ pageNo := pdf.PageNo()36+37+ rowHints := normalizeNonogramHintsForRender(data.RowHints, data.Height)38+ colHints := normalizeNonogramHintsForRender(data.ColHints, data.Width)39+40+ rowHintCols := maxHintDepth(rowHints)41+ colHintRows := maxHintDepth(colHints)42+ if rowHintCols < 1 {43+ rowHintCols = 144+ }45+ if colHintRows < 1 {46+ colHintRows = 147+ }48+49+ layout := layoutNonogram(50+ pageW,51+ pageH,52+ pageNo,53+ data.Width,54+ data.Height,55+ rowHintCols,56+ colHintRows,57+ )58+ cellSize := layout.cellSize59+ if cellSize <= 0 {60+ return61+ }62+63+ gridW := float64(data.Width) * cellSize64+ gridH := float64(data.Height) * cellSize65+ startX := layout.hintStartX66+ startY := layout.hintStartY67+ xSep := layout.gridX68+ ySep := layout.gridY69+70+ for row := 0; row < colHintRows; row++ {71+ for col := 0; col < data.Width; col++ {72+ cellX := xSep + float64(col)*cellSize73+ cellY := startY + float64(row)*cellSize74+ if text := colHintText(colHints[col], colHintRows, row); text != "" {75+ drawNonogramHintText(pdf, cellX, cellY, cellSize, cellSize, text)76+ }77+ }78+ }79+80+ for row := 0; row < data.Height; row++ {81+ for col := 0; col < rowHintCols; col++ {82+ cellX := startX + float64(col)*cellSize83+ cellY := ySep + float64(row)*cellSize84+ if text := rowHintText(rowHints[row], rowHintCols, col); text != "" {85+ drawNonogramHintText(pdf, cellX, cellY, cellSize, cellSize, text)86+ }87+ }88+ }89+90+ drawNonogramPuzzleGrid(pdf, xSep, ySep, data.Width, data.Height, cellSize)91+ drawNonogramMajorLines(pdf, xSep, ySep, cellSize, data.Width, data.Height, 5)92+93+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)94+ pdf.Rect(xSep, ySep, gridW, gridH, "D")95+96+ ruleY := ySep + gridH + 3.597+ ruleY = pdfexport.InstructionY(ruleY-3.5, pageH, 1)98+ body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo)99+ pdfexport.SetInstructionStyle(pdf)100+ pdf.SetXY(body.X, ruleY)101+ pdf.CellFormat(102+ body.W,103+ pdfexport.InstructionLineHMM,104+ "Use row/column hints to fill blocks in order; groups are separated by at least one blank cell.",105+ "",106+ 0,107+ "C",108+ false,109+ 0,110+ "",111+ )112+}113+114+func drawNonogramPuzzleGrid(115+ pdf *fpdf.Fpdf,116+ startX,117+ startY float64,118+ width,119+ height int,120+ cellSize float64,121+) {122+ if width <= 0 || height <= 0 || cellSize <= 0 {123+ return124+ }125+126+ gridW := float64(width) * cellSize127+ gridH := float64(height) * cellSize128+129+ pdf.SetDrawColor(45, 45, 45)130+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)131+ for col := 0; col <= width; col++ {132+ x := startX + float64(col)*cellSize133+ pdf.Line(x, startY, x, startY+gridH)134+ }135+ for row := 0; row <= height; row++ {136+ y := startY + float64(row)*cellSize137+ pdf.Line(startX, y, startX+gridW, y)138+ }139+}140+141+func drawNonogramHintText(pdf *fpdf.Fpdf, x, y, w, h float64, text string) {142+ if strings.TrimSpace(text) == "" {143+ return144+ }145+146+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)147+ fontSize := pdfexport.StandardCellFontSize(h, 0.70)148+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)149+ lineH := fontSize * 0.86150+ pdf.SetXY(x, y+(h-lineH)/2)151+ pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "")152+}153+154+type nonogramLayout struct {155+ cellSize float64156+ hintStartX float64157+ hintStartY float64158+ gridX float64159+ gridY float64160+}161+162+func layoutNonogram(163+ pageW,164+ pageH float64,165+ pageNo,166+ gridCols,167+ gridRows,168+ rowHintCols,169+ colHintRows int,170+) nonogramLayout {171+ totalCols := rowHintCols + gridCols172+ totalRows := colHintRows + gridRows173+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)174+ cellSize := pdfexport.FitNonogramCellSize(totalCols, totalRows, area)175+ if cellSize <= 0 {176+ return nonogramLayout{}177+ }178+179+ if rowHintCols > 0 {180+ centeredCapW := area.W / float64(gridCols+2*rowHintCols)181+ if centeredCapW > 0 && centeredCapW < cellSize {182+ cellSize = centeredCapW183+ }184+ }185+ if colHintRows > 0 {186+ centeredCapH := area.H / float64(gridRows+2*colHintRows)187+ if centeredCapH > 0 && centeredCapH < cellSize {188+ cellSize = centeredCapH189+ }190+ }191+192+ gridW := float64(gridCols) * cellSize193+ gridH := float64(gridRows) * cellSize194+ gridX := area.X + (area.W-gridW)/2195+ gridY := area.Y + (area.H-gridH)/2196+ hintStartX := gridX - float64(rowHintCols)*cellSize197+ hintStartY := gridY - float64(colHintRows)*cellSize198+199+ return nonogramLayout{200+ cellSize: cellSize,201+ hintStartX: hintStartX,202+ hintStartY: hintStartY,203+ gridX: gridX,204+ gridY: gridY,205+ }206+}207+208+func colHintText(hints []int, depth, row int) string {209+ if len(hints) == 0 {210+ return ""211+ }212+ start := depth - len(hints)213+ if row < start {214+ return ""215+ }216+ return strconv.Itoa(hints[row-start])217+}218+219+func rowHintText(hints []int, depth, col int) string {220+ if len(hints) == 0 {221+ return ""222+ }223+ start := depth - len(hints)224+ if col < start {225+ return ""226+ }227+ return strconv.Itoa(hints[col-start])228+}229+230+func maxHintDepth(hints [][]int) int {231+ maxDepth := 0232+ for _, h := range hints {233+ if len(h) > maxDepth {234+ maxDepth = len(h)235+ }236+ }237+ return maxDepth238+}239+240+func normalizeNonogramHintsForRender(hints [][]int, size int) [][]int {241+ if size <= 0 {242+ return nil243+ }244+245+ normalized := make([][]int, size)246+ for i := 0; i < size; i++ {247+ if i >= len(hints) || len(hints[i]) == 0 {248+ normalized[i] = []int{0}249+ continue250+ }251+ normalized[i] = append([]int(nil), hints[i]...)252+ }253+ return normalized254+}255+256+func drawNonogramMajorLines(257+ pdf *fpdf.Fpdf,258+ puzzleStartX,259+ puzzleStartY,260+ cellSize float64,261+ width,262+ height,263+ step int,264+) {265+ if step <= 0 || width <= 0 || height <= 0 {266+ return267+ }268+269+ pdf.SetDrawColor(45, 45, 45)270+ pdf.SetLineWidth(pdfexport.MajorGridLineMM)271+272+ for col := step; col < width; col += step {273+ x := puzzleStartX + float64(col)*cellSize274+ pdf.Line(x, puzzleStartY, x, puzzleStartY+float64(height)*cellSize)275+ }276+ for row := step; row < height; row += step {277+ y := puzzleStartY + float64(row)*cellSize278+ pdf.Line(puzzleStartX, y, puzzleStartX+float64(width)*cellSize, y)279+ }280+}281+282+func init() {283+ game.RegisterPrintAdapter(printAdapter{})284+}
A nonogram/print_adapter_test.go +139 -0
@@ -0,0 +1,139 @@
1+package nonogram2+3+import (4+ "math"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/pdfexport"8+)9+10+func TestNormalizeNonogramHintsForRender(t *testing.T) {11+ tests := []struct {12+ name string13+ hints [][]int14+ size int15+ want [][]int16+ }{17+ {18+ name: "empty source defaults to zeros",19+ hints: nil,20+ size: 3,21+ want: [][]int{{0}, {0}, {0}},22+ },23+ {24+ name: "preserves provided hint rows",25+ hints: [][]int{{3, 1}, {}, {2}},26+ size: 3,27+ want: [][]int{{3, 1}, {0}, {2}},28+ },29+ {30+ name: "pads beyond provided rows",31+ hints: [][]int{{1}},32+ size: 3,33+ want: [][]int{{1}, {0}, {0}},34+ },35+ {36+ name: "non-positive size returns nil",37+ hints: [][]int{{1}},38+ size: 0,39+ want: nil,40+ },41+ }42+43+ for _, tt := range tests {44+ t.Run(tt.name, func(t *testing.T) {45+ got := normalizeNonogramHintsForRender(tt.hints, tt.size)46+ if len(got) != len(tt.want) {47+ t.Fatalf("rows = %d, want %d", len(got), len(tt.want))48+ }49+ for row := range len(tt.want) {50+ if len(got[row]) != len(tt.want[row]) {51+ t.Fatalf("row %d len = %d, want %d", row, len(got[row]), len(tt.want[row]))52+ }53+ for col := range len(tt.want[row]) {54+ if got[row][col] != tt.want[row][col] {55+ t.Fatalf("row %d col %d = %d, want %d", row, col, got[row][col], tt.want[row][col])56+ }57+ }58+ }59+ })60+ }61+}62+63+func TestNormalizeNonogramHintsForRenderCopiesRows(t *testing.T) {64+ src := [][]int{{2, 1}}65+66+ got := normalizeNonogramHintsForRender(src, 1)67+ if got == nil || len(got) != 1 {68+ t.Fatalf("unexpected normalized hints: %#v", got)69+ }70+71+ src[0][0] = 972+ if got[0][0] != 2 {73+ t.Fatalf("normalized hint should be copied, got %d want 2", got[0][0])74+ }75+}76+77+func TestLayoutNonogramCentersGrid(t *testing.T) {78+ tests := []struct {79+ name string80+ rowHintCol int81+ colHintRow int82+ }{83+ {name: "shallow hints", rowHintCol: 1, colHintRow: 1},84+ {name: "deep hints", rowHintCol: 5, colHintRow: 4},85+ }86+87+ const (88+ pageNo = 389+ pageW = 139.790+ pageH = 215.991+ )92+93+ boardArea := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)94+ for _, tt := range tests {95+ t.Run(tt.name, func(t *testing.T) {96+ layout := layoutNonogram(97+ pageW,98+ pageH,99+ pageNo,100+ 10,101+ 10,102+ tt.rowHintCol,103+ tt.colHintRow,104+ )105+ if layout.cellSize <= 0 {106+ t.Fatal("expected non-zero cell size")107+ }108+109+ gridW := 10.0 * layout.cellSize110+ gridH := 10.0 * layout.cellSize111+ centerX := layout.gridX + gridW/2112+ centerY := layout.gridY + gridH/2113+ wantX := boardArea.X + boardArea.W/2114+ wantY := boardArea.Y + boardArea.H/2115+116+ if diff := math.Abs(centerX - wantX); diff > 0.8 {117+ t.Fatalf("grid centerX diff = %.3f, want <= 0.8", diff)118+ }119+ if diff := math.Abs(centerY - wantY); diff > 0.8 {120+ t.Fatalf("grid centerY diff = %.3f, want <= 0.8", diff)121+ }122+123+ fullW := float64(tt.rowHintCol+10) * layout.cellSize124+ fullH := float64(tt.colHintRow+10) * layout.cellSize125+ if layout.hintStartX < boardArea.X-0.01 {126+ t.Fatalf("hintStartX = %.3f, want >= %.3f", layout.hintStartX, boardArea.X)127+ }128+ if layout.hintStartY < boardArea.Y-0.01 {129+ t.Fatalf("hintStartY = %.3f, want >= %.3f", layout.hintStartY, boardArea.Y)130+ }131+ if right := layout.hintStartX + fullW; right > boardArea.X+boardArea.W+0.01 {132+ t.Fatalf("hint block right = %.3f, want <= %.3f", right, boardArea.X+boardArea.W)133+ }134+ if bottom := layout.hintStartY + fullH; bottom > boardArea.Y+boardArea.H+0.01 {135+ t.Fatalf("hint block bottom = %.3f, want <= %.3f", bottom, boardArea.Y+boardArea.H)136+ }137+ })138+ }139+}
A nurikabe/PrintAdapter.go +102 -0
@@ -0,0 +1,102 @@
1+package nurikabe2+3+import (4+ "strconv"5+ "unicode/utf8"6+7+ "codeberg.org/go-pdf/fpdf"8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/pdfexport"10+)11+12+type printAdapter struct{}13+14+func (printAdapter) CanonicalGameType() string { return "Nurikabe" }15+func (printAdapter) Aliases() []string { return []string{"nurikabe"} }16+17+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {18+ return pdfexport.ParseNurikabePrintData(save)19+}20+21+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {22+ switch data := payload.(type) {23+ case *pdfexport.NurikabeData:24+ renderNurikabePage(pdf, data)25+ }26+ return nil27+}28+29+func renderNurikabePage(pdf *fpdf.Fpdf, data *pdfexport.NurikabeData) {30+ if data == nil || data.Width <= 0 || data.Height <= 0 {31+ return32+ }33+34+ pageW, pageH := pdf.GetPageSize()35+ pageNo := pdf.PageNo()36+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)37+ cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area)38+ if cellSize <= 0 {39+ return40+ }41+42+ blockW := float64(data.Width) * cellSize43+ blockH := float64(data.Height) * cellSize44+ startX, startY := pdfexport.CenteredOrigin(area, data.Width, data.Height, cellSize)45+46+ pdf.SetDrawColor(55, 55, 55)47+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)48+ for y := 0; y < data.Height; y++ {49+ for x := 0; x < data.Width; x++ {50+ cellX := startX + float64(x)*cellSize51+ cellY := startY + float64(y)*cellSize52+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")53+54+ if y >= len(data.Clues) || x >= len(data.Clues[y]) || data.Clues[y][x] <= 0 {55+ continue56+ }57+ drawNurikabeClue(pdf, cellX, cellY, cellSize, data.Clues[y][x])58+ }59+ }60+61+ pdf.SetDrawColor(35, 35, 35)62+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)63+ pdf.Rect(startX, startY, blockW, blockH, "D")64+65+ ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1)66+ pdfexport.SetInstructionStyle(pdf)67+ pdf.SetXY(area.X, ruleY)68+ pdf.CellFormat(69+ area.W,70+ pdfexport.InstructionLineHMM,71+ "Expand each numbered island to its size; connect all sea cells into one wall.",72+ "",73+ 0,74+ "C",75+ false,76+ 0,77+ "",78+ )79+}80+81+func drawNurikabeClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) {82+ text := strconv.Itoa(value)83+ fontSize := pdfexport.StandardCellFontSize(cellSize, 0.58)84+ runeCount := utf8.RuneCountInString(text)85+ switch {86+ case runeCount >= 3:87+ fontSize *= 0.7288+ case runeCount == 2:89+ fontSize *= 0.8490+ }91+ fontSize = pdfexport.ClampStandardCellFontSize(fontSize)92+93+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)94+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)95+ lineH := fontSize * 0.9296+ pdf.SetXY(x, y+(cellSize-lineH)/2)97+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")98+}99+100+func init() {101+ game.RegisterPrintAdapter(printAdapter{})102+}
A pdfexport/cover_art.go +1241 -0
@@ -0,0 +1,1241 @@
1+package pdfexport2+3+import (4+ "bytes"5+ "encoding/binary"6+ "fmt"7+ "hash/fnv"8+ "image"9+ "image/color"10+ "image/png"11+ "math"12+ "math/rand"13+ "strings"14+ "time"15+16+ "codeberg.org/go-pdf/fpdf"17+)18+19+type coverVec2 struct {20+ x float6421+ y float6422+}23+24+type coverGlow struct {25+ center coverVec226+ spread float6427+ strength float6428+ col color.RGBA29+}30+31+type coverFieldProfile struct {32+ freqX float6433+ freqY float6434+ freqMixX float6435+ freqMixY float6436+ freqCurlX float6437+ freqCurlY float6438+ ampX float6439+ ampY float6440+ ampMix float6441+ ampCurl float6442+ phaseX float6443+ phaseY float6444+ phaseMix float6445+ phaseCurl float6446+ swirl float6447+ shear float6448+ pinch float6449+ pivot coverVec250+}51+52+type coverFlowLayer struct {53+ count int54+ steps int55+ step float6456+ alpha uint857+ radius float6458+ drift float6459+ phase float6460+ waveXFreq float6461+ waveYFreq float6462+ waveXAmp float6463+ waveYAmp float6464+ a color.RGBA65+ b color.RGBA66+}67+68+type coverLatticeProfile struct {69+ enabled bool70+ layout int71+ cols int72+ rows int73+ neighbors int74+ jitterX float6475+ jitterY float6476+ center coverVec277+ radialWarp float6478+ edge color.RGBA79+ nodeOuter color.RGBA80+ nodeInner color.RGBA81+ nodeSize float6482+ coreSize float6483+}84+85+type coverOrbitProfile struct {86+ enabled bool87+ center coverVec288+ count int89+ radiusStart float6490+ radiusStep float6491+ arcCoverage float6492+ segmentsMin int93+ segmentsJitter int94+ eccentricity float6495+ wobble float6496+ dotSize float6497+ colorA color.RGBA98+ colorB color.RGBA99+ alphaBase uint8100+ alphaStep uint8101+}102+103+type coverMarkProfile struct {104+ enabled bool105+ count int106+ gridW int107+ gridH int108+ jitter float64109+ sizeMin float64110+ sizeMax float64111+ alphaBase uint8112+ alphaRange uint8113+ colorA color.RGBA114+ colorB color.RGBA115+ cutout color.RGBA116+}117+118+type coverPalette struct {119+ bgTop color.RGBA120+ bgMid color.RGBA121+ bgBottom color.RGBA122+ accentA color.RGBA123+ accentB color.RGBA124+ ink color.RGBA125+ nodeOuter color.RGBA126+ nodeInner color.RGBA127+ markCutout color.RGBA128+ grainWarm color.RGBA129+ grainCool color.RGBA130+}131+132+type coverPaletteFamily struct {133+ name string134+ bgShift [3]float64135+ accentShift [2]float64136+ satBias float64137+ valueBias float64138+}139+140+type coverArchetype uint8141+142+const (143+ coverArchetypeConstellation coverArchetype = iota144+ coverArchetypeVortex145+ coverArchetypeBands146+ coverArchetypeDriftField147+ coverArchetypeRadialMesh148+ coverArchetypeSparseGlyph149+ coverArchetypeCount150+)151+152+type coverModifier uint8153+154+const (155+ coverModifierDenseFlow coverModifier = iota156+ coverModifierHighOrbit157+ coverModifierQuietLattice158+ coverModifierMicroMarks159+ coverModifierGrainHeavy160+ coverModifierNegativeSpace161+)162+163+var coverPaletteFamilies = []coverPaletteFamily{164+ {165+ name: "tropical",166+ bgShift: [3]float64{0.10, 0.02, -0.14},167+ accentShift: [2]float64{0.33, 0.52},168+ satBias: 0.08,169+ valueBias: 0.03,170+ },171+ {172+ name: "sunset",173+ bgShift: [3]float64{0.05, -0.05, -0.17},174+ accentShift: [2]float64{0.19, 0.38},175+ satBias: 0.10,176+ valueBias: 0.04,177+ },178+ {179+ name: "electric-mineral",180+ bgShift: [3]float64{-0.16, -0.08, 0.01},181+ accentShift: [2]float64{0.36, -0.28},182+ satBias: 0.13,183+ valueBias: -0.02,184+ },185+ {186+ name: "aurora",187+ bgShift: [3]float64{0.22, 0.10, -0.09},188+ accentShift: [2]float64{0.45, 0.62},189+ satBias: 0.11,190+ valueBias: 0.02,191+ },192+ {193+ name: "ember",194+ bgShift: [3]float64{0.00, -0.11, -0.22},195+ accentShift: [2]float64{-0.36, 0.11},196+ satBias: 0.09,197+ valueBias: -0.03,198+ },199+ {200+ name: "citrus-marine",201+ bgShift: [3]float64{0.14, 0.03, -0.13},202+ accentShift: [2]float64{0.30, -0.22},203+ satBias: 0.12,204+ valueBias: 0.01,205+ },206+}207+208+type coverArtDirection struct {209+ motif int210+ top color.RGBA211+ mid color.RGBA212+ bottom color.RGBA213+ verticalCurve float64214+ glows []coverGlow215+ field coverFieldProfile216+ flowLayers []coverFlowLayer217+ lattice coverLatticeProfile218+ orbit coverOrbitProfile219+ marks coverMarkProfile220+ grainCount int221+ grainWarm color.RGBA222+ grainCool color.RGBA223+}224+225+func drawCoverArtworkImage(226+ pdf *fpdf.Fpdf,227+ scene rectMM,228+ seedText string,229+ variant string,230+ base RGB,231+) {232+ seedText = strings.TrimSpace(seedText)233+ if seedText == "" {234+ seedText = "puzzletea" + time.Now().String()235+ }236+ if strings.TrimSpace(variant) == "" {237+ variant = "front"238+ }239+ imageName := fmt.Sprintf(240+ "puzzletea-cover-artwork-%016x",241+ coverSeedHash(seedText+"|"+variant),242+ )243+ artPNG := renderCoverArtworkPNG(seedText, variant, base)244+ options := fpdf.ImageOptions{245+ ImageType: "PNG",246+ ReadDpi: true,247+ }248+ pdf.RegisterImageOptionsReader(imageName, options, bytes.NewReader(artPNG))249+ pdf.ImageOptions(imageName, scene.x, scene.y, scene.w, scene.h, false, options, 0, "")250+}251+252+func renderCoverArtworkPNG(seedText, variant string, base RGB) []byte {253+ const (254+ width = 1200255+ height = 1400256+ )257+ seed := coverSeedHash(seedText + "|" + variant)258+ direction := newCoverArtDirection(seed, base)259+ img := image.NewRGBA(image.Rect(0, 0, width, height))260+261+ paintCoverBackground(img, width, height, direction)262+ drawCoverFlowTrails(img, width, height, direction, coverStreamRNG(seed, "flow"))263+ drawCoverPuzzleLattice(img, width, height, direction, coverStreamRNG(seed, "lattice"))264+ drawCoverOrbitBands(img, width, height, direction, coverStreamRNG(seed, "orbit"))265+ drawCoverSeedMarks(img, width, height, direction, coverStreamRNG(seed, "marks"))266+ drawCoverFilmGrain(img, width, height, direction, coverStreamRNG(seed, "grain"))267+268+ var out bytes.Buffer269+ if err := png.Encode(&out, img); err != nil {270+ return []byte{}271+ }272+ return out.Bytes()273+}274+275+func newCoverArtDirection(seed uint64, base RGB) coverArtDirection {276+ paletteRNG := coverStreamRNG(seed, "palette")277+ structureRNG := coverStreamRNG(seed, "structure")278+ palette := newCoverPalette(base, paletteRNG)279+ primary, modifiers := pickCoverComposition(structureRNG)280+281+ glows := make([]coverGlow, 0, 5)282+ anchorColor := palette.accentA283+ if structureRNG.Intn(2) == 0 {284+ anchorColor = palette.accentB285+ }286+ glows = append(glows, coverGlow{287+ center: coverVec2{288+ x: 0.12 + structureRNG.Float64()*0.76,289+ y: 0.10 + structureRNG.Float64()*0.78,290+ },291+ spread: 0.24 + structureRNG.Float64()*0.30,292+ strength: 0.46 + structureRNG.Float64()*0.28,293+ col: jitterRGBA(anchorColor, structureRNG, 26),294+ })295+296+ glowCount := 2 + structureRNG.Intn(3)297+ for i := 0; i < glowCount; i++ {298+ col := palette.accentA299+ if i%2 == 1 {300+ col = palette.accentB301+ }302+ col = jitterRGBA(col, structureRNG, 28)303+ glows = append(glows, coverGlow{304+ center: coverVec2{305+ x: 0.06 + structureRNG.Float64()*0.88,306+ y: 0.08 + structureRNG.Float64()*0.84,307+ },308+ spread: 0.42 + structureRNG.Float64()*0.70,309+ strength: 0.12 + structureRNG.Float64()*0.28,310+ col: col,311+ })312+ }313+314+ field := coverFieldProfile{315+ freqX: 3.8 + structureRNG.Float64()*6.8,316+ freqY: 3.6 + structureRNG.Float64()*6.5,317+ freqMixX: 0.5 + structureRNG.Float64()*1.8,318+ freqMixY: 0.5 + structureRNG.Float64()*1.8,319+ freqCurlX: 0.4 + structureRNG.Float64()*2.0,320+ freqCurlY: 0.4 + structureRNG.Float64()*2.0,321+ ampX: 0.60 + structureRNG.Float64()*1.00,322+ ampY: 0.60 + structureRNG.Float64()*1.00,323+ ampMix: 0.26 + structureRNG.Float64()*0.92,324+ ampCurl: 0.24 + structureRNG.Float64()*0.88,325+ phaseX: 0.22 + structureRNG.Float64()*1.45,326+ phaseY: 0.22 + structureRNG.Float64()*1.45,327+ phaseMix: 0.20 + structureRNG.Float64()*1.20,328+ phaseCurl: 0.20 + structureRNG.Float64()*1.20,329+ swirl: (structureRNG.Float64()*2 - 1) * 0.40,330+ shear: (structureRNG.Float64()*2 - 1) * 0.36,331+ pinch: (structureRNG.Float64()*2 - 1) * 0.56,332+ pivot: coverVec2{333+ x: 0.20 + structureRNG.Float64()*0.60,334+ y: 0.18 + structureRNG.Float64()*0.64,335+ },336+ }337+338+ flowLayers := make([]coverFlowLayer, 0, 4)339+ layerCount := 2 + structureRNG.Intn(3)340+ for i := 0; i < layerCount; i++ {341+ t := float64(i) / float64(maxInt(1, layerCount-1))342+ a := jitterRGBA(lerpRGB(palette.accentA, palette.bgMid, 0.28+t*0.42), structureRNG, 24)343+ b := jitterRGBA(lerpRGB(palette.accentB, palette.bgTop, 0.16+t*0.38), structureRNG, 24)344+ flowLayers = append(flowLayers, coverFlowLayer{345+ count: 220 + structureRNG.Intn(360),346+ steps: 96 + structureRNG.Intn(132),347+ step: 1.10 + structureRNG.Float64()*1.80,348+ alpha: uint8(22 + structureRNG.Intn(42)),349+ radius: 0.78 + structureRNG.Float64()*0.98,350+ drift: 0.40 + structureRNG.Float64()*1.72,351+ phase: structureRNG.Float64() * math.Pi * 2,352+ waveXFreq: 4.6 + structureRNG.Float64()*7.8,353+ waveYFreq: 4.6 + structureRNG.Float64()*7.8,354+ waveXAmp: 0.06 + structureRNG.Float64()*0.34,355+ waveYAmp: 0.06 + structureRNG.Float64()*0.34,356+ a: a,357+ b: b,358+ })359+ }360+361+ lattice := coverLatticeProfile{362+ enabled: true,363+ layout: structureRNG.Intn(3),364+ cols: 7 + structureRNG.Intn(6),365+ rows: 6 + structureRNG.Intn(7),366+ neighbors: 2 + structureRNG.Intn(3),367+ jitterX: 24 + structureRNG.Float64()*48,368+ jitterY: 28 + structureRNG.Float64()*56,369+ center: coverVec2{x: 0.28 + structureRNG.Float64()*0.44, y: 0.24 + structureRNG.Float64()*0.50},370+ radialWarp: (structureRNG.Float64()*2 - 1) * 0.40,371+ edge: color.RGBA{R: palette.ink.R, G: palette.ink.G, B: palette.ink.B, A: uint8(52 + structureRNG.Intn(76))},372+ nodeOuter: palette.nodeOuter,373+ nodeInner: palette.nodeInner,374+ nodeSize: 3.3 + structureRNG.Float64()*3.8,375+ coreSize: 1.4 + structureRNG.Float64()*1.7,376+ }377+378+ orbit := coverOrbitProfile{379+ enabled: true,380+ center: coverVec2{x: 0.30 + structureRNG.Float64()*0.38, y: 0.26 + structureRNG.Float64()*0.38},381+ count: 10 + structureRNG.Intn(18),382+ radiusStart: 36 + structureRNG.Float64()*52,383+ radiusStep: 13 + structureRNG.Float64()*20,384+ arcCoverage: 0.48 + structureRNG.Float64()*0.48,385+ segmentsMin: 34 + structureRNG.Intn(40),386+ segmentsJitter: 42 + structureRNG.Intn(56),387+ eccentricity: 0.66 + structureRNG.Float64()*0.56,388+ wobble: 2.6 + structureRNG.Float64()*8.8,389+ dotSize: 0.84 + structureRNG.Float64()*0.86,390+ colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.22), structureRNG, 18),391+ colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.34), structureRNG, 18),392+ alphaBase: uint8(18 + structureRNG.Intn(28)),393+ alphaStep: uint8(1 + structureRNG.Intn(3)),394+ }395+396+ marks := coverMarkProfile{397+ enabled: true,398+ count: 28 + structureRNG.Intn(58),399+ gridW: 8 + structureRNG.Intn(9),400+ gridH: 10 + structureRNG.Intn(10),401+ jitter: 8 + structureRNG.Float64()*30,402+ sizeMin: 1.7 + structureRNG.Float64()*1.5,403+ sizeMax: 3.4 + structureRNG.Float64()*3.0,404+ alphaBase: uint8(40 + structureRNG.Intn(40)),405+ alphaRange: uint8(20 + structureRNG.Intn(30)),406+ colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.18), structureRNG, 22),407+ colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.30), structureRNG, 22),408+ cutout: palette.markCutout,409+ }410+411+ direction := coverArtDirection{412+ motif: int(primary),413+ top: palette.bgTop,414+ mid: palette.bgMid,415+ bottom: palette.bgBottom,416+ verticalCurve: 1.18 + structureRNG.Float64()*1.14,417+ glows: glows,418+ field: field,419+ flowLayers: flowLayers,420+ lattice: lattice,421+ orbit: orbit,422+ marks: marks,423+ grainCount: 34000 + structureRNG.Intn(32000),424+ grainWarm: palette.grainWarm,425+ grainCool: palette.grainCool,426+ }427+428+ applyCoverPrimaryArchetype(&direction, primary, structureRNG)429+ for _, modifier := range modifiers {430+ applyCoverModifier(&direction, modifier, structureRNG)431+ }432+433+ return direction434+}435+436+func coverArchetypeForSeed(seed uint64) coverArchetype {437+ primary, _ := coverCompositionForSeed(seed)438+ return primary439+}440+441+func coverCompositionForSeed(seed uint64) (coverArchetype, []coverModifier) {442+ structureRNG := coverStreamRNG(seed, "structure")443+ return pickCoverComposition(structureRNG)444+}445+446+func pickCoverComposition(rng *rand.Rand) (coverArchetype, []coverModifier) {447+ primary := pickWeightedCoverArchetype(rng)448+ modifierCount := 0449+ roll := rng.Float64()450+ switch {451+ case roll < 0.58:452+ modifierCount = 1453+ default:454+ modifierCount = 2455+ }456+457+ modifierPool := []coverModifier{458+ coverModifierDenseFlow,459+ coverModifierHighOrbit,460+ coverModifierQuietLattice,461+ coverModifierMicroMarks,462+ coverModifierGrainHeavy,463+ coverModifierNegativeSpace,464+ }465+ rng.Shuffle(len(modifierPool), func(i, j int) {466+ modifierPool[i], modifierPool[j] = modifierPool[j], modifierPool[i]467+ })468+469+ modifiers := make([]coverModifier, 0, modifierCount)470+ for _, modifier := range modifierPool {471+ if len(modifiers) >= modifierCount {472+ break473+ }474+ if primary == coverArchetypeSparseGlyph && modifier == coverModifierNegativeSpace {475+ continue476+ }477+ if primary == coverArchetypeBands && modifier == coverModifierHighOrbit {478+ continue479+ }480+ modifiers = append(modifiers, modifier)481+ }482+ return primary, modifiers483+}484+485+func pickWeightedCoverArchetype(rng *rand.Rand) coverArchetype {486+ weights := [...]int{18, 16, 14, 18, 17, 17}487+ total := 0488+ for _, weight := range weights {489+ total += weight490+ }491+ pick := rng.Intn(total)492+ for idx, weight := range weights {493+ if pick < weight {494+ return coverArchetype(idx)495+ }496+ pick -= weight497+ }498+ return coverArchetypeSparseGlyph499+}500+501+func newCoverPalette(base RGB, rng *rand.Rand) coverPalette {502+ baseColor := rgbToColor(base)503+ baseHue, baseSat, baseVal := rgbToHSV(baseColor)504+ family := coverPaletteFamilies[rng.Intn(len(coverPaletteFamilies))]505+ baseHue = wrapHue(baseHue + randCentered(rng, 0.32))506+507+ baseSat = clamp(0.14, 0.50, baseSat*0.62+0.11+family.satBias*0.18+rng.Float64()*0.07)508+ baseVal = clamp(0.34, 0.86, baseVal*0.84+0.12+family.valueBias*0.15+randCentered(rng, 0.05))509+510+ top := hsvToRGB(511+ wrapHue(baseHue+family.bgShift[0]+randCentered(rng, 0.10)),512+ clamp(0.14, 0.34, baseSat*0.50+0.04+family.satBias*0.28+rng.Float64()*0.08),513+ clamp(0.74, 0.98, baseVal+0.16+family.valueBias*0.24+rng.Float64()*0.14),514+ )515+ mid := hsvToRGB(516+ wrapHue(baseHue+family.bgShift[1]+randCentered(rng, 0.14)),517+ clamp(0.20, 0.50, baseSat*0.74+0.02+family.satBias*0.40+rng.Float64()*0.10),518+ clamp(0.46, 0.88, baseVal-0.02+family.valueBias*0.12+randCentered(rng, 0.12)),519+ )520+ bottom := hsvToRGB(521+ wrapHue(baseHue+family.bgShift[2]+randCentered(rng, 0.16)),522+ clamp(0.18, 0.54, baseSat*0.82+0.01+family.satBias*0.35+rng.Float64()*0.10),523+ clamp(0.08, 0.36, baseVal*0.32+0.02+family.valueBias*0.10+randCentered(rng, 0.10)),524+ )525+ accentA := hsvToRGB(526+ wrapHue(baseHue+family.accentShift[0]+randCentered(rng, 0.18)),527+ clamp(0.30, 0.62, baseSat+0.12+family.satBias*0.40+rng.Float64()*0.15),528+ clamp(0.62, 0.96, baseVal+0.14+rng.Float64()*0.12),529+ )530+ accentB := hsvToRGB(531+ wrapHue(baseHue+family.accentShift[1]+randCentered(rng, 0.20)),532+ clamp(0.28, 0.60, baseSat+0.10+family.satBias*0.38+rng.Float64()*0.16),533+ clamp(0.56, 0.92, baseVal+0.08+rng.Float64()*0.12),534+ )535+ ink := hsvToRGB(536+ wrapHue(baseHue+0.50+family.bgShift[2]*0.35),537+ clamp(0.22, 0.44, 0.24+baseSat*0.22),538+ clamp(0.06, 0.22, 0.12+(1-baseVal)*0.10),539+ )540+541+ return coverPalette{542+ bgTop: top,543+ bgMid: mid,544+ bgBottom: bottom,545+ accentA: accentA,546+ accentB: accentB,547+ ink: ink,548+ nodeOuter: blendRGB(top, color.RGBA{R: 255, G: 244, B: 220, A: 255}, 0.26),549+ nodeInner: blendRGB(ink, bottom, 0.34),550+ markCutout: blendRGB(bottom, color.RGBA{R: 6, G: 11, B: 20, A: 255}, 0.30),551+ grainWarm: blendRGB(top, color.RGBA{R: 255, G: 248, B: 232, A: 255}, 0.36),552+ grainCool: blendRGB(bottom, ink, 0.44),553+ }554+}555+556+func applyCoverPrimaryArchetype(557+ direction *coverArtDirection,558+ primary coverArchetype,559+ rng *rand.Rand,560+) {561+ switch primary {562+ case coverArchetypeConstellation:563+ direction.lattice.layout = 0564+ direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1)565+ direction.lattice.edge.A = minUint8(160, direction.lattice.edge.A+22)566+ for i := range direction.flowLayers {567+ direction.flowLayers[i].count = maxInt(160, int(float64(direction.flowLayers[i].count)*0.76))568+ direction.flowLayers[i].alpha = maxUint8(16, direction.flowLayers[i].alpha-6)569+ }570+ direction.orbit.enabled = false571+ direction.orbit.count = maxInt(4, direction.orbit.count/2)572+ direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*0.72 + 0.12)573+ direction.marks.count += 12574+ direction.field.swirl *= 0.88575+ case coverArchetypeVortex:576+ direction.lattice.enabled = false577+ direction.orbit.count += 14578+ direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.20 + 0.08)579+ direction.orbit.radiusStep *= 0.84580+ direction.field.swirl *= 1.42581+ for i := range direction.flowLayers {582+ direction.flowLayers[i].alpha = minUint8(86, direction.flowLayers[i].alpha+10)583+ direction.flowLayers[i].drift *= 1.18584+ }585+ direction.marks.count = maxInt(8, direction.marks.count-16)586+ case coverArchetypeBands:587+ direction.lattice.layout = 2588+ direction.lattice.enabled = false589+ direction.orbit.enabled = false590+ direction.orbit.count = maxInt(5, direction.orbit.count/2)591+ direction.field.shear *= 0.44592+ direction.field.pinch *= 0.74593+ for i := range direction.flowLayers {594+ direction.flowLayers[i].waveYFreq = 1.8 + rng.Float64()*2.3595+ direction.flowLayers[i].waveYAmp += 0.14 + rng.Float64()*0.16596+ direction.flowLayers[i].waveXAmp *= 0.66597+ direction.flowLayers[i].step *= 0.94598+ direction.flowLayers[i].count = maxInt(180, int(float64(direction.flowLayers[i].count)*0.84))599+ }600+ direction.marks.count = maxInt(8, direction.marks.count-14)601+ case coverArchetypeDriftField:602+ direction.lattice.enabled = false603+ for i := range direction.flowLayers {604+ direction.flowLayers[i].count += 130605+ direction.flowLayers[i].steps += 18606+ direction.flowLayers[i].alpha = minUint8(90, direction.flowLayers[i].alpha+12)607+ direction.flowLayers[i].drift *= 1.24608+ }609+ direction.orbit.count = maxInt(8, direction.orbit.count-4)610+ direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1)611+ direction.marks.count = maxInt(10, direction.marks.count-12)612+ direction.grainCount += 5000613+ case coverArchetypeRadialMesh:614+ direction.lattice.layout = 1615+ direction.lattice.rows += 2616+ direction.lattice.cols += 1617+ direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1)618+ direction.lattice.center = coverVec2{619+ x: 0.44 + rng.Float64()*0.12,620+ y: 0.38 + rng.Float64()*0.14,621+ }622+ direction.orbit.enabled = true623+ direction.orbit.count += 8624+ direction.orbit.eccentricity = clamp(0.56, 1.46, direction.orbit.eccentricity*1.16)625+ direction.field.pinch *= 1.24626+ for i := range direction.flowLayers {627+ direction.flowLayers[i].count = maxInt(110, int(float64(direction.flowLayers[i].count)*0.45))628+ direction.flowLayers[i].alpha = maxUint8(14, direction.flowLayers[i].alpha-6)629+ }630+ direction.marks.count = maxInt(10, direction.marks.count-14)631+ case coverArchetypeSparseGlyph:632+ for i := range direction.flowLayers {633+ direction.flowLayers[i].count = maxInt(120, int(float64(direction.flowLayers[i].count)*0.55))634+ direction.flowLayers[i].steps = maxInt(72, int(float64(direction.flowLayers[i].steps)*0.70))635+ direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8)636+ }637+ direction.lattice.enabled = rng.Float64() > 0.68638+ if direction.lattice.enabled {639+ direction.lattice.neighbors = 2640+ direction.lattice.edge.A = maxUint8(14, direction.lattice.edge.A-26)641+ }642+ direction.orbit.enabled = rng.Float64() > 0.80643+ direction.orbit.count = maxInt(5, direction.orbit.count/2)644+ direction.orbit.arcCoverage *= 0.66645+ direction.marks.count += 40646+ direction.marks.sizeMax += 1.5647+ direction.marks.jitter *= 0.76648+ direction.field.swirl *= 0.72649+ direction.grainCount += 9000650+ }651+}652+653+func applyCoverModifier(654+ direction *coverArtDirection,655+ modifier coverModifier,656+ rng *rand.Rand,657+) {658+ switch modifier {659+ case coverModifierDenseFlow:660+ for i := range direction.flowLayers {661+ direction.flowLayers[i].count += 96662+ direction.flowLayers[i].steps += 16663+ direction.flowLayers[i].alpha = minUint8(94, direction.flowLayers[i].alpha+10)664+ }665+ direction.grainCount += 3000666+ case coverModifierHighOrbit:667+ direction.orbit.enabled = true668+ direction.orbit.count += 10669+ direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.15 + 0.05)670+ direction.orbit.dotSize += 0.12671+ direction.orbit.alphaBase = minUint8(120, direction.orbit.alphaBase+6)672+ case coverModifierQuietLattice:673+ if direction.lattice.enabled {674+ direction.lattice.edge.A = maxUint8(12, direction.lattice.edge.A-30)675+ direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1)676+ }677+ if rng.Float64() < 0.35 {678+ direction.lattice.enabled = false679+ }680+ case coverModifierMicroMarks:681+ direction.marks.count += 26682+ direction.marks.sizeMin = clamp(0.8, 4.0, direction.marks.sizeMin*0.78)683+ direction.marks.sizeMax = clamp(1.2, 5.2, direction.marks.sizeMax*0.74)684+ direction.marks.jitter *= 0.86685+ direction.marks.alphaBase = minUint8(108, direction.marks.alphaBase+8)686+ case coverModifierGrainHeavy:687+ direction.grainCount += 12000688+ direction.grainWarm = blendRGB(direction.grainWarm, color.RGBA{R: 255, G: 250, B: 240, A: 255}, 0.12)689+ case coverModifierNegativeSpace:690+ for i := range direction.flowLayers {691+ direction.flowLayers[i].count = maxInt(140, int(float64(direction.flowLayers[i].count)*0.62))692+ direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8)693+ }694+ direction.orbit.count = maxInt(4, direction.orbit.count-6)695+ direction.orbit.alphaBase = maxUint8(8, direction.orbit.alphaBase-4)696+ direction.marks.count = maxInt(10, direction.marks.count-12)697+ direction.verticalCurve = clamp(1.0, 2.8, direction.verticalCurve+0.26)698+ for i := range direction.glows {699+ direction.glows[i].strength *= 0.72700+ }701+ }702+}703+704+func randCentered(rng *rand.Rand, spread float64) float64 {705+ return (rng.Float64()*2 - 1) * spread706+}707+708+func paintCoverBackground(img *image.RGBA, w, h int, direction coverArtDirection) {709+ for y := range h {710+ ty := float64(y) / float64(h-1)711+ vertical := blendRGB(712+ lerpRGB(direction.top, direction.mid, ty*1.12),713+ direction.bottom,714+ powClamp(ty, direction.verticalCurve),715+ )716+ for x := range w {717+ tx := float64(x) / float64(w-1)718+ col := vertical719+ for _, glow := range direction.glows {720+ amount := radialFalloff(tx, ty, glow.center.x, glow.center.y, glow.spread)721+ col = blendRGB(col, glow.col, amount*glow.strength)722+ }723+ img.SetRGBA(x, y, col)724+ }725+ }726+}727+728+func drawCoverFlowTrails(729+ img *image.RGBA,730+ w, h int,731+ direction coverArtDirection,732+ rng *rand.Rand,733+) {734+ for _, layer := range direction.flowLayers {735+ for i := 0; i < layer.count; i++ {736+ x := rng.Float64() * float64(w)737+ y := rng.Float64() * float64(h)738+ for step := 0; step < layer.steps; step++ {739+ nx := x / float64(w)740+ ny := y / float64(h)741+ angle := coverFieldAngle(nx, ny, direction.field, layer.phase)742+ angle += math.Sin((ny+layer.phase)*layer.waveYFreq) * layer.waveYAmp743+ angle += math.Cos((nx-layer.phase)*layer.waveXFreq) * layer.waveXAmp744+745+ x += math.Cos(angle) * layer.step746+ y += math.Sin(angle) * layer.step747+ x += math.Cos((ny-layer.phase)*math.Pi*2) * layer.drift * 0.04748+ y += math.Sin((nx+layer.phase)*math.Pi*2) * layer.drift * 0.08749+ if x < 1 || x >= float64(w-1) || y < 1 || y >= float64(h-1) {750+ break751+ }752+ t := float64(step) / float64(layer.steps)753+ c := lerpRGB(layer.a, layer.b, t)754+ c.A = layer.alpha755+ drawDisc(img, x, y, layer.radius, c)756+ }757+ }758+ }759+}760+761+func drawCoverPuzzleLattice(762+ img *image.RGBA,763+ w, h int,764+ direction coverArtDirection,765+ rng *rand.Rand,766+) {767+ if !direction.lattice.enabled {768+ return769+ }770+ nodes := buildCoverLatticeNodes(w, h, direction.lattice, rng)771+ if len(nodes) == 0 {772+ return773+ }774+775+ for i := range nodes {776+ for _, j := range nearestN(nodes, i, direction.lattice.neighbors) {777+ if j > i {778+ drawLine(img, nodes[i], nodes[j], direction.lattice.edge)779+ }780+ }781+ }782+ for _, n := range nodes {783+ drawDisc(img, n.x, n.y, direction.lattice.nodeSize, direction.lattice.nodeOuter)784+ drawDisc(img, n.x, n.y, direction.lattice.coreSize, direction.lattice.nodeInner)785+ }786+}787+788+func buildCoverLatticeNodes(789+ w, h int,790+ profile coverLatticeProfile,791+ rng *rand.Rand,792+) []coverVec2 {793+ nodes := make([]coverVec2, 0, profile.cols*profile.rows)794+ switch profile.layout {795+ case 1:796+ minDim := math.Min(float64(w), float64(h))797+ rings := maxInt(4, profile.rows+1)798+ spokes := maxInt(8, profile.cols+4)799+ center := coverVec2{800+ x: profile.center.x * float64(w),801+ y: profile.center.y * float64(h),802+ }803+ for ring := 1; ring <= rings; ring++ {804+ ringT := float64(ring) / float64(rings+1)805+ radius := ringT * minDim * (0.16 + 0.62*ringT)806+ offset := rng.Float64() * math.Pi * 2807+ for spoke := 0; spoke < spokes; spoke++ {808+ ang := offset + float64(spoke)/float64(spokes)*math.Pi*2809+ ang += math.Sin(float64(ring)*0.9+float64(spoke)*0.45) * profile.radialWarp810+ jitter := (rng.Float64() - 0.5) * profile.jitterX811+ x := center.x + math.Cos(ang)*(radius+jitter)812+ y := center.y + math.Sin(ang)*(radius+jitter*0.45)813+ nodes = append(nodes, coverVec2{x: x, y: y})814+ }815+ }816+ case 2:817+ total := maxInt(38, profile.cols*profile.rows)818+ for i := 0; i < total; i++ {819+ tx := (rng.Float64()*0.84 + 0.08)820+ ty := (rng.Float64()*0.84 + 0.08)821+ warpX := math.Sin(ty*math.Pi*4+float64(i)*0.22) * profile.radialWarp * 0.12822+ warpY := math.Cos(tx*math.Pi*3+float64(i)*0.18) * profile.radialWarp * 0.10823+ x := (tx+warpX)*float64(w) + (rng.Float64()-0.5)*profile.jitterX824+ y := (ty+warpY)*float64(h) + (rng.Float64()-0.5)*profile.jitterY825+ nodes = append(nodes, coverVec2{x: x, y: y})826+ }827+ default:828+ for row := 0; row < profile.rows; row++ {829+ for col := 0; col < profile.cols; col++ {830+ x := (float64(col) + 1) / (float64(profile.cols) + 1) * float64(w)831+ y := (float64(row) + 1) / (float64(profile.rows) + 1) * float64(h)832+ x += (rng.Float64() - 0.5) * profile.jitterX833+ y += (rng.Float64() - 0.5) * profile.jitterY834+ nodes = append(nodes, coverVec2{x: x, y: y})835+ }836+ }837+ }838+ return nodes839+}840+841+func drawCoverOrbitBands(842+ img *image.RGBA,843+ w, h int,844+ direction coverArtDirection,845+ rng *rand.Rand,846+) {847+ if !direction.orbit.enabled {848+ return849+ }850+ center := coverVec2{851+ x: float64(w) * direction.orbit.center.x,852+ y: float64(h) * direction.orbit.center.y,853+ }854+ for i := 0; i < direction.orbit.count; i++ {855+ radius := direction.orbit.radiusStart + float64(i)*direction.orbit.radiusStep856+ start := rng.Float64() * math.Pi * 2857+ sweep := direction.orbit.arcCoverage * (0.74 + rng.Float64()*0.52) * math.Pi * 2858+ sweep = math.Min(sweep, math.Pi*2)859+ segments := direction.orbit.segmentsMin + rng.Intn(direction.orbit.segmentsJitter)860+ t := float64(i) / float64(maxInt(1, direction.orbit.count-1))861+ col := lerpRGB(direction.orbit.colorA, direction.orbit.colorB, t)862+ col.A = minUint8(220, direction.orbit.alphaBase+uint8(i)*direction.orbit.alphaStep)863+ for s := 0; s < segments; s++ {864+ a := start + float64(s)/float64(segments)*sweep865+ jx := math.Sin(float64(i)*0.43+a*2.9) * direction.orbit.wobble866+ jy := math.Cos(float64(i)*0.37+a*2.3) * direction.orbit.wobble * 0.82867+ x := center.x + math.Cos(a)*radius*direction.orbit.eccentricity + jx868+ y := center.y + math.Sin(a)*radius + jy869+ drawDisc(img, x, y, direction.orbit.dotSize, col)870+ }871+ }872+}873+874+func drawCoverSeedMarks(875+ img *image.RGBA,876+ w, h int,877+ direction coverArtDirection,878+ rng *rand.Rand,879+) {880+ if !direction.marks.enabled {881+ return882+ }883+ for i := 0; i < direction.marks.count; i++ {884+ gx := 1 + rng.Intn(direction.marks.gridW)885+ gy := 1 + rng.Intn(direction.marks.gridH)886+ x := float64(gx) / float64(direction.marks.gridW+1) * float64(w)887+ y := float64(gy) / float64(direction.marks.gridH+1) * float64(h)888+ x += (rng.Float64() - 0.5) * direction.marks.jitter889+ y += (rng.Float64() - 0.5) * direction.marks.jitter890+891+ size := direction.marks.sizeMin892+ if direction.marks.sizeMax > direction.marks.sizeMin {893+ size += rng.Float64() * (direction.marks.sizeMax - direction.marks.sizeMin)894+ }895+ col := lerpRGB(direction.marks.colorA, direction.marks.colorB, rng.Float64())896+ col.A = minUint8(220, direction.marks.alphaBase+uint8(rng.Intn(int(direction.marks.alphaRange)+1)))897+ style := (i + rng.Intn(4) + direction.motif) % 4898+ switch style {899+ case 0:900+ drawDisc(img, x, y, size*0.48, col)901+ case 1:902+ drawDisc(img, x, y, size, col)903+ drawDisc(img, x, y, size*0.52, direction.marks.cutout)904+ case 2:905+ drawLine(img, coverVec2{x: x - size, y: y}, coverVec2{x: x + size, y: y}, col)906+ drawLine(img, coverVec2{x: x, y: y - size}, coverVec2{x: x, y: y + size}, col)907+ default:908+ drawLine(img, coverVec2{x: x - size, y: y - size}, coverVec2{x: x + size, y: y + size}, col)909+ drawLine(img, coverVec2{x: x - size, y: y + size}, coverVec2{x: x + size, y: y - size}, col)910+ drawDisc(img, x, y, size*0.32, direction.marks.cutout)911+ }912+ }913+}914+915+func drawCoverFilmGrain(916+ img *image.RGBA,917+ w, h int,918+ direction coverArtDirection,919+ rng *rand.Rand,920+) {921+ for i := 0; i < direction.grainCount; i++ {922+ x := rng.Intn(w)923+ y := rng.Intn(h)924+ alpha := uint8(8 + rng.Intn(18))925+ if rng.Intn(2) == 0 {926+ blendPixel(img, x, y, color.RGBA{927+ R: direction.grainWarm.R,928+ G: direction.grainWarm.G,929+ B: direction.grainWarm.B,930+ A: alpha,931+ })932+ continue933+ }934+ blendPixel(img, x, y, color.RGBA{935+ R: direction.grainCool.R,936+ G: direction.grainCool.G,937+ B: direction.grainCool.B,938+ A: alpha,939+ })940+ }941+}942+943+func coverFieldAngle(x, y float64, field coverFieldProfile, phase float64) float64 {944+ ax := math.Sin((x*field.freqX+phase*field.phaseX)*math.Pi*2) * field.ampX945+ ay := math.Cos((y*field.freqY-phase*field.phaseY)*math.Pi*2) * field.ampY946+ mix := math.Sin((x*field.freqMixX+y*field.freqMixY+phase*field.phaseMix)*math.Pi*2) * field.ampMix947+ curl := math.Cos((x*field.freqCurlX-y*field.freqCurlY+phase*field.phaseCurl)*math.Pi*2) * field.ampCurl948+ radial := math.Atan2(y-field.pivot.y, x-field.pivot.x)949+ distance := math.Hypot(x-field.pivot.x, y-field.pivot.y)950+ swirl := radial * field.swirl951+ pinch := (0.5 - distance) * field.pinch952+ shear := (x - y) * field.shear953+ return ax + ay + mix + curl + swirl + pinch + shear954+}955+956+func nearestN(nodes []coverVec2, idx, n int) []int {957+ n = maxInt(1, n)958+ bestDistance := make([]float64, n)959+ bestIndex := make([]int, n)960+ for i := range bestDistance {961+ bestDistance[i] = math.MaxFloat64962+ bestIndex[i] = -1963+ }964+ for j := range nodes {965+ if j == idx {966+ continue967+ }968+ dx := nodes[idx].x - nodes[j].x969+ dy := nodes[idx].y - nodes[j].y970+ distance := dx*dx + dy*dy971+ for k := range bestDistance {972+ if distance >= bestDistance[k] {973+ continue974+ }975+ for shift := len(bestDistance) - 1; shift > k; shift-- {976+ bestDistance[shift] = bestDistance[shift-1]977+ bestIndex[shift] = bestIndex[shift-1]978+ }979+ bestDistance[k] = distance980+ bestIndex[k] = j981+ break982+ }983+ }984+ out := make([]int, 0, len(bestIndex))985+ for _, best := range bestIndex {986+ if best >= 0 {987+ out = append(out, best)988+ }989+ }990+ return out991+}992+993+func drawDisc(img *image.RGBA, cx, cy, radius float64, c color.RGBA) {994+ minX := int(math.Floor(cx - radius))995+ maxX := int(math.Ceil(cx + radius))996+ minY := int(math.Floor(cy - radius))997+ maxY := int(math.Ceil(cy + radius))998+ r2 := radius * radius999+ for y := minY; y <= maxY; y++ {1000+ for x := minX; x <= maxX; x++ {1001+ dx := float64(x) + 0.5 - cx1002+ dy := float64(y) + 0.5 - cy1003+ if dx*dx+dy*dy <= r2 {1004+ blendPixel(img, x, y, c)1005+ }1006+ }1007+ }1008+}1009+1010+func drawLine(img *image.RGBA, a, b coverVec2, c color.RGBA) {1011+ dx := b.x - a.x1012+ dy := b.y - a.y1013+ steps := int(math.Max(math.Abs(dx), math.Abs(dy)))1014+ if steps <= 0 {1015+ blendPixel(img, int(a.x), int(a.y), c)1016+ return1017+ }1018+ for i := 0; i <= steps; i++ {1019+ t := float64(i) / float64(steps)1020+ drawDisc(img, a.x+dx*t, a.y+dy*t, 0.82, c)1021+ }1022+}1023+1024+func blendPixel(img *image.RGBA, x, y int, src color.RGBA) {1025+ if !image.Pt(x, y).In(img.Rect) {1026+ return1027+ }1028+ dst := img.RGBAAt(x, y)1029+ alpha := float64(src.A) / 2551030+ inv := 1 - alpha1031+ img.SetRGBA(x, y, color.RGBA{1032+ R: uint8(float64(src.R)*alpha + float64(dst.R)*inv),1033+ G: uint8(float64(src.G)*alpha + float64(dst.G)*inv),1034+ B: uint8(float64(src.B)*alpha + float64(dst.B)*inv),1035+ A: 255,1036+ })1037+}1038+1039+func rgbToColor(c RGB) color.RGBA {1040+ return color.RGBA{R: c.R, G: c.G, B: c.B, A: 255}1041+}1042+1043+func rgbToHSV(c color.RGBA) (h, s, v float64) {1044+ r := float64(c.R) / 2551045+ g := float64(c.G) / 2551046+ b := float64(c.B) / 2551047+ maxC := math.Max(r, math.Max(g, b))1048+ minC := math.Min(r, math.Min(g, b))1049+ chroma := maxC - minC1050+1051+ v = maxC1052+ if maxC == 0 {1053+ s = 01054+ } else {1055+ s = chroma / maxC1056+ }1057+ if chroma == 0 {1058+ return 0, s, v1059+ }1060+1061+ switch maxC {1062+ case r:1063+ h = math.Mod((g-b)/chroma, 6)1064+ case g:1065+ h = (b-r)/chroma + 21066+ default:1067+ h = (r-g)/chroma + 41068+ }1069+ h /= 61070+ if h < 0 {1071+ h += 11072+ }1073+ return h, s, v1074+}1075+1076+func hsvToRGB(h, s, v float64) color.RGBA {1077+ h = wrapHue(h)1078+ s = clamp01(s)1079+ v = clamp01(v)1080+ if s == 0 {1081+ ch := uint8(math.Round(v * 255))1082+ return color.RGBA{R: ch, G: ch, B: ch, A: 255}1083+ }1084+1085+ h6 := h * 61086+ segment := math.Floor(h6)1087+ f := h6 - segment1088+ p := v * (1 - s)1089+ q := v * (1 - s*f)1090+ t := v * (1 - s*(1-f))1091+1092+ var r, g, b float641093+ switch int(segment) % 6 {1094+ case 0:1095+ r, g, b = v, t, p1096+ case 1:1097+ r, g, b = q, v, p1098+ case 2:1099+ r, g, b = p, v, t1100+ case 3:1101+ r, g, b = p, q, v1102+ case 4:1103+ r, g, b = t, p, v1104+ default:1105+ r, g, b = v, p, q1106+ }1107+1108+ return color.RGBA{1109+ R: uint8(math.Round(clamp01(r) * 255)),1110+ G: uint8(math.Round(clamp01(g) * 255)),1111+ B: uint8(math.Round(clamp01(b) * 255)),1112+ A: 255,1113+ }1114+}1115+1116+func wrapHue(h float64) float64 {1117+ if h >= 0 && h < 1 {1118+ return h1119+ }1120+ h = math.Mod(h, 1)1121+ if h < 0 {1122+ h += 11123+ }1124+ return h1125+}1126+1127+func lerpRGB(a, b color.RGBA, t float64) color.RGBA {1128+ t = clamp01(t)1129+ return color.RGBA{1130+ R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t),1131+ G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t),1132+ B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t),1133+ A: 255,1134+ }1135+}1136+1137+func blendRGB(base, top color.RGBA, amount float64) color.RGBA {1138+ return lerpRGB(base, top, clamp01(amount))1139+}1140+1141+func radialFalloff(x, y, cx, cy, spread float64) float64 {1142+ dx := x - cx1143+ dy := y - cy1144+ distance := math.Sqrt(dx*dx + dy*dy)1145+ if distance >= spread {1146+ return 01147+ }1148+ q := 1 - distance/spread1149+ return q * q1150+}1151+1152+func powClamp(v, p float64) float64 {1153+ return math.Pow(clamp01(v), p)1154+}1155+1156+func clamp01(v float64) float64 {1157+ if v < 0 {1158+ return 01159+ }1160+ if v > 1 {1161+ return 11162+ }1163+ return v1164+}1165+1166+func clamp(min, max, v float64) float64 {1167+ if v < min {1168+ return min1169+ }1170+ if v > max {1171+ return max1172+ }1173+ return v1174+}1175+1176+func maxInt(a, b int) int {1177+ if a > b {1178+ return a1179+ }1180+ return b1181+}1182+1183+func minInt(a, b int) int {1184+ if a < b {1185+ return a1186+ }1187+ return b1188+}1189+1190+func maxUint8(a, b uint8) uint8 {1191+ if a > b {1192+ return a1193+ }1194+ return b1195+}1196+1197+func minUint8(a, b uint8) uint8 {1198+ if a < b {1199+ return a1200+ }1201+ return b1202+}1203+1204+func jitterRGBA(c color.RGBA, rng *rand.Rand, spread int) color.RGBA {1205+ if spread <= 0 {1206+ return c1207+ }1208+ half := spread / 21209+ return color.RGBA{1210+ R: shiftChannel(c.R, rng.Intn(spread)-half),1211+ G: shiftChannel(c.G, rng.Intn(spread)-half),1212+ B: shiftChannel(c.B, rng.Intn(spread)-half),1213+ A: c.A,1214+ }1215+}1216+1217+func shiftChannel(ch uint8, delta int) uint8 {1218+ v := int(ch) + delta1219+ if v < 0 {1220+ return 01221+ }1222+ if v > 255 {1223+ return 2551224+ }1225+ return uint8(v)1226+}1227+1228+func coverStreamRNG(seed uint64, stream string) *rand.Rand {1229+ h := fnv.New64a()1230+ var raw [8]byte1231+ binary.LittleEndian.PutUint64(raw[:], seed)1232+ _, _ = h.Write(raw[:])1233+ _, _ = h.Write([]byte(stream))1234+ return rand.New(rand.NewSource(int64(h.Sum64())))1235+}1236+1237+func coverSeedHash(text string) uint64 {1238+ h := fnv.New64a()1239+ h.Write([]byte(text))1240+ return h.Sum64()1241+}
A pdfexport/cover_art_test.go +203 -0
@@ -0,0 +1,203 @@
1+package pdfexport2+3+import (4+ "bytes"5+ "fmt"6+ "image"7+ "image/color"8+ "image/png"9+ "math"10+ "testing"11+)12+13+// --- Cover art seeding (P1) ---14+15+func TestRenderCoverArtworkPNGDeterministic(t *testing.T) {16+ base := RGB{R: 86, G: 124, B: 149}17+ seed := "quiet-fjord"18+19+ first := renderCoverArtworkPNG(seed, "front", base)20+ second := renderCoverArtworkPNG(seed, "front", base)21+22+ if len(first) == 0 || len(second) == 0 {23+ t.Fatalf("renderCoverArtworkPNG returned empty output")24+ }25+ if !bytes.Equal(first, second) {26+ t.Fatalf("expected identical PNG bytes for identical seed and variant")27+ }28+}29+30+func TestRenderCoverArtworkPNGVariesBySeedAndVariant(t *testing.T) {31+ if testing.Short() {32+ t.Skip("skipping cover art variation check in short mode")33+ }34+35+ base := RGB{R: 90, G: 128, B: 154}36+ frontA := renderCoverArtworkPNG("quiet-fjord", "front", base)37+ frontB := renderCoverArtworkPNG("quiet-fjord-alt", "front", base)38+ backA := renderCoverArtworkPNG("quiet-fjord", "back", base)39+40+ assertArtworkDiff(t, frontA, frontB, 0.03)41+ assertArtworkDiff(t, frontA, backA, 0.03)42+}43+44+func TestCoverArtworkBatchSaturationFloor(t *testing.T) {45+ if testing.Short() {46+ t.Skip("skipping batch saturation check in short mode")47+ }48+49+ base := RGB{R: 90, G: 128, B: 154}50+ seeds := coverSeedSamples(30)51+ var total float6452+ for _, seed := range seeds {53+ img := decodePNG(t, renderCoverArtworkPNG(seed, "front", base))54+ total += sampledMeanSaturation(img, 14)55+ }56+ meanSaturation := total / float64(len(seeds))57+ if meanSaturation < 0.24 {58+ t.Fatalf("mean saturation too low: got %.4f want >= 0.2400", meanSaturation)59+ }60+ if meanSaturation > 0.36 {61+ t.Fatalf("mean saturation unexpectedly high: got %.4f want <= 0.3600", meanSaturation)62+ }63+}64+65+func TestCoverArtworkBatchDiversityFloor(t *testing.T) {66+ if testing.Short() {67+ t.Skip("skipping batch diversity check in short mode")68+ }69+70+ base := RGB{R: 90, G: 128, B: 154}71+ seeds := coverSeedSamples(30)72+ images := make([]image.Image, 0, len(seeds))73+ for _, seed := range seeds {74+ images = append(images, decodePNG(t, renderCoverArtworkPNG(seed, "front", base)))75+ }76+77+ minDiff := math.MaxFloat6478+ var total float6479+ var pairs int80+ for i := range images {81+ for j := i + 1; j < len(images); j++ {82+ diff := sampledMeanChannelDiff(images[i], images[j], 12)83+ if diff < minDiff {84+ minDiff = diff85+ }86+ total += diff87+ pairs++88+ }89+ }90+ meanDiff := total / float64(pairs)91+ if meanDiff < 0.090 {92+ t.Fatalf("mean pairwise diff too low: got %.4f want >= 0.0900", meanDiff)93+ }94+ if minDiff < 0.040 {95+ t.Fatalf("minimum pairwise diff too low: got %.4f want >= 0.0400", minDiff)96+ }97+}98+99+func TestCoverArtworkArchetypeCoverage(t *testing.T) {100+ seen := make(map[coverArchetype]bool, int(coverArchetypeCount))101+ for i := 1; i <= 120; i++ {102+ seed := fmt.Sprintf("coverage-%03d|front", i)103+ seen[coverArchetypeForSeed(coverSeedHash(seed))] = true104+ }105+106+ if len(seen) != int(coverArchetypeCount) {107+ t.Fatalf("archetype coverage incomplete: got %d want %d", len(seen), coverArchetypeCount)108+ }109+}110+111+func TestRenderCoverArtworkPNGHandlesLowAndHighChromaBase(t *testing.T) {112+ lowChroma := RGB{R: 128, G: 128, B: 128}113+ highChroma := RGB{R: 245, G: 62, B: 58}114+115+ low := renderCoverArtworkPNG("chroma-check", "front", lowChroma)116+ high := renderCoverArtworkPNG("chroma-check", "front", highChroma)117+118+ if len(low) == 0 || len(high) == 0 {119+ t.Fatalf("renderCoverArtworkPNG returned empty output for chroma check")120+ }121+ assertArtworkDiff(t, low, high, 0.03)122+}123+124+func assertArtworkDiff(t *testing.T, left, right []byte, minMeanDiff float64) {125+ t.Helper()126+127+ if bytes.Equal(left, right) {128+ t.Fatalf("unexpected byte-identical images for different inputs")129+ }130+131+ imgA := decodePNG(t, left)132+ imgB := decodePNG(t, right)133+ diff := sampledMeanChannelDiff(imgA, imgB, 12)134+ if diff < minMeanDiff {135+ t.Fatalf("mean sampled channel diff too low: got %.4f want >= %.4f", diff, minMeanDiff)136+ }137+}138+139+func decodePNG(t *testing.T, data []byte) image.Image {140+ t.Helper()141+ img, err := png.Decode(bytes.NewReader(data))142+ if err != nil {143+ t.Fatalf("png.Decode error: %v", err)144+ }145+ return img146+}147+148+func sampledMeanChannelDiff(a, b image.Image, step int) float64 {149+ bounds := a.Bounds()150+ if step < 1 {151+ step = 1152+ }153+154+ var total float64155+ var samples int156+ for y := bounds.Min.Y; y < bounds.Max.Y; y += step {157+ for x := bounds.Min.X; x < bounds.Max.X; x += step {158+ ar, ag, ab, _ := a.At(x, y).RGBA()159+ br, bg, bb, _ := b.At(x, y).RGBA()160+ total += math.Abs(float64(ar)-float64(br)) / 65535161+ total += math.Abs(float64(ag)-float64(bg)) / 65535162+ total += math.Abs(float64(ab)-float64(bb)) / 65535163+ samples += 3164+ }165+ }166+167+ if samples == 0 {168+ return 0169+ }170+ return total / float64(samples)171+}172+173+func sampledMeanSaturation(img image.Image, step int) float64 {174+ bounds := img.Bounds()175+ if step < 1 {176+ step = 1177+ }178+179+ var total float64180+ var samples int181+ for y := bounds.Min.Y; y < bounds.Max.Y; y += step {182+ for x := bounds.Min.X; x < bounds.Max.X; x += step {183+ r, g, b, _ := img.At(x, y).RGBA()184+ c := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255}185+ _, sat, _ := rgbToHSV(c)186+ total += sat187+ samples++188+ }189+ }190+191+ if samples == 0 {192+ return 0193+ }194+ return total / float64(samples)195+}196+197+func coverSeedSamples(n int) []string {198+ out := make([]string, 0, n)199+ for i := 1; i <= n; i++ {200+ out = append(out, fmt.Sprintf("test-cover-%02d", i))201+ }202+ return out203+}
A pdfexport/font.go +20 -0
A pdfexport/font_test.go +73 -0
A pdfexport/fonts.go +34 -0
A pdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttf +0 -0

Binary file.

A pdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt +93 -0
@@ -0,0 +1,93 @@
1+Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next) 2+ 3+This Font Software is licensed under the SIL Open Font License, Version 1.1. 4+This license is copied below, and is also available with a FAQ at: 5+https://openfontlicense.org 6+ 7+ 8+----------------------------------------------------------- 9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10+----------------------------------------------------------- 11+ 12+PREAMBLE 13+The goals of the Open Font License (OFL) are to stimulate worldwide 14+development of collaborative font projects, to support the font creation 15+efforts of academic and linguistic communities, and to provide a free and 16+open framework in which fonts may be shared and improved in partnership 17+with others. 18+ 19+The OFL allows the licensed fonts to be used, studied, modified and 20+redistributed freely as long as they are not sold by themselves. The 21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved 23+names are not used by derivative works. The fonts and derivatives, 24+however, cannot be released under any other type of license. The 25+requirement for fonts to remain under this license does not apply 26+to any document created using the fonts or their derivatives. 27+ 28+DEFINITIONS 29+"Font Software" refers to the set of files released by the Copyright 30+Holder(s) under this license and clearly marked as such. This may 31+include source files, build scripts and documentation. 32+ 33+"Reserved Font Name" refers to any names specified as such after the 34+copyright statement(s). 35+ 36+"Original Version" refers to the collection of Font Software components as 37+distributed by the Copyright Holder(s). 38+ 39+"Modified Version" refers to any derivative made by adding to, deleting, 40+or substituting -- in part or in whole -- any of the components of the 41+Original Version, by changing formats or by porting the Font Software to a 42+new environment. 43+ 44+"Author" refers to any designer, engineer, programmer, technical 45+writer or other person who contributed to the Font Software. 46+ 47+PERMISSION & CONDITIONS 48+Permission is hereby granted, free of charge, to any person obtaining 49+a copy of the Font Software, to use, study, copy, merge, embed, modify, 50+redistribute, and sell modified and unmodified copies of the Font 51+Software, subject to the following conditions: 52+ 53+1) Neither the Font Software nor any of its individual components, 54+in Original or Modified Versions, may be sold by itself. 55+ 56+2) Original or Modified Versions of the Font Software may be bundled, 57+redistributed and/or sold with any software, provided that each copy 58+contains the above copyright notice and this license. These can be 59+included either as stand-alone text files, human-readable headers or 60+in the appropriate machine-readable metadata fields within text or 61+binary files as long as those fields can be easily viewed by the user. 62+ 63+3) No Modified Version of the Font Software may use the Reserved Font 64+Name(s) unless explicit written permission is granted by the corresponding 65+Copyright Holder. This restriction only applies to the primary font name as 66+presented to the users. 67+ 68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69+Software shall not be used to promote, endorse or advertise any 70+Modified Version, except to acknowledge the contribution(s) of the 71+Copyright Holder(s) and the Author(s) or with their explicit written 72+permission. 73+ 74+5) The Font Software, modified or unmodified, in part or in whole, 75+must be distributed entirely under this license, and must not be 76+distributed under any other license. The requirement for fonts to 77+remain under this license does not apply to any document created 78+using the Font Software. 79+ 80+TERMINATION 81+This license becomes null and void if any of the above conditions are 82+not met. 83+ 84+DISCLAIMER 85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93+OTHER DEALINGS IN THE FONT SOFTWARE.
A pdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf +0 -0

Binary file.

A pdfexport/fonts/DMSerifDisplay-OFL.txt +93 -0
@@ -0,0 +1,93 @@
1+Copyright 2014-2018 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. Copyright 2019 Google LLC. 2+ 3+This Font Software is licensed under the SIL Open Font License, Version 1.1. 4+ 5+This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL 6+ 7+ 8+----------------------------------------------------------- 9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10+----------------------------------------------------------- 11+ 12+PREAMBLE 13+The goals of the Open Font License (OFL) are to stimulate worldwide 14+development of collaborative font projects, to support the font creation 15+efforts of academic and linguistic communities, and to provide a free and 16+open framework in which fonts may be shared and improved in partnership 17+with others. 18+ 19+The OFL allows the licensed fonts to be used, studied, modified and 20+redistributed freely as long as they are not sold by themselves. The 21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved 23+names are not used by derivative works. The fonts and derivatives, 24+however, cannot be released under any other type of license. The 25+requirement for fonts to remain under this license does not apply 26+to any document created using the fonts or their derivatives. 27+ 28+DEFINITIONS 29+"Font Software" refers to the set of files released by the Copyright 30+Holder(s) under this license and clearly marked as such. This may 31+include source files, build scripts and documentation. 32+ 33+"Reserved Font Name" refers to any names specified as such after the 34+copyright statement(s). 35+ 36+"Original Version" refers to the collection of Font Software components as 37+distributed by the Copyright Holder(s). 38+ 39+"Modified Version" refers to any derivative made by adding to, deleting, 40+or substituting -- in part or in whole -- any of the components of the 41+Original Version, by changing formats or by porting the Font Software to a 42+new environment. 43+ 44+"Author" refers to any designer, engineer, programmer, technical 45+writer or other person who contributed to the Font Software. 46+ 47+PERMISSION & CONDITIONS 48+Permission is hereby granted, free of charge, to any person obtaining 49+a copy of the Font Software, to use, study, copy, merge, embed, modify, 50+redistribute, and sell modified and unmodified copies of the Font 51+Software, subject to the following conditions: 52+ 53+1) Neither the Font Software nor any of its individual components, 54+in Original or Modified Versions, may be sold by itself. 55+ 56+2) Original or Modified Versions of the Font Software may be bundled, 57+redistributed and/or sold with any software, provided that each copy 58+contains the above copyright notice and this license. These can be 59+included either as stand-alone text files, human-readable headers or 60+in the appropriate machine-readable metadata fields within text or 61+binary files as long as those fields can be easily viewed by the user. 62+ 63+3) No Modified Version of the Font Software may use the Reserved Font 64+Name(s) unless explicit written permission is granted by the corresponding 65+Copyright Holder. This restriction only applies to the primary font name as 66+presented to the users. 67+ 68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69+Software shall not be used to promote, endorse or advertise any 70+Modified Version, except to acknowledge the contribution(s) of the 71+Copyright Holder(s) and the Author(s) or with their explicit written 72+permission. 73+ 74+5) The Font Software, modified or unmodified, in part or in whole, 75+must be distributed entirely under this license, and must not be 76+distributed under any other license. The requirement for fonts to 77+remain under this license does not apply to any document created 78+using the Font Software. 79+ 80+TERMINATION 81+This license becomes null and void if any of the above conditions are 82+not met. 83+ 84+DISCLAIMER 85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93+OTHER DEALINGS IN THE FONT SOFTWARE.
A pdfexport/fonts/DMSerifDisplay-Regular.ttf +0 -0

Binary file.

A pdfexport/jsonl.go +152 -0
@@ -0,0 +1,152 @@
1+package pdfexport2+3+import (4+ "bufio"5+ "encoding/json"6+ "fmt"7+ "os"8+ "path/filepath"9+ "strings"10+11+ "github.com/FelineStateMachine/puzzletea/game"12+)13+14+const ExportSchemaV1 = "puzzletea.export.v1"15+16+type JSONLRecord struct {17+ Schema string `json:"schema"`18+ Pack JSONLPackMeta `json:"pack"`19+ Puzzle JSONLPuzzle `json:"puzzle"`20+}21+22+type JSONLPackMeta struct {23+ Generated string `json:"generated"`24+ Version string `json:"version"`25+ Category string `json:"category"`26+ ModeSelection string `json:"mode_selection"`27+ Count int `json:"count"`28+ Seed string `json:"seed"`29+}30+31+type JSONLPuzzle struct {32+ Index int `json:"index"`33+ Name string `json:"name"`34+ Game string `json:"game"`35+ Mode string `json:"mode"`36+ Save json.RawMessage `json:"save"`37+}38+39+func ParseJSONLFiles(paths []string) ([]PackDocument, error) {40+ docs := make([]PackDocument, 0, len(paths))41+ for _, path := range paths {42+ doc, err := ParseJSONLFile(path)43+ if err != nil {44+ return nil, err45+ }46+ docs = append(docs, doc)47+ }48+ return docs, nil49+}50+51+func ParseJSONLFile(path string) (PackDocument, error) {52+ if !strings.EqualFold(filepath.Ext(path), ".jsonl") {53+ return PackDocument{}, fmt.Errorf("%s: expected .jsonl input", path)54+ }55+56+ f, err := os.Open(path)57+ if err != nil {58+ return PackDocument{}, fmt.Errorf("open input jsonl: %w", err)59+ }60+ defer f.Close()61+62+ scanner := bufio.NewScanner(f)63+ buf := make([]byte, 0, 64*1024)64+ scanner.Buffer(buf, 16*1024*1024)65+66+ doc := PackDocument{SourcePath: path}67+ doc.Metadata.SourceFileName = filepath.Base(path)68+ puzzles := []Puzzle{}69+ lineNo := 070+ seenAny := false71+72+ for scanner.Scan() {73+ lineNo++74+ line := strings.TrimSpace(scanner.Text())75+ if line == "" {76+ continue77+ }78+ seenAny = true79+80+ var record JSONLRecord81+ if err := json.Unmarshal([]byte(line), &record); err != nil {82+ return PackDocument{}, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err)83+ }84+ if record.Schema != ExportSchemaV1 {85+ return PackDocument{}, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema)86+ }87+88+ category := strings.TrimSpace(record.Puzzle.Game)89+ if category == "" {90+ category = strings.TrimSpace(record.Pack.Category)91+ }92+ mode := strings.TrimSpace(record.Puzzle.Mode)93+ if mode == "" {94+ mode = strings.TrimSpace(record.Pack.ModeSelection)95+ }96+97+ p := Puzzle{98+ SourcePath: path,99+ SourceFileName: filepath.Base(path),100+ Category: category,101+ ModeSelection: mode,102+ Name: record.Puzzle.Name,103+ Index: record.Puzzle.Index,104+ SaveData: append([]byte(nil), record.Puzzle.Save...),105+ }106+107+ adapter, ok := game.LookupPrintAdapter(category)108+ if !ok {109+ continue110+ }111+ payload, err := adapter.BuildPDFPayload(p.SaveData)112+ if err != nil {113+ return PackDocument{}, fmt.Errorf("%s:%d: build print payload: %w", path, lineNo, err)114+ }115+ if game.IsNilPrintPayload(payload) {116+ continue117+ }118+ p.PrintPayload = payload119+120+ // Metadata should reflect the first printable record, not merely the121+ // first syntactically valid line in the file.122+ if doc.Metadata.GeneratedRaw == "" {123+ doc.Metadata.GeneratedRaw = record.Pack.Generated124+ doc.Metadata.Version = record.Pack.Version125+ doc.Metadata.Count = record.Pack.Count126+ doc.Metadata.Seed = record.Pack.Seed127+128+ doc.Metadata.Category = strings.TrimSpace(p.Category)129+ if doc.Metadata.Category == "" {130+ doc.Metadata.Category = record.Pack.Category131+ }132+ doc.Metadata.ModeSelection = strings.TrimSpace(p.ModeSelection)133+ if doc.Metadata.ModeSelection == "" {134+ doc.Metadata.ModeSelection = record.Pack.ModeSelection135+ }136+ }137+138+ puzzles = append(puzzles, p)139+ }140+141+ if err := scanner.Err(); err != nil {142+ return PackDocument{}, fmt.Errorf("read input jsonl: %w", err)143+ }144+ if !seenAny {145+ return PackDocument{}, fmt.Errorf("%s: input jsonl is empty", path)146+ }147+ if doc.Metadata.Count == 0 {148+ doc.Metadata.Count = len(puzzles)149+ }150+ doc.Puzzles = puzzles151+ return doc, nil152+}
A pdfexport/jsonl_test.go +494 -0
@@ -0,0 +1,494 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "os"6+ "path/filepath"7+ "strings"8+ "sync"9+ "testing"10+11+ "codeberg.org/go-pdf/fpdf"12+ "github.com/FelineStateMachine/puzzletea/game"13+)14+15+func TestParseJSONLFile(t *testing.T) {16+ path := filepath.Join(t.TempDir(), "pack.jsonl")17+ record := JSONLRecord{18+ Schema: ExportSchemaV1,19+ Pack: JSONLPackMeta{20+ Generated: "2026-02-22T10:00:00Z",21+ Version: "v-test",22+ Category: "Nonogram",23+ ModeSelection: "Mini",24+ Count: 1,25+ Seed: "seed-1",26+ },27+ Puzzle: JSONLPuzzle{28+ Index: 1,29+ Name: "ember-newt",30+ Game: "Nonogram",31+ Mode: "Mini",32+ Save: json.RawMessage(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`),33+ },34+ }35+ writeSingleJSONLRecord(t, path, record)36+37+ doc, err := ParseJSONLFile(path)38+ if err != nil {39+ t.Fatal(err)40+ }41+ if got, want := len(doc.Puzzles), 1; got != want {42+ t.Fatalf("puzzles = %d, want %d", got, want)43+ }44+ payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData)45+ if !ok || payload == nil {46+ t.Fatal("expected nonogram print payload from save hydration")47+ }48+}49+50+func TestParseJSONLFileHydratesNonogramFromSaveWithoutSnippet(t *testing.T) {51+ path := filepath.Join(t.TempDir(), "nonogram-save-only.jsonl")52+ record := JSONLRecord{53+ Schema: ExportSchemaV1,54+ Pack: JSONLPackMeta{55+ Generated: "2026-02-22T10:00:00Z",56+ Version: "v-test",57+ Category: "Nonogram",58+ ModeSelection: "Mini",59+ Count: 1,60+ },61+ Puzzle: JSONLPuzzle{62+ Index: 1,63+ Name: "fern-owl",64+ Game: "Nonogram",65+ Mode: "Mini",66+ Save: json.RawMessage(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`),67+ },68+ }69+ writeSingleJSONLRecord(t, path, record)70+71+ doc, err := ParseJSONLFile(path)72+ if err != nil {73+ t.Fatal(err)74+ }75+ if got, want := len(doc.Puzzles), 1; got != want {76+ t.Fatalf("puzzles = %d, want %d", got, want)77+ }78+ payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData)79+ if !ok || payload == nil {80+ t.Fatal("expected nonogram print payload from save hydration")81+ }82+}83+84+func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) {85+ path := filepath.Join(t.TempDir(), "pack.md")86+ if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {87+ t.Fatal(err)88+ }89+90+ _, err := ParseJSONLFile(path)91+ if err == nil {92+ t.Fatal("expected extension validation error")93+ }94+ if !strings.Contains(err.Error(), "expected .jsonl input") {95+ t.Fatalf("unexpected error: %v", err)96+ }97+}98+99+func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) {100+ path := filepath.Join(t.TempDir(), "takuzu-pack.jsonl")101+ record := JSONLRecord{102+ Schema: ExportSchemaV1,103+ Pack: JSONLPackMeta{104+ Generated: "2026-02-22T10:00:00Z",105+ Version: "v-test",106+ Category: "Takuzu",107+ ModeSelection: "Beginner",108+ Count: 1,109+ Seed: "seed-2",110+ },111+ Puzzle: JSONLPuzzle{112+ Index: 1,113+ Name: "binary-wave",114+ Game: "Takuzu",115+ Mode: "Beginner",116+ Save: json.RawMessage(`{"size":2,"state":"01\n10","provided":"##\n#."}`),117+ },118+ }119+ writeSingleJSONLRecord(t, path, record)120+121+ doc, err := ParseJSONLFile(path)122+ if err != nil {123+ t.Fatal(err)124+ }125+ if got, want := len(doc.Puzzles), 1; got != want {126+ t.Fatalf("puzzles = %d, want %d", got, want)127+ }128+ payload, ok := doc.Puzzles[0].PrintPayload.(*TakuzuData)129+ if !ok || payload == nil {130+ t.Fatal("expected takuzu print payload from save hydration")131+ }132+ if got, want := payload.Givens[1][1], ""; got != want {133+ t.Fatalf("takuzu row 1 col 1 = %q, want empty", got)134+ }135+}136+137+func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) {138+ path := filepath.Join(t.TempDir(), "sudoku.jsonl")139+ record := JSONLRecord{140+ Schema: ExportSchemaV1,141+ Pack: JSONLPackMeta{142+ Generated: "2026-02-22T10:00:00Z",143+ Version: "v-test",144+ Category: "Sudoku",145+ ModeSelection: "Easy",146+ Count: 1,147+ },148+ Puzzle: JSONLPuzzle{149+ Index: 1,150+ Name: "moss-pine",151+ Game: "Sudoku",152+ Mode: "Easy",153+ Save: json.RawMessage(`{"grid":"500000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":5}]}`),154+ },155+ }156+ writeSingleJSONLRecord(t, path, record)157+158+ doc, err := ParseJSONLFile(path)159+ if err != nil {160+ t.Fatal(err)161+ }162+ if got, want := len(doc.Puzzles), 1; got != want {163+ t.Fatalf("puzzles = %d, want %d", got, want)164+ }165+ payload, ok := doc.Puzzles[0].PrintPayload.(*SudokuData)166+ if !ok || payload == nil {167+ t.Fatal("expected sudoku print payload from save hydration")168+ }169+ if got, want := payload.Givens[0][0], 5; got != want {170+ t.Fatalf("sudoku givens[0][0] = %d, want %d", got, want)171+ }172+}173+174+func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) {175+ path := filepath.Join(t.TempDir(), "wordsearch.jsonl")176+ record := JSONLRecord{177+ Schema: ExportSchemaV1,178+ Pack: JSONLPackMeta{179+ Generated: "2026-02-22T10:00:00Z",180+ Version: "v-test",181+ Category: "Word Search",182+ ModeSelection: "Standard",183+ Count: 1,184+ },185+ Puzzle: JSONLPuzzle{186+ Index: 1,187+ Name: "sage-sky",188+ Game: "Word Search",189+ Mode: "Standard",190+ Save: json.RawMessage(`{"width":3,"height":3,"grid":"abc\ndef\nghi","words":[{"text":"ace"},{"text":"fig"}]}`),191+ },192+ }193+ writeSingleJSONLRecord(t, path, record)194+195+ doc, err := ParseJSONLFile(path)196+ if err != nil {197+ t.Fatal(err)198+ }199+ if got, want := len(doc.Puzzles), 1; got != want {200+ t.Fatalf("puzzles = %d, want %d", got, want)201+ }202+ payload, ok := doc.Puzzles[0].PrintPayload.(*WordSearchData)203+ if !ok || payload == nil {204+ t.Fatal("expected word search print payload from save hydration")205+ }206+ if got, want := len(payload.Words), 2; got != want {207+ t.Fatalf("word count = %d, want %d", got, want)208+ }209+ if got, want := payload.Words[0], "ACE"; got != want {210+ t.Fatalf("first word = %q, want %q", got, want)211+ }212+}213+214+func TestParseJSONLFileHydratesNurikabeFromSave(t *testing.T) {215+ path := filepath.Join(t.TempDir(), "nurikabe.jsonl")216+ record := JSONLRecord{217+ Schema: ExportSchemaV1,218+ Pack: JSONLPackMeta{219+ Generated: "2026-02-22T10:00:00Z",220+ Version: "v-test",221+ Category: "Nurikabe",222+ ModeSelection: "Mini",223+ Count: 1,224+ },225+ Puzzle: JSONLPuzzle{226+ Index: 1,227+ Name: "mist-pond",228+ Game: "Nurikabe",229+ Mode: "Mini",230+ Save: json.RawMessage(`{"width":2,"height":2,"clues":"1,0\n0,2","marks":"??\n??"}`),231+ },232+ }233+ writeSingleJSONLRecord(t, path, record)234+235+ doc, err := ParseJSONLFile(path)236+ if err != nil {237+ t.Fatal(err)238+ }239+ if got, want := len(doc.Puzzles), 1; got != want {240+ t.Fatalf("puzzles = %d, want %d", got, want)241+ }242+ payload, ok := doc.Puzzles[0].PrintPayload.(*NurikabeData)243+ if !ok || payload == nil {244+ t.Fatal("expected nurikabe print payload from save hydration")245+ }246+ if got, want := payload.Clues[1][1], 2; got != want {247+ t.Fatalf("nurikabe clues[1][1] = %d, want %d", got, want)248+ }249+}250+251+func TestParseJSONLFileHydratesShikakuFromSave(t *testing.T) {252+ path := filepath.Join(t.TempDir(), "shikaku.jsonl")253+ record := JSONLRecord{254+ Schema: ExportSchemaV1,255+ Pack: JSONLPackMeta{256+ Generated: "2026-02-22T10:00:00Z",257+ Version: "v-test",258+ Category: "Shikaku",259+ ModeSelection: "Mini",260+ Count: 1,261+ },262+ Puzzle: JSONLPuzzle{263+ Index: 1,264+ Name: "ridge-glen",265+ Game: "Shikaku",266+ Mode: "Mini",267+ Save: json.RawMessage(`{"width":2,"height":2,"clues":[{"x":0,"y":0,"value":1},{"x":1,"y":1,"value":4}]}`),268+ },269+ }270+ writeSingleJSONLRecord(t, path, record)271+272+ doc, err := ParseJSONLFile(path)273+ if err != nil {274+ t.Fatal(err)275+ }276+ if got, want := len(doc.Puzzles), 1; got != want {277+ t.Fatalf("puzzles = %d, want %d", got, want)278+ }279+ payload, ok := doc.Puzzles[0].PrintPayload.(*ShikakuData)280+ if !ok || payload == nil {281+ t.Fatal("expected shikaku print payload from save hydration")282+ }283+ if got, want := payload.Clues[1][1], 4; got != want {284+ t.Fatalf("shikaku clues[1][1] = %d, want %d", got, want)285+ }286+}287+288+func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) {289+ path := filepath.Join(t.TempDir(), "hashi.jsonl")290+ record := JSONLRecord{291+ Schema: ExportSchemaV1,292+ Pack: JSONLPackMeta{293+ Generated: "2026-02-22T10:00:00Z",294+ Version: "v-test",295+ Category: "Hashiwokakero",296+ ModeSelection: "Standard",297+ Count: 1,298+ },299+ Puzzle: JSONLPuzzle{300+ Index: 1,301+ Name: "reef-palm",302+ Game: "Hashiwokakero",303+ Mode: "Standard",304+ Save: json.RawMessage(`{"width":7,"height":7,"islands":[{"x":0,"y":0,"required":3},{"x":6,"y":6,"required":2}],"bridges":[]}`),305+ },306+ }307+ writeSingleJSONLRecord(t, path, record)308+309+ doc, err := ParseJSONLFile(path)310+ if err != nil {311+ t.Fatal(err)312+ }313+ if got, want := len(doc.Puzzles), 1; got != want {314+ t.Fatalf("puzzles = %d, want %d", got, want)315+ }316+ payload, ok := doc.Puzzles[0].PrintPayload.(*HashiData)317+ if !ok || payload == nil {318+ t.Fatal("expected hashi print payload from save hydration")319+ }320+ if got, want := len(payload.Islands), 2; got != want {321+ t.Fatalf("hashi island count = %d, want %d", got, want)322+ }323+}324+325+func TestParseJSONLFileSilentlySkipsUnsupportedGame(t *testing.T) {326+ path := filepath.Join(t.TempDir(), "lights.jsonl")327+ record := JSONLRecord{328+ Schema: ExportSchemaV1,329+ Pack: JSONLPackMeta{330+ Generated: "2026-02-22T10:00:00Z",331+ Version: "v-test",332+ Category: "Lights Out",333+ ModeSelection: "Standard",334+ Count: 1,335+ },336+ Puzzle: JSONLPuzzle{337+ Index: 1,338+ Name: "glow-shore",339+ Game: "Lights Out",340+ Mode: "Standard",341+ Save: json.RawMessage(`{"size":5}`),342+ },343+ }344+ writeSingleJSONLRecord(t, path, record)345+346+ doc, err := ParseJSONLFile(path)347+ if err != nil {348+ t.Fatalf("expected silent no-op for unsupported game, got: %v", err)349+ }350+ if got := len(doc.Puzzles); got != 0 {351+ t.Fatalf("puzzles = %d, want 0", got)352+ }353+}354+355+func TestParseJSONLFileMetadataFromFirstPrintableRecord(t *testing.T) {356+ path := filepath.Join(t.TempDir(), "mixed.jsonl")357+358+ records := []JSONLRecord{359+ {360+ Schema: ExportSchemaV1,361+ Pack: JSONLPackMeta{362+ Generated: "2026-02-22T10:00:00Z",363+ Version: "v-test",364+ Category: "Lights Out",365+ ModeSelection: "Standard",366+ Count: 2,367+ Seed: "seed-unsupported",368+ },369+ Puzzle: JSONLPuzzle{370+ Index: 1,371+ Name: "glow-shore",372+ Game: "Lights Out",373+ Mode: "Standard",374+ Save: json.RawMessage(`{"size":5}`),375+ },376+ },377+ {378+ Schema: ExportSchemaV1,379+ Pack: JSONLPackMeta{380+ Generated: "2026-02-22T10:00:00Z",381+ Version: "v-test",382+ Category: "Sudoku",383+ ModeSelection: "Easy",384+ Count: 2,385+ Seed: "seed-supported",386+ },387+ Puzzle: JSONLPuzzle{388+ Index: 2,389+ Name: "moss-pine",390+ Game: "Sudoku",391+ Mode: "Easy",392+ Save: json.RawMessage(`{"provided":[{"x":0,"y":0,"v":5}]}`),393+ },394+ },395+ }396+397+ var lines []byte398+ for _, record := range records {399+ line, err := json.Marshal(record)400+ if err != nil {401+ t.Fatal(err)402+ }403+ lines = append(lines, line...)404+ lines = append(lines, '\n')405+ }406+ if err := os.WriteFile(path, lines, 0o644); err != nil {407+ t.Fatal(err)408+ }409+410+ doc, err := ParseJSONLFile(path)411+ if err != nil {412+ t.Fatalf("expected parse success, got: %v", err)413+ }414+ if got, want := len(doc.Puzzles), 1; got != want {415+ t.Fatalf("puzzles = %d, want %d", got, want)416+ }417+ if got, want := doc.Metadata.Category, "Sudoku"; got != want {418+ t.Fatalf("metadata category = %q, want %q", got, want)419+ }420+ if got, want := doc.Metadata.ModeSelection, "Easy"; got != want {421+ t.Fatalf("metadata mode = %q, want %q", got, want)422+ }423+ if got, want := doc.Metadata.Seed, "seed-supported"; got != want {424+ t.Fatalf("metadata seed = %q, want %q", got, want)425+ }426+}427+428+func writeSingleJSONLRecord(t *testing.T, path string, record JSONLRecord) {429+ t.Helper()430+ data, err := json.Marshal(record)431+ if err != nil {432+ t.Fatal(err)433+ }434+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {435+ t.Fatal(err)436+ }437+}438+439+var registerJSONLAdaptersOnce sync.Once440+441+func init() {442+ ensureJSONLTestAdapters()443+}444+445+func ensureJSONLTestAdapters() {446+ registerJSONLAdaptersOnce.Do(func() {447+ register := func(category string, build func(save []byte) (any, error), aliases ...string) {448+ game.RegisterPrintAdapter(jsonlTestAdapter{449+ category: category,450+ aliases: aliases,451+ build: build,452+ })453+ }454+455+ register("Nonogram", buildPayloadAdapter("Nonogram", func(save []byte) (any, error) {456+ return ParseNonogramPrintData(save)457+ }), "nonogram")458+ register("Takuzu", buildPayloadAdapter("Takuzu", func(save []byte) (any, error) {459+ return ParseTakuzuPrintData(save)460+ }), "takuzu")461+ register("Sudoku", buildPayloadAdapter("Sudoku", func(save []byte) (any, error) {462+ return ParseSudokuPrintData(save)463+ }), "sudoku")464+ register("Word Search", buildPayloadAdapter("Word Search", func(save []byte) (any, error) {465+ return ParseWordSearchPrintData(save)466+ }), "wordsearch")467+ register("Nurikabe", buildPayloadAdapter("Nurikabe", func(save []byte) (any, error) {468+ return ParseNurikabePrintData(save)469+ }), "nurikabe")470+ register("Shikaku", buildPayloadAdapter("Shikaku", func(save []byte) (any, error) {471+ return ParseShikakuPrintData(save)472+ }), "shikaku")473+ register("Hashiwokakero", buildPayloadAdapter("Hashiwokakero", func(save []byte) (any, error) {474+ return ParseHashiPrintData(save)475+ }), "hashi")476+ })477+}478+479+func buildPayloadAdapter(_ string, parse func(save []byte) (any, error)) func([]byte) (any, error) {480+ return parse481+}482+483+type jsonlTestAdapter struct {484+ category string485+ aliases []string486+ build func(save []byte) (any, error)487+}488+489+func (a jsonlTestAdapter) CanonicalGameType() string { return a.category }490+func (a jsonlTestAdapter) Aliases() []string { return a.aliases }491+func (a jsonlTestAdapter) BuildPDFPayload(save []byte) (any, error) {492+ return a.build(save)493+}494+func (a jsonlTestAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil }
A pdfexport/order.go +104 -0
@@ -0,0 +1,104 @@
1+package pdfexport2+3+import (4+ "hash/fnv"5+ "math/rand/v2"6+ "sort"7+ "strings"8+ "time"9+)10+11+func OrderPuzzlesForPrint(puzzles []Puzzle, seed string) []Puzzle {12+ ordered := append([]Puzzle(nil), puzzles...)13+ if len(ordered) <= 1 {14+ return ordered15+ }16+17+ sort.SliceStable(ordered, func(i, j int) bool {18+ if ordered[i].DifficultyScore != ordered[j].DifficultyScore {19+ return ordered[i].DifficultyScore < ordered[j].DifficultyScore20+ }21+ if c := strings.Compare(normalizeToken(ordered[i].Category), normalizeToken(ordered[j].Category)); c != 0 {22+ return c < 023+ }24+ if c := strings.Compare(normalizeToken(ordered[i].ModeSelection), normalizeToken(ordered[j].ModeSelection)); c != 0 {25+ return c < 026+ }27+ if c := strings.Compare(ordered[i].SourceFileName, ordered[j].SourceFileName); c != 0 {28+ return c < 029+ }30+ return ordered[i].Index < ordered[j].Index31+ })32+33+ rng := seededRand(seed)34+35+ const bandSize = 636+ for start := 0; start < len(ordered); start += bandSize {37+ end := min(start+bandSize, len(ordered))38+ shuffleBand(ordered[start:end], rng)39+ }40+41+ // Reduce same-category runs across band edges while preserving42+ // the overall difficulty trajectory.43+ for i := 1; i < len(ordered); i++ {44+ if !sameCategory(ordered[i-1], ordered[i]) {45+ continue46+ }47+ for j := i + 1; j < min(i+4, len(ordered)); j++ {48+ if sameCategory(ordered[i-1], ordered[j]) {49+ continue50+ }51+ ordered[i], ordered[j] = ordered[j], ordered[i]52+ break53+ }54+ }55+56+ return ordered57+}58+59+func shuffleBand(puzzles []Puzzle, rng *rand.Rand) {60+ if len(puzzles) <= 1 {61+ return62+ }63+64+ perm := rng.Perm(len(puzzles))65+ shuffled := make([]Puzzle, len(puzzles))66+ for i, idx := range perm {67+ shuffled[i] = puzzles[idx]68+ }69+ copy(puzzles, shuffled)70+71+ for i := 1; i < len(puzzles); i++ {72+ if !sameCategory(puzzles[i-1], puzzles[i]) {73+ continue74+ }75+ for j := i + 1; j < len(puzzles); j++ {76+ if sameCategory(puzzles[i-1], puzzles[j]) {77+ continue78+ }79+ puzzles[i], puzzles[j] = puzzles[j], puzzles[i]80+ break81+ }82+ }83+}84+85+func seededRand(seed string) *rand.Rand {86+ if strings.TrimSpace(seed) == "" {87+ seed = time.Now().Format(time.RFC3339Nano)88+ }89+ h := fnv.New64a()90+ h.Write([]byte(seed))91+ s := h.Sum64()92+ return rand.New(rand.NewPCG(s, ^s))93+}94+95+func sameCategory(a, b Puzzle) bool {96+ return normalizeToken(a.Category) == normalizeToken(b.Category)97+}98+99+func normalizeToken(s string) string {100+ s = strings.ToLower(strings.TrimSpace(s))101+ s = strings.ReplaceAll(s, "-", " ")102+ s = strings.ReplaceAll(s, "_", " ")103+ return strings.Join(strings.Fields(s), " ")104+}
A pdfexport/order_test.go +36 -0
A pdfexport/parse.go +496 -0
@@ -0,0 +1,496 @@
1+package pdfexport2+3+import (4+ "fmt"5+ "os"6+ "path/filepath"7+ "regexp"8+ "strconv"9+ "strings"10+ "time"11+)12+13+var (14+ puzzleHeadingPattern = regexp.MustCompile(`^##\s+(.+?)\s+-\s+(\d+)\s*$`)15+ nonogramRowHeaderRegex = regexp.MustCompile(`^R\d+$`)16+ nonogramColHeaderRegex = regexp.MustCompile(`^C\d+$`)17+ tableSepCellRegex = regexp.MustCompile(`^:?-{3,}:?$`)18+)19+20+func ParseFiles(paths []string) ([]PackDocument, error) {21+ docs := make([]PackDocument, 0, len(paths))22+ for _, path := range paths {23+ doc, err := ParseFile(path)24+ if err != nil {25+ return nil, err26+ }27+ docs = append(docs, doc)28+ }29+ return docs, nil30+}31+32+func ParseFile(path string) (PackDocument, error) {33+ data, err := os.ReadFile(path)34+ if err != nil {35+ return PackDocument{}, fmt.Errorf("read input markdown: %w", err)36+ }37+ return ParseMarkdown(path, string(data))38+}39+40+func ParseMarkdown(path, content string) (PackDocument, error) {41+ content = strings.ReplaceAll(content, "\r\n", "\n")42+ content = strings.ReplaceAll(content, "\r", "\n")43+ lines := strings.Split(content, "\n")44+45+ firstContentLine := firstNonEmptyLine(lines)46+ if firstContentLine < 0 {47+ return PackDocument{}, parseError(path, 1, "input markdown is empty")48+ }49+ if strings.TrimSpace(lines[firstContentLine]) != "# PuzzleTea Export" {50+ return PackDocument{}, parseError(path, firstContentLine+1, "expected markdown title '# PuzzleTea Export'")51+ }52+53+ headings := findHeadingLines(lines)54+ if len(headings) == 0 {55+ return PackDocument{}, parseError(path, firstContentLine+1, "expected at least one puzzle section heading")56+ }57+58+ meta, err := parseMetadata(lines[firstContentLine+1:headings[0]], path, firstContentLine+2)59+ if err != nil {60+ return PackDocument{}, err61+ }62+ meta.SourceFileName = filepath.Base(path)63+64+ puzzles := make([]Puzzle, 0, len(headings))65+ for i, start := range headings {66+ end := len(lines)67+ if i+1 < len(headings) {68+ end = headings[i+1]69+ }70+71+ puzzle, err := parsePuzzleSection(lines[start:end], path, start+1, meta)72+ if err != nil {73+ return PackDocument{}, err74+ }75+ puzzles = append(puzzles, puzzle)76+ }77+78+ if len(puzzles) == 0 {79+ return PackDocument{}, parseError(path, firstContentLine+1, "no puzzle sections were parsed")80+ }81+82+ return PackDocument{83+ SourcePath: path,84+ Metadata: meta,85+ Puzzles: puzzles,86+ }, nil87+}88+89+func parseMetadata(lines []string, path string, startLine int) (PackMetadata, error) {90+ meta := PackMetadata{}91+92+ for i, line := range lines {93+ trimmed := strings.TrimSpace(line)94+ if trimmed == "" {95+ continue96+ }97+ if !strings.HasPrefix(trimmed, "- ") {98+ continue99+ }100+101+ entry := strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))102+ key, value, ok := strings.Cut(entry, ":")103+ if !ok {104+ continue105+ }106+107+ lineNo := startLine + i108+ key = strings.ToLower(strings.TrimSpace(key))109+ value = strings.TrimSpace(value)110+111+ switch key {112+ case "generated":113+ meta.GeneratedRaw = value114+ if ts, err := time.Parse(time.RFC3339, value); err == nil {115+ meta.GeneratedAt = ts116+ }117+ case "version":118+ meta.Version = value119+ case "category":120+ meta.Category = value121+ case "mode selection":122+ meta.ModeSelection = value123+ case "count":124+ count, err := strconv.Atoi(value)125+ if err != nil {126+ return PackMetadata{}, parseError(path, lineNo, "invalid Count value %q", value)127+ }128+ meta.Count = count129+ case "seed":130+ meta.Seed = value131+ case "export format":132+ meta.Format = value133+ }134+ }135+136+ if strings.TrimSpace(meta.Category) == "" {137+ return PackMetadata{}, parseError(path, startLine, "missing required metadata field: Category")138+ }139+ if strings.TrimSpace(meta.ModeSelection) == "" {140+ return PackMetadata{}, parseError(path, startLine, "missing required metadata field: Mode Selection")141+ }142+143+ return meta, nil144+}145+146+func parsePuzzleSection(section []string, path string, startLine int, meta PackMetadata) (Puzzle, error) {147+ if len(section) == 0 {148+ return Puzzle{}, parseError(path, startLine, "empty puzzle section")149+ }150+151+ heading := strings.TrimSpace(section[0])152+ matches := puzzleHeadingPattern.FindStringSubmatch(heading)153+ if len(matches) != 3 {154+ return Puzzle{}, parseError(path, startLine, "invalid puzzle heading %q", heading)155+ }156+157+ index, err := strconv.Atoi(matches[2])158+ if err != nil {159+ return Puzzle{}, parseError(path, startLine, "invalid puzzle index %q", matches[2])160+ }161+162+ bodyLines := append([]string(nil), section[1:]...)163+ trimSectionBody(&bodyLines)164+ body := strings.Join(bodyLines, "\n")165+166+ p := Puzzle{167+ SourcePath: path,168+ SourceFileName: meta.SourceFileName,169+ Category: meta.Category,170+ ModeSelection: meta.ModeSelection,171+ Name: matches[1],172+ Index: index,173+ Body: body,174+ }175+176+ if strings.EqualFold(strings.TrimSpace(meta.Category), "nonogram") {177+ nonogram, err := parseNonogramBody(bodyLines, path, startLine+1)178+ if err != nil {179+ return Puzzle{}, err180+ }181+ p.PrintPayload = nonogram182+ return p, nil183+ }184+185+ table, err := parseGridTableBody(bodyLines, path, startLine+1)186+ if err != nil {187+ return Puzzle{}, err188+ }189+ p.PrintPayload = table190+191+ return p, nil192+}193+194+func parseNonogramBody(bodyLines []string, path string, bodyStartLine int) (*NonogramData, error) {195+ tableLines, tableLineNumbers := findFirstMarkdownTable(bodyLines, bodyStartLine)196+ if len(tableLines) < 3 {197+ lineNo := bodyStartLine198+ if len(tableLineNumbers) > 0 {199+ lineNo = tableLineNumbers[0]200+ }201+ return nil, parseError(path, lineNo, "expected nonogram markdown table with header, separator, and data rows")202+ }203+204+ header := parseTableRow(tableLines[0])205+ if len(header) == 0 {206+ return nil, parseError(path, tableLineNumbers[0], "nonogram header row is empty")207+ }208+209+ rowHintCols := 0210+ colCount := 0211+ for _, cell := range header {212+ switch {213+ case nonogramRowHeaderRegex.MatchString(cell):214+ rowHintCols++215+ case nonogramColHeaderRegex.MatchString(cell):216+ colCount++217+ }218+ }219+ if rowHintCols < 1 || colCount < 1 {220+ return nil, parseError(path, tableLineNumbers[0], "expected nonogram header cells like R1.. and C1..")221+ }222+223+ expectedCols := rowHintCols + colCount224+ dataRows := make([][]string, 0, len(tableLines)-2)225+ for i := 2; i < len(tableLines); i++ {226+ cells := parseTableRow(tableLines[i])227+ if len(cells) < expectedCols {228+ return nil, parseError(path, tableLineNumbers[i], "expected %d columns, found %d", expectedCols, len(cells))229+ }230+ if len(cells) > expectedCols {231+ cells = cells[:expectedCols]232+ }233+ dataRows = append(dataRows, cells)234+ }235+236+ if len(dataRows) == 0 {237+ return nil, parseError(path, tableLineNumbers[0], "nonogram table has no data rows")238+ }239+240+ colHintRows := 0241+ for _, row := range dataRows {242+ if rowHintPlaceholderRow(row[:rowHintCols]) {243+ colHintRows++244+ continue245+ }246+ break247+ }248+249+ height := len(dataRows) - colHintRows250+ if height <= 0 {251+ return nil, parseError(path, tableLineNumbers[len(tableLineNumbers)-1], "nonogram table does not contain puzzle rows")252+ }253+254+ rowHints := make([][]int, height)255+ grid := make([][]string, height)256+ for y := 0; y < height; y++ {257+ row := dataRows[colHintRows+y]258+ hints := parseHintCells(row[:rowHintCols])259+ if len(hints) == 0 {260+ hints = []int{0}261+ }262+ rowHints[y] = hints263+264+ gridRow := make([]string, colCount)265+ for x := 0; x < colCount; x++ {266+ cell := strings.TrimSpace(row[rowHintCols+x])267+ switch cell {268+ case "", ".":269+ gridRow[x] = " "270+ default:271+ gridRow[x] = cell272+ }273+ }274+ grid[y] = gridRow275+ }276+277+ colHints := make([][]int, colCount)278+ for x := 0; x < colCount; x++ {279+ hints := make([]int, 0, colHintRows)280+ for r := 0; r < colHintRows; r++ {281+ if v, ok := parseHintValue(dataRows[r][rowHintCols+x]); ok {282+ hints = append(hints, v)283+ }284+ }285+ if len(hints) == 0 {286+ hints = []int{0}287+ }288+ colHints[x] = hints289+ }290+291+ return &NonogramData{292+ Width: colCount,293+ Height: height,294+ RowHints: rowHints,295+ ColHints: colHints,296+ Grid: grid,297+ }, nil298+}299+300+func parseGridTableBody(bodyLines []string, path string, bodyStartLine int) (*GridTable, error) {301+ tableLines, tableLineNumbers := findFirstMarkdownTable(bodyLines, bodyStartLine)302+ if len(tableLines) == 0 {303+ return nil, nil304+ }305+ if len(tableLines) < 2 {306+ return nil, parseError(path, tableLineNumbers[0], "expected markdown table to include data rows")307+ }308+309+ rows := make([][]string, 0, len(tableLines))310+ for i, line := range tableLines {311+ cells := parseTableRow(line)312+ if len(cells) == 0 {313+ return nil, parseError(path, tableLineNumbers[i], "empty table row")314+ }315+ rows = append(rows, cells)316+ }317+318+ hasHeaderRow := false319+ if len(rows) > 1 && isMarkdownSeparatorRow(rows[1]) {320+ hasHeaderRow = true321+ rows = append(rows[:1], rows[2:]...)322+ }323+ if len(rows) < 2 {324+ return nil, parseError(path, tableLineNumbers[0], "table must include header and data rows")325+ }326+327+ width := 0328+ for _, row := range rows {329+ if len(row) > width {330+ width = len(row)331+ }332+ }333+ for i := range rows {334+ if len(rows[i]) >= width {335+ continue336+ }337+ padded := make([]string, width)338+ copy(padded, rows[i])339+ rows[i] = padded340+ }341+342+ return &GridTable{343+ Rows: rows,344+ HasHeaderRow: hasHeaderRow,345+ HasHeaderCol: detectHeaderColumn(rows, hasHeaderRow),346+ }, nil347+}348+349+func findFirstMarkdownTable(lines []string, startLine int) ([]string, []int) {350+ table := []string{}351+ lineNumbers := []int{}352+ started := false353+354+ for i, line := range lines {355+ trimmed := strings.TrimSpace(line)356+ if strings.HasPrefix(trimmed, "|") {357+ started = true358+ table = append(table, line)359+ lineNumbers = append(lineNumbers, startLine+i)360+ continue361+ }362+ if started {363+ break364+ }365+ }366+367+ return table, lineNumbers368+}369+370+func parseTableRow(line string) []string {371+ trimmed := strings.TrimSpace(line)372+ trimmed = strings.TrimPrefix(trimmed, "|")373+ trimmed = strings.TrimSuffix(trimmed, "|")374+ if trimmed == "" {375+ return []string{}376+ }377+378+ parts := strings.Split(trimmed, "|")379+ cells := make([]string, 0, len(parts))380+ for _, part := range parts {381+ cells = append(cells, strings.TrimSpace(part))382+ }383+ return cells384+}385+386+func isMarkdownSeparatorRow(cells []string) bool {387+ if len(cells) == 0 {388+ return false389+ }390+ for _, cell := range cells {391+ if !tableSepCellRegex.MatchString(strings.TrimSpace(cell)) {392+ return false393+ }394+ }395+ return true396+}397+398+func rowHintPlaceholderRow(cells []string) bool {399+ for _, cell := range cells {400+ trimmed := strings.TrimSpace(cell)401+ if trimmed != "" && trimmed != "." {402+ return false403+ }404+ }405+ return true406+}407+408+func parseHintCells(cells []string) []int {409+ hints := make([]int, 0, len(cells))410+ for _, cell := range cells {411+ if v, ok := parseHintValue(cell); ok {412+ hints = append(hints, v)413+ }414+ }415+ return hints416+}417+418+func detectHeaderColumn(rows [][]string, hasHeaderRow bool) bool {419+ if len(rows) < 2 {420+ return false421+ }422+423+ start := 1424+ if !hasHeaderRow {425+ start = 0426+ }427+428+ total := 0429+ numeric := 0430+ for i := start; i < len(rows); i++ {431+ if len(rows[i]) == 0 {432+ continue433+ }434+ total++435+ if _, err := strconv.Atoi(strings.TrimSpace(rows[i][0])); err == nil {436+ numeric++437+ }438+ }439+440+ return total > 0 && numeric*100/total >= 70441+}442+443+func parseHintValue(cell string) (int, bool) {444+ trimmed := strings.TrimSpace(cell)445+ if trimmed == "" || trimmed == "." {446+ return 0, false447+ }448+ v, err := strconv.Atoi(trimmed)449+ if err != nil {450+ return 0, false451+ }452+ return v, true453+}454+455+func trimSectionBody(lines *[]string) {456+ for len(*lines) > 0 {457+ trimmed := strings.TrimSpace((*lines)[0])458+ if trimmed != "" && trimmed != "---" {459+ break460+ }461+ *lines = (*lines)[1:]462+ }463+ for len(*lines) > 0 {464+ trimmed := strings.TrimSpace((*lines)[len(*lines)-1])465+ if trimmed != "" && trimmed != "---" {466+ break467+ }468+ *lines = (*lines)[:len(*lines)-1]469+ }470+}471+472+func firstNonEmptyLine(lines []string) int {473+ for i, line := range lines {474+ if strings.TrimSpace(line) != "" {475+ return i476+ }477+ }478+ return -1479+}480+481+func findHeadingLines(lines []string) []int {482+ indexes := []int{}483+ for i, line := range lines {484+ if strings.HasPrefix(strings.TrimSpace(line), "## ") {485+ indexes = append(indexes, i)486+ }487+ }488+ return indexes489+}490+491+func parseError(path string, line int, format string, args ...any) error {492+ if line < 1 {493+ line = 1494+ }495+ return fmt.Errorf("%s:%d: %s", path, line, fmt.Sprintf(format, args...))496+}
A pdfexport/parse_test.go +134 -0
@@ -0,0 +1,134 @@
1+package pdfexport2+3+import (4+ "os"5+ "path/filepath"6+ "strconv"7+ "testing"8+)9+10+func TestParseMarkdownNonogram(t *testing.T) {11+ doc, err := ParseMarkdown("sample.md", sampleNonogramDoc("Standard", 1))12+ if err != nil {13+ t.Fatal(err)14+ }15+16+ if got, want := doc.Metadata.Category, "Nonogram"; got != want {17+ t.Fatalf("category = %q, want %q", got, want)18+ }19+ if got, want := len(doc.Puzzles), 1; got != want {20+ t.Fatalf("puzzles = %d, want %d", got, want)21+ }22+23+ p := doc.Puzzles[0]24+ nonogram, ok := p.PrintPayload.(*NonogramData)25+ if !ok || nonogram == nil {26+ t.Fatal("expected parsed nonogram data")27+ }28+ if nonogram.Width != 2 || nonogram.Height != 2 {29+ t.Fatalf("nonogram size = %dx%d, want 2x2", nonogram.Width, nonogram.Height)30+ }31+32+ if got, want := nonogram.RowHints[0][0], 1; got != want {33+ t.Fatalf("first row first hint = %d, want %d", got, want)34+ }35+ if got, want := nonogram.ColHints[0][0], 1; got != want {36+ t.Fatalf("first col first hint = %d, want %d", got, want)37+ }38+39+ if got, want := nonogram.Grid[0][0], " "; got != want {40+ t.Fatalf("grid dot replacement = %q, want %q", got, want)41+ }42+}43+44+func TestParseFilesMultipleInputs(t *testing.T) {45+ temp := t.TempDir()46+47+ fileA := filepath.Join(temp, "pack-a.md")48+ if err := os.WriteFile(fileA, []byte(sampleNonogramDoc("Standard", 1)), 0o644); err != nil {49+ t.Fatal(err)50+ }51+52+ fileB := filepath.Join(temp, "pack-b.md")53+ if err := os.WriteFile(fileB, []byte(sampleNonogramDoc("Classic", 2)), 0o644); err != nil {54+ t.Fatal(err)55+ }56+57+ docs, err := ParseFiles([]string{fileA, fileB})58+ if err != nil {59+ t.Fatal(err)60+ }61+ if got, want := len(docs), 2; got != want {62+ t.Fatalf("docs = %d, want %d", got, want)63+ }64+65+ if got, want := docs[0].Metadata.SourceFileName, "pack-a.md"; got != want {66+ t.Fatalf("first source file = %q, want %q", got, want)67+ }68+ if got, want := docs[1].Puzzles[0].ModeSelection, "Classic"; got != want {69+ t.Fatalf("second mode selection = %q, want %q", got, want)70+ }71+}72+73+func TestParseMarkdownTakuzuTable(t *testing.T) {74+ doc, err := ParseMarkdown("takuzu.md", sampleTakuzuDoc())75+ if err != nil {76+ t.Fatal(err)77+ }78+79+ if got, want := doc.Metadata.Category, "Takuzu"; got != want {80+ t.Fatalf("category = %q, want %q", got, want)81+ }82+83+ p := doc.Puzzles[0]84+ table, ok := p.PrintPayload.(*GridTable)85+ if !ok || table == nil {86+ t.Fatal("expected parsed grid table for takuzu")87+ }88+ if !table.HasHeaderRow {89+ t.Fatal("expected takuzu table to detect a header row")90+ }91+ if !table.HasHeaderCol {92+ t.Fatal("expected takuzu table to detect a header column")93+ }94+95+ if got, want := table.Rows[1][1], "."; got != want {96+ t.Fatalf("table cell = %q, want %q", got, want)97+ }98+}99+100+func sampleNonogramDoc(mode string, idx int) string {101+ return "# PuzzleTea Export\n\n" +102+ "- Generated: 2026-02-21T20:42:05-07:00\n" +103+ "- Version: v1.6.0\n" +104+ "- Category: Nonogram\n" +105+ "- Mode Selection: " + mode + "\n" +106+ "- Count: 1\n" +107+ "- Seed: zine\n\n" +108+ "## ember-newt - " + strconv.Itoa(idx) + "\n\n" +109+ "### Puzzle Grid with Integrated Hints\n\n" +110+ "| R1 | R2 | C1 | C2 |\n" +111+ "| --- | --- | --- | --- |\n" +112+ "| . | . | 1 | 2 |\n" +113+ "| . | . | 3 | 4 |\n" +114+ "| 1 | 1 | . | . |\n" +115+ "| . | 2 | . | . |\n\n" +116+ "Row hints are right-aligned beside each row.\n"117+}118+119+func sampleTakuzuDoc() string {120+ return "# PuzzleTea Export\n\n" +121+ "- Generated: 2026-02-21T20:42:05-07:00\n" +122+ "- Version: v1.6.0\n" +123+ "- Category: Takuzu\n" +124+ "- Mode Selection: Beginner\n" +125+ "- Count: 1\n" +126+ "- Seed: zine\n\n" +127+ "## scarlet-lichen - 1\n\n" +128+ "### Given Grid\n\n" +129+ "| | 1 | 2 | 3 |\n" +130+ "| --- | --- | --- | --- |\n" +131+ "| 1 | . | 0 | . |\n" +132+ "| 2 | 1 | . | 0 |\n\n" +133+ "Goal: fill with 0/1.\n"134+}
A pdfexport/printdata.go +569 -0
@@ -0,0 +1,569 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "fmt"6+ "strconv"7+ "strings"8+ "unicode"9+ "unicode/utf8"10+)11+12+type nurikabeSave struct {13+ Width int `json:"width"`14+ Height int `json:"height"`15+ Clues string `json:"clues"`16+}17+18+type nonogramSave struct {19+ State string `json:"state"`20+ Width int `json:"width"`21+ Height int `json:"height"`22+ RowHints [][]int `json:"row-hints"`23+ ColHints [][]int `json:"col-hints"`24+}25+26+type hashiSave struct {27+ Width int `json:"width"`28+ Height int `json:"height"`29+ Islands []hashiIsland `json:"islands"`30+}31+32+type hashiIsland struct {33+ X int `json:"x"`34+ Y int `json:"y"`35+ Required int `json:"required"`36+}37+38+type shikakuSave struct {39+ Width int `json:"width"`40+ Height int `json:"height"`41+ Clues []shikakuClue `json:"clues"`42+}43+44+type shikakuClue struct {45+ X int `json:"x"`46+ Y int `json:"y"`47+ Value int `json:"value"`48+}49+50+type hitoriSave struct {51+ Size int `json:"size"`52+ Numbers string `json:"numbers"`53+}54+55+type takuzuSave struct {56+ Size int `json:"size"`57+ State string `json:"state"`58+ Provided string `json:"provided"`59+}60+61+type sudokuSave struct {62+ Provided []sudokuCell `json:"provided"`63+}64+65+type sudokuCell struct {66+ X int `json:"x"`67+ Y int `json:"y"`68+ V int `json:"v"`69+}70+71+type wordSearchSave struct {72+ Width int `json:"width"`73+ Height int `json:"height"`74+ Grid string `json:"grid"`75+ Words []wordSearchWord `json:"words"`76+}77+78+type wordSearchWord struct {79+ Text string `json:"text"`80+}81+82+func ParseNonogramPrintData(saveData []byte) (*NonogramData, error) {83+ if len(strings.TrimSpace(string(saveData))) == 0 {84+ return nil, nil85+ }86+87+ var save nonogramSave88+ if err := json.Unmarshal(saveData, &save); err != nil {89+ return nil, fmt.Errorf("decode nonogram save: %w", err)90+ }91+92+ stateRows := splitNonogramStateRows(save.State)93+94+ width := save.Width95+ if width <= 0 {96+ width = len(save.ColHints)97+ }98+ if width <= 0 {99+ width = maxRuneWidth(stateRows)100+ }101+102+ height := save.Height103+ if height <= 0 {104+ height = len(save.RowHints)105+ }106+ if height <= 0 {107+ height = len(stateRows)108+ }109+110+ if width <= 0 || height <= 0 {111+ return nil, nil112+ }113+114+ return &NonogramData{115+ Width: width,116+ Height: height,117+ RowHints: normalizeNonogramHintRows(save.RowHints, height),118+ ColHints: normalizeNonogramHintRows(save.ColHints, width),119+ Grid: normalizeNonogramStateGrid(stateRows, width, height),120+ }, nil121+}122+123+func ParseNurikabePrintData(saveData []byte) (*NurikabeData, error) {124+ if len(strings.TrimSpace(string(saveData))) == 0 {125+ return nil, nil126+ }127+128+ var save nurikabeSave129+ if err := json.Unmarshal(saveData, &save); err != nil {130+ return nil, fmt.Errorf("decode nurikabe save: %w", err)131+ }132+133+ width := save.Width134+ height := save.Height135+ if width <= 0 || height <= 0 {136+ return nil, nil137+ }138+139+ clues, err := parseNurikabeClues(save.Clues, width, height)140+ if err != nil {141+ return nil, err142+ }143+144+ return &NurikabeData{145+ Width: width,146+ Height: height,147+ Clues: clues,148+ }, nil149+}150+151+func ParseShikakuPrintData(saveData []byte) (*ShikakuData, error) {152+ if len(strings.TrimSpace(string(saveData))) == 0 {153+ return nil, nil154+ }155+156+ var save shikakuSave157+ if err := json.Unmarshal(saveData, &save); err != nil {158+ return nil, fmt.Errorf("decode shikaku save: %w", err)159+ }160+ if save.Width <= 0 || save.Height <= 0 {161+ return nil, nil162+ }163+164+ clues := make([][]int, save.Height)165+ for y := 0; y < save.Height; y++ {166+ clues[y] = make([]int, save.Width)167+ }168+169+ for _, clue := range save.Clues {170+ if clue.X < 0 || clue.X >= save.Width || clue.Y < 0 || clue.Y >= save.Height {171+ continue172+ }173+ if clue.Value <= 0 {174+ continue175+ }176+ clues[clue.Y][clue.X] = clue.Value177+ }178+179+ return &ShikakuData{180+ Width: save.Width,181+ Height: save.Height,182+ Clues: clues,183+ }, nil184+}185+186+func ParseHashiPrintData(saveData []byte) (*HashiData, error) {187+ if len(strings.TrimSpace(string(saveData))) == 0 {188+ return nil, nil189+ }190+191+ var save hashiSave192+ if err := json.Unmarshal(saveData, &save); err != nil {193+ return nil, fmt.Errorf("decode hashiwokakero save: %w", err)194+ }195+ if save.Width <= 0 || save.Height <= 0 {196+ return nil, nil197+ }198+199+ islands := make([]HashiIsland, 0, len(save.Islands))200+ for _, island := range save.Islands {201+ if island.X < 0 || island.X >= save.Width || island.Y < 0 || island.Y >= save.Height {202+ continue203+ }204+ if island.Required <= 0 {205+ continue206+ }207+ islands = append(islands, HashiIsland(island))208+ }209+210+ return &HashiData{211+ Width: save.Width,212+ Height: save.Height,213+ Islands: islands,214+ }, nil215+}216+217+func ParseHitoriPrintData(saveData []byte) (*HitoriData, error) {218+ if len(strings.TrimSpace(string(saveData))) == 0 {219+ return nil, nil220+ }221+222+ var save hitoriSave223+ if err := json.Unmarshal(saveData, &save); err != nil {224+ return nil, fmt.Errorf("decode hitori save: %w", err)225+ }226+227+ rows := splitNormalizedLines(save.Numbers)228+ size := save.Size229+ if size <= 0 {230+ size = len(rows)231+ }232+ if size <= 0 {233+ return nil, nil234+ }235+236+ numbers := make([][]string, size)237+ for y := 0; y < size; y++ {238+ numbers[y] = make([]string, size)239+ if y >= len(rows) {240+ continue241+ }242+243+ rowValues := parseHitoriRowValues(rows[y])244+ for x := 0; x < size && x < len(rowValues); x++ {245+ numbers[y][x] = rowValues[x]246+ }247+ }248+249+ return &HitoriData{250+ Size: size,251+ Numbers: numbers,252+ }, nil253+}254+255+func ParseTakuzuPrintData(saveData []byte) (*TakuzuData, error) {256+ if len(strings.TrimSpace(string(saveData))) == 0 {257+ return nil, nil258+ }259+260+ var save takuzuSave261+ if err := json.Unmarshal(saveData, &save); err != nil {262+ return nil, fmt.Errorf("decode takuzu save: %w", err)263+ }264+265+ stateRows := splitNormalizedLines(save.State)266+ providedRows := splitNormalizedLines(save.Provided)267+268+ size := save.Size269+ if size <= 0 {270+ size = max(len(stateRows), len(providedRows))271+ }272+ if size <= 0 {273+ return nil, nil274+ }275+276+ givens := make([][]string, size)277+ for y := 0; y < size; y++ {278+ givens[y] = make([]string, size)279+280+ var stateRunes []rune281+ if y < len(stateRows) {282+ stateRunes = []rune(stateRows[y])283+ }284+285+ var providedRunes []rune286+ if y < len(providedRows) {287+ providedRunes = []rune(providedRows[y])288+ }289+290+ for x := 0; x < size; x++ {291+ if x >= len(providedRunes) || providedRunes[x] != '#' {292+ continue293+ }294+ if x >= len(stateRunes) {295+ continue296+ }297+ if stateRunes[x] != '0' && stateRunes[x] != '1' {298+ continue299+ }300+ givens[y][x] = string(stateRunes[x])301+ }302+ }303+304+ return &TakuzuData{305+ Size: size,306+ Givens: givens,307+ GroupEveryTwo: true,308+ }, nil309+}310+311+func ParseSudokuPrintData(saveData []byte) (*SudokuData, error) {312+ if len(strings.TrimSpace(string(saveData))) == 0 {313+ return nil, nil314+ }315+316+ var save sudokuSave317+ if err := json.Unmarshal(saveData, &save); err != nil {318+ return nil, fmt.Errorf("decode sudoku save: %w", err)319+ }320+321+ var givens [9][9]int322+ for _, cell := range save.Provided {323+ if !isSudokuCellInBounds(cell.X, cell.Y) {324+ continue325+ }326+ if cell.V < 1 || cell.V > 9 {327+ continue328+ }329+ givens[cell.Y][cell.X] = cell.V330+ }331+332+ return &SudokuData{Givens: givens}, nil333+}334+335+func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) {336+ if len(strings.TrimSpace(string(saveData))) == 0 {337+ return nil, nil338+ }339+340+ var save wordSearchSave341+ if err := json.Unmarshal(saveData, &save); err != nil {342+ return nil, fmt.Errorf("decode word search save: %w", err)343+ }344+345+ rows := strings.Split(strings.ReplaceAll(strings.ReplaceAll(save.Grid, "\r\n", "\n"), "\r", "\n"), "\n")346+ if len(rows) == 1 && rows[0] == "" {347+ rows = nil348+ }349+350+ width := save.Width351+ for _, row := range rows {352+ if n := len([]rune(row)); n > width {353+ width = n354+ }355+ }356+357+ height := save.Height358+ if len(rows) > height {359+ height = len(rows)360+ }361+ if width <= 0 || height <= 0 {362+ return nil, nil363+ }364+365+ grid := make([][]string, height)366+ for y := 0; y < height; y++ {367+ grid[y] = make([]string, width)368+ runes := []rune{}369+ if y < len(rows) {370+ runes = []rune(rows[y])371+ }372+ for x := 0; x < width; x++ {373+ grid[y][x] = " "374+ if x >= len(runes) {375+ continue376+ }377+ r := runes[x]378+ if unicode.IsSpace(r) {379+ continue380+ }381+ grid[y][x] = string(unicode.ToUpper(r))382+ }383+ }384+385+ words := make([]string, 0, len(save.Words))386+ for _, word := range save.Words {387+ text := strings.ToUpper(strings.TrimSpace(word.Text))388+ if text == "" {389+ continue390+ }391+ words = append(words, text)392+ }393+394+ return &WordSearchData{395+ Width: width,396+ Height: height,397+ Grid: grid,398+ Words: words,399+ }, nil400+}401+402+func isSudokuCellInBounds(x, y int) bool {403+ return x >= 0 && x < 9 && y >= 0 && y < 9404+}405+406+func splitNormalizedLines(raw string) []string {407+ normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")408+ if strings.TrimSpace(normalized) == "" {409+ return nil410+ }411+ return strings.Split(normalized, "\n")412+}413+414+func parseHitoriRowValues(row string) []string {415+ row = strings.TrimSpace(row)416+ if row == "" {417+ return nil418+ }419+420+ if strings.Contains(row, " ") || strings.Contains(row, ",") {421+ fields := strings.Fields(strings.ReplaceAll(row, ",", " "))422+ if len(fields) > 1 {423+ values := make([]string, len(fields))424+ for i, field := range fields {425+ values[i] = normalizeHitoriToken(field)426+ }427+ return values428+ }429+ }430+431+ runes := []rune(row)432+ values := make([]string, len(runes))433+ for i, r := range runes {434+ values[i] = normalizeHitoriRune(r)435+ }436+ return values437+}438+439+func normalizeHitoriToken(token string) string {440+ token = strings.TrimSpace(token)441+ if token == "" || token == "." {442+ return ""443+ }444+ if utf8.RuneCountInString(token) == 1 {445+ r, _ := utf8.DecodeRuneInString(token)446+ return normalizeHitoriRune(r)447+ }448+ return token449+}450+451+func normalizeHitoriRune(r rune) string {452+ switch {453+ case r == '.':454+ return ""455+ case r >= '0' && r <= '9':456+ return string(r)457+ default:458+ value := int(r - '0')459+ if value >= 10 && value <= 35 {460+ return fmt.Sprintf("%d", value)461+ }462+ return string(r)463+ }464+}465+466+func parseNurikabeClues(raw string, width, height int) ([][]int, error) {467+ if width <= 0 || height <= 0 {468+ return nil, fmt.Errorf("invalid clue dimensions: %dx%d", width, height)469+ }470+471+ clues := make([][]int, height)472+ for y := 0; y < height; y++ {473+ clues[y] = make([]int, width)474+ }475+476+ rows := splitNormalizedLines(raw)477+ for y := 0; y < len(rows) && y < height; y++ {478+ parts := strings.Split(rows[y], ",")479+ for x := 0; x < len(parts) && x < width; x++ {480+ token := strings.TrimSpace(parts[x])481+ if token == "" {482+ continue483+ }484+ value, err := strconv.Atoi(token)485+ if err != nil {486+ return nil, fmt.Errorf("invalid clue value %q at (%d,%d): %w", token, x, y, err)487+ }488+ if value < 0 {489+ return nil, fmt.Errorf("negative clue value %d at (%d,%d)", value, x, y)490+ }491+ clues[y][x] = value492+ }493+ }494+495+ return clues, nil496+}497+498+func splitNonogramStateRows(raw string) []string {499+ normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")500+ if normalized == "" {501+ return nil502+ }503+ return strings.Split(normalized, "\n")504+}505+506+func maxRuneWidth(rows []string) int {507+ maxWidth := 0508+ for _, row := range rows {509+ if n := len([]rune(row)); n > maxWidth {510+ maxWidth = n511+ }512+ }513+ return maxWidth514+}515+516+func normalizeNonogramHintRows(src [][]int, size int) [][]int {517+ if size <= 0 {518+ return nil519+ }520+521+ normalized := make([][]int, size)522+ for i := 0; i < size; i++ {523+ if i >= len(src) {524+ normalized[i] = []int{0}525+ continue526+ }527+528+ filtered := make([]int, 0, len(src[i]))529+ for _, value := range src[i] {530+ if value > 0 {531+ filtered = append(filtered, value)532+ }533+ }534+ if len(filtered) == 0 {535+ filtered = []int{0}536+ }537+ normalized[i] = filtered538+ }539+540+ return normalized541+}542+543+func normalizeNonogramStateGrid(rows []string, width, height int) [][]string {544+ if width <= 0 || height <= 0 {545+ return nil546+ }547+548+ grid := make([][]string, height)549+ for y := 0; y < height; y++ {550+ grid[y] = make([]string, width)551+ for x := 0; x < width; x++ {552+ grid[y][x] = " "553+ }554+555+ if y >= len(rows) {556+ continue557+ }558+559+ runes := []rune(rows[y])560+ for x := 0; x < width && x < len(runes); x++ {561+ if runes[x] == ' ' {562+ continue563+ }564+ grid[y][x] = string(runes[x])565+ }566+ }567+568+ return grid569+}
A pdfexport/printdata_test.go +183 -0
@@ -0,0 +1,183 @@
1+package pdfexport2+3+import "testing"4+5+func TestParseNonogramPrintData(t *testing.T) {6+ save := []byte(`{"state":" .-\n.. ","row-hints":[[1],[2]],"col-hints":[[2],[1],[1]]}`)7+8+ data, err := ParseNonogramPrintData(save)9+ if err != nil {10+ t.Fatal(err)11+ }12+ if data == nil {13+ t.Fatal("expected nonogram print data")14+ }15+ if got, want := data.Width, 3; got != want {16+ t.Fatalf("width = %d, want %d", got, want)17+ }18+ if got, want := data.Height, 2; got != want {19+ t.Fatalf("height = %d, want %d", got, want)20+ }21+ if got, want := data.RowHints[0][0], 1; got != want {22+ t.Fatalf("row hint[0][0] = %d, want %d", got, want)23+ }24+ if got, want := data.ColHints[0][0], 2; got != want {25+ t.Fatalf("col hint[0][0] = %d, want %d", got, want)26+ }27+ if got, want := data.Grid[0][0], " "; got != want {28+ t.Fatalf("grid[0][0] = %q, want %q", got, want)29+ }30+ if got, want := data.Grid[0][1], "."; got != want {31+ t.Fatalf("grid[0][1] = %q, want %q", got, want)32+ }33+ if got, want := data.Grid[0][2], "-"; got != want {34+ t.Fatalf("grid[0][2] = %q, want %q", got, want)35+ }36+}37+38+func TestParseNurikabePrintData(t *testing.T) {39+ save := []byte(`{"width":3,"height":2,"clues":"1,0,2\n0,3,0","marks":"???\n???"}`)40+41+ data, err := ParseNurikabePrintData(save)42+ if err != nil {43+ t.Fatal(err)44+ }45+ if data == nil {46+ t.Fatal("expected nurikabe print data")47+ }48+ if got, want := data.Width, 3; got != want {49+ t.Fatalf("width = %d, want %d", got, want)50+ }51+ if got, want := data.Height, 2; got != want {52+ t.Fatalf("height = %d, want %d", got, want)53+ }54+ if got, want := data.Clues[0][0], 1; got != want {55+ t.Fatalf("row 0 col 0 = %d, want %d", got, want)56+ }57+ if got, want := data.Clues[0][2], 2; got != want {58+ t.Fatalf("row 0 col 2 = %d, want %d", got, want)59+ }60+ if got, want := data.Clues[1][1], 3; got != want {61+ t.Fatalf("row 1 col 1 = %d, want %d", got, want)62+ }63+}64+65+func TestParseHashiPrintData(t *testing.T) {66+ save := []byte(`{"width":7,"height":7,"islands":[{"x":0,"y":0,"required":3},{"x":6,"y":6,"required":2},{"x":9,"y":9,"required":5}]}`)67+68+ data, err := ParseHashiPrintData(save)69+ if err != nil {70+ t.Fatal(err)71+ }72+ if data == nil {73+ t.Fatal("expected hashi print data")74+ }75+ if got, want := data.Width, 7; got != want {76+ t.Fatalf("width = %d, want %d", got, want)77+ }78+ if got, want := data.Height, 7; got != want {79+ t.Fatalf("height = %d, want %d", got, want)80+ }81+ if got, want := len(data.Islands), 2; got != want {82+ t.Fatalf("island count = %d, want %d", got, want)83+ }84+ if got, want := data.Islands[0].Required, 3; got != want {85+ t.Fatalf("island[0].required = %d, want %d", got, want)86+ }87+}88+89+func TestParseShikakuPrintDataDuplicateClueCoordinatesUseLatest(t *testing.T) {90+ save := []byte(`{"width":3,"height":3,"clues":[{"x":1,"y":1,"value":2},{"x":1,"y":1,"value":5},{"x":2,"y":0,"value":4}]}`)91+92+ data, err := ParseShikakuPrintData(save)93+ if err != nil {94+ t.Fatal(err)95+ }96+ if data == nil {97+ t.Fatal("expected shikaku print data")98+ }99+ if got, want := data.Clues[1][1], 5; got != want {100+ t.Fatalf("row 1 col 1 = %d, want %d", got, want)101+ }102+ if got, want := data.Clues[0][2], 4; got != want {103+ t.Fatalf("row 0 col 2 = %d, want %d", got, want)104+ }105+}106+107+func TestParseHitoriPrintData(t *testing.T) {108+ save := []byte(`{"size":4,"numbers":"1 2 10\n4 . .\n7\n"}`)109+110+ data, err := ParseHitoriPrintData(save)111+ if err != nil {112+ t.Fatal(err)113+ }114+ if data == nil {115+ t.Fatal("expected hitori print data")116+ }117+ if got, want := data.Size, 4; got != want {118+ t.Fatalf("size = %d, want %d", got, want)119+ }120+121+ if got, want := data.Numbers[0][2], "10"; got != want {122+ t.Fatalf("row 0 col 2 = %q, want %q", got, want)123+ }124+ if got, want := data.Numbers[1][1], ""; got != want {125+ t.Fatalf("row 1 col 1 = %q, want empty", got)126+ }127+ if got, want := data.Numbers[2][0], "7"; got != want {128+ t.Fatalf("row 2 col 0 = %q, want %q", got, want)129+ }130+ if got, want := data.Numbers[3][3], ""; got != want {131+ t.Fatalf("row 3 col 3 = %q, want empty", got)132+ }133+}134+135+func TestParseHitoriPrintDataCompactRuneEncoding(t *testing.T) {136+ save := []byte(`{"size":3,"numbers":"12:\n4..\n789"}`)137+138+ data, err := ParseHitoriPrintData(save)139+ if err != nil {140+ t.Fatal(err)141+ }142+ if data == nil {143+ t.Fatal("expected hitori print data")144+ }145+146+ if got, want := data.Numbers[0][2], "10"; got != want {147+ t.Fatalf("row 0 col 2 = %q, want %q", got, want)148+ }149+ if got, want := data.Numbers[1][1], ""; got != want {150+ t.Fatalf("row 1 col 1 = %q, want empty", got)151+ }152+}153+154+func TestParseTakuzuPrintData(t *testing.T) {155+ save := []byte(`{"size":4,"state":"01..\n10..\n0011\n1111","provided":"#.\n.##\n####\n#"}`)156+157+ data, err := ParseTakuzuPrintData(save)158+ if err != nil {159+ t.Fatal(err)160+ }161+ if data == nil {162+ t.Fatal("expected takuzu print data")163+ }164+ if got, want := data.Size, 4; got != want {165+ t.Fatalf("size = %d, want %d", got, want)166+ }167+ if !data.GroupEveryTwo {168+ t.Fatal("expected GroupEveryTwo to be true")169+ }170+171+ if got, want := data.Givens[0][0], "0"; got != want {172+ t.Fatalf("row 0 col 0 = %q, want %q", got, want)173+ }174+ if got, want := data.Givens[1][2], ""; got != want {175+ t.Fatalf("row 1 col 2 = %q, want empty", got)176+ }177+ if got, want := data.Givens[2][3], "1"; got != want {178+ t.Fatalf("row 2 col 3 = %q, want %q", got, want)179+ }180+ if got, want := data.Givens[3][1], ""; got != want {181+ t.Fatalf("row 3 col 1 = %q, want empty", got)182+ }183+}
A pdfexport/render.go +169 -0

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

A pdfexport/render_cover.go +148 -0

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

A pdfexport/render_cover_test.go +29 -0

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

A pdfexport/render_error_test.go +48 -0

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

A pdfexport/render_kit.go +92 -0

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

A pdfexport/render_layout_test.go +143 -0

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

A pdfexport/render_metadata.go +17 -0

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

A pdfexport/render_title.go +284 -0

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

A pdfexport/render_tokens.go +177 -0

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

A pdfexport/types.go +116 -0

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

A shikaku/PrintAdapter.go +102 -0

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

A sudoku/PrintAdapter.go +112 -0

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

A takuzu/PrintAdapter.go +138 -0

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

A takuzu/print_adapter_test.go +44 -0

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

A wordsearch/PrintAdapter.go +261 -0

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