diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 35bb1b1..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,216 +0,0 @@ -# AGENTS.md - PuzzleTea Development Guide - -## Build, Test, and Quality Commands - -### Build & Run -```bash -just # build with version from git tags -just run # build and run -just install # install to $GOPATH/bin -just clean # remove binary and dist/ -``` -Without `just`: `go build -ldflags "-X github.com/FelineStateMachine/puzzletea/cmd.Version=$(git describe --tags --always --dirty)" -o puzzletea` - -### CLI Seed Flags -```bash -puzzletea new --set-seed myseed # deterministic game/mode/puzzle selection -puzzletea new nonogram epic --with-seed s1 # deterministic puzzle in selected game/mode -``` -- `--set-seed` cannot be combined with positional game/mode arguments. -- `--with-seed` is used with explicit game/mode arguments for mode-local reproducibility. - -### Testing -```bash -just test # go test ./... -just test-short # go test -short ./... (skips slow generator tests) -go test ./nonogram/ # single package -go test ./sudoku/ -run TestGenerateGrid # specific test -go test ./resolve/ -run TestCategory -v # specific test, verbose -``` - -### Linting & Formatting -```bash -just lint # golangci-lint run ./... -just fmt # gofumpt -w . -just tidy # go mod tidy -``` - -**Always run `just fmt` and `just lint` before committing.** - -Enabled linters (`.golangci.yml`): errcheck, gofumpt (extra-rules), gosimple, govet, ineffassign, misspell (US locale), staticcheck, unused. - ---- - -## Project Structure -``` -puzzletea/ -├── main.go # Entry point: wires cmd package -├── app/ # Root TUI model (Elm architecture) -│ ├── model.go, update.go, view.go, keys.go, spawn.go, debug.go -├── cmd/ # CLI commands (Cobra) -│ ├── root.go, new.go, continue.go, list.go -├── config/ # Persistent JSON config (~/.puzzletea/config.json) -├── theme/ # Color theming (WCAG-compliant palettes, contrast utils) -├── stats/ # XP/level math, streaks, card rendering -├── game/ # Plugin interfaces, cursor, keys, style, border helpers -├── store/ # SQLite persistence (~/.puzzletea/history.db) -├── ui/ # Shared UI: menu list, main menu, table, panel, styles -├── daily/ # Daily puzzle seeding, RNG, mode selection -├── resolve/ # CLI argument resolution (category/mode name matching) -├── namegen/ # Adjective-noun name generator -├── hashiwokakero/, hitori/, lightsout/, nonogram/ -├── shikaku/, sudoku/, takuzu/, wordsearch/ # Puzzle game packages -└── vhs/ # VHS tape files for GIF previews -``` - -Each puzzle package follows a consistent file structure: -- **Capitalized**: `Model.go`, `Gamemode.go`, `Export.go` -- **Lowercase**: `grid.go`, `keys.go`, `style.go`, `generator.go`, `mouse.go`, `_test.go` -- **Docs**: `help.md` (embedded via `//go:embed`), `README.md` - ---- - -## Code Style Guidelines - -### Formatting -- Use `gofumpt` (stricter than gofmt, extra-rules enabled) -- run `just fmt` -- No comments required unless explaining non-obvious logic -- Keep lines under ~100 characters -- US English spelling enforced by misspell linter - -### Imports -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. -```go -import ( - "errors" - "fmt" - - "github.com/FelineStateMachine/puzzletea/game" - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) -``` -Note: always alias bubbletea as `tea`. - -### Naming Conventions -- **Types**: PascalCase (`Model`, `NonogramMode`, `Entry`) -- **Unexported types**: camelCase or lowercase (`grid`, `state`, `menuItem`) -- **Variables/Fields**: camelCase (`rowHints`, `currentHints`) -- **Constants**: camelCase (`mainMenuView`, `gameSelectView`) -- **Interfaces**: PascalCase (`Gamer`, `Spawner`, `Mode`) - -### Type Declarations -Prefer grouped type blocks: `type ( grid [][]rune; state string )` - -### Interface Compliance -Use compile-time checks in grouped var blocks: -```go -var ( - _ game.Mode = NonogramMode{} - _ game.Spawner = NonogramMode{} - _ game.SeededSpawner = NonogramMode{} -) -``` - -### Error Handling -- Return descriptive errors: `errors.New("puzzle width does not support row tomography definition")` -- Check errors immediately; use `fmt.Errorf` with `%w` only when wrapping adds context -- No assertion libraries in tests -- use `t.Errorf` / `t.Fatalf` only - -### Styling -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. - ---- - -## Plugin Architecture - -### Gamer Interface (game/gamer.go) -Every puzzle `Model` must implement: -```go -type Gamer interface { - Init() tea.Cmd - Update(msg tea.Msg) (Gamer, tea.Cmd) - View() string - GetDebugInfo() string - GetFullHelp() [][]key.Binding - GetSave() ([]byte, error) - IsSolved() bool - Reset() Gamer - SetTitle(string) Gamer -} -``` - -### Mode/Spawner Interfaces -```go -type Spawner interface { Spawn() (Gamer, error) } -type SeededSpawner interface { - Spawner - SpawnSeeded(rng *rand.Rand) (Gamer, error) -} -``` - -Every mode type embeds `game.BaseMode` via `game.NewBaseMode(title, description)`. - -### Puzzle Package Exports -Every puzzle package exports: `Modes`, `DailyModes` (`[]list.Item`), `HelpContent` (`string`, from `//go:embed help.md`), `NewMode(...)`, `New(...)`, `ImportModel([]byte) (*Model, error)`, and `DefaultKeyMap`. - -Each package registers itself via `init()` in `Gamemode.go`: -```go -func init() { - game.Register("Nonogram", func(data []byte) (game.Gamer, error) { - return ImportModel(data) - }) -} -``` - ---- - -## Testing Conventions - -### Section Comments with Priority -```go -// --- generateTomography (P0) --- // P0 = critical -// --- Grid serialization (P1) --- // P1 = important -// --- generateRandomState (P2) --- // P2 = generators/slow -// --- TitleBarView (P3) --- // P3 = low-priority UI -``` - -### Table-Driven Tests with Subtests -```go -func TestGenerateTomography(t *testing.T) { - tests := []struct { - name string - grid grid - wantRows TomographyDefinition - }{ - {name: "all filled row", grid: grid{...}, wantRows: ...}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // test logic using t.Errorf / t.Fatalf only (no assertion libraries) - }) - } -} -``` - -### Save/Load Round-Trip Pattern -```go -data, err := m.GetSave() -if err != nil { t.Fatal(err) } -loaded, err := ImportModel(data) -if err != nil { t.Fatal(err) } -// verify state preserved -``` - -### Slow Test Gating -```go -if testing.Short() { - t.Skip("skipping slow generator test in short mode") -} -``` - ---- - -## Global Keybindings -`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 diff --git a/README.md b/README.md index bc6d83e..81e982d 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,38 @@ puzzletea new --set-seed myseed puzzletea new nonogram epic --with-seed myseed ``` +Export printable puzzle sets to JSONL: + +```bash +# Stream JSONL to stdout (redirect if desired) +puzzletea new nonogram mini --export 2 > nonogram-mini-set.jsonl + +# Single mode export +puzzletea new nonogram mini -e 6 -o nonogram-mini-set.jsonl + +# Mixed modes within a category (deterministic with --with-seed) +puzzletea new sudoku --export 10 -o sudoku-mixed.jsonl --with-seed zine-issue-01 +``` + +Render one or more JSONL packs into a half-letter print PDF: + +```bash +puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01 --volume 1 --title "Catacombs & Pines" +``` + +`--title` sets the pack subtitle (title page, and cover pages when enabled), and `--volume` sets the volume number. +By default, covers are not included. Use `--cover-color` to include front/back cover pages. +Page count is always auto-padded to a multiple of 4 for half-letter booklet printing. + +Font license note (Atkinson Hyperlegible Next): + +- Follow the SIL OFL 1.1 requirements in `pdfexport/fonts/OFL.txt`. +- Do not sell the font files by themselves. +- If redistributing fonts with software, include the copyright notice and OFL text. +- Modified font versions must keep OFL terms, and modified names must respect Reserved Font Name rules. + +`Lights Out` is currently excluded from export because it does not translate cleanly to paper workflows. + Override the color theme: ```bash diff --git a/cmd/export_pdf.go b/cmd/export_pdf.go new file mode 100644 index 0000000..f23902f --- /dev/null +++ b/cmd/export_pdf.go @@ -0,0 +1,245 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/FelineStateMachine/puzzletea/app" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" + + "github.com/spf13/cobra" +) + +var ( + flagPDFOutput string + flagPDFTitle string + flagPDFHeader string + flagPDFVolume int + flagPDFAdvert string + flagPDFShuffleSeed string + flagPDFCoverColor string +) + +var exportPDFCmd = &cobra.Command{ + Use: "export-pdf [more.jsonl ...]", + Short: "Convert one or more PuzzleTea JSONL exports into a half-letter printable PDF", + 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.", + Args: cobra.MinimumNArgs(1), + RunE: runExportPDF, +} + +func init() { + exportPDFCmd.Flags().StringVarP(&flagPDFOutput, "output", "o", "", "write output PDF path (defaults to -print.pdf)") + exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "subtitle shown on the title page (and on covers when enabled)") + exportPDFCmd.Flags().StringVar(&flagPDFHeader, "header", "", "optional intro paragraph shown on the title page under 'PuzzleTea Puzzle Pack'") + exportPDFCmd.Flags().IntVar(&flagPDFVolume, "volume", 1, "volume number shown on the title page (and on covers when enabled) (must be >= 1)") + exportPDFCmd.Flags().StringVar(&flagPDFAdvert, "advert", "Find more puzzles at github.com/FelineStateMachine/puzzletea", "advert text shown on the title page") + exportPDFCmd.Flags().StringVar(&flagPDFShuffleSeed, "shuffle-seed", "", "seed for deterministic within-band difficulty mixing") + exportPDFCmd.Flags().StringVar(&flagPDFCoverColor, "cover-color", "", `accent color for optional front/back covers: hex "#RRGGBB" or decimal "R,G,B" (omit for no covers)`) +} + +func runExportPDF(cmd *cobra.Command, args []string) error { + docs, err := pdfexport.ParseJSONLFiles(args) + if err != nil { + return err + } + + puzzles := flattenPuzzles(docs) + if len(puzzles) == 0 { + return nil + } + + lookup := buildModeDifficultyLookup(app.Categories) + annotateDifficulty(puzzles, lookup) + + shuffleSeed := strings.TrimSpace(flagPDFShuffleSeed) + if shuffleSeed == "" { + shuffleSeed = time.Now().Format(time.RFC3339Nano) + } + ordered := pdfexport.OrderPuzzlesForPrint(puzzles, shuffleSeed) + + output := strings.TrimSpace(flagPDFOutput) + if output == "" { + base := filepath.Base(args[0]) + output = strings.TrimSuffix(base, filepath.Ext(base)) + "-print.pdf" + } + if !strings.EqualFold(filepath.Ext(output), ".pdf") { + return fmt.Errorf("--output must use a .pdf extension") + } + + cfg, err := buildRenderConfigForPDF(docs, shuffleSeed, time.Now()) + if err != nil { + return err + } + if err := pdfexport.WritePDF(output, docs, ordered, cfg); err != nil { + return err + } + + _, err = fmt.Fprintf(cmd.OutOrStdout(), "wrote %s with %d puzzles\n", output, len(ordered)) + return err +} + +func flattenPuzzles(docs []pdfexport.PackDocument) []pdfexport.Puzzle { + puzzles := []pdfexport.Puzzle{} + for _, doc := range docs { + puzzles = append(puzzles, doc.Puzzles...) + } + return puzzles +} + +func defaultPDFTitle(docs []pdfexport.PackDocument) string { + if len(docs) == 1 { + category := strings.TrimSpace(docs[0].Metadata.Category) + if category != "" { + return fmt.Sprintf("%s Puzzle Pack", category) + } + } + return "PuzzleTea Mixed Puzzle Pack" +} + +func validatePDFVolume(volume int) error { + if volume < 1 { + return fmt.Errorf("--volume must be >= 1") + } + return nil +} + +func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string, now time.Time) (pdfexport.RenderConfig, error) { + if err := validatePDFVolume(flagPDFVolume); err != nil { + return pdfexport.RenderConfig{}, err + } + + subtitle := strings.TrimSpace(flagPDFTitle) + if subtitle == "" { + subtitle = defaultPDFTitle(docs) + } + + coverColor, err := parseCoverColor(flagPDFCoverColor) + if err != nil { + return pdfexport.RenderConfig{}, fmt.Errorf("--cover-color: %w", err) + } + + cfg := pdfexport.RenderConfig{ + CoverSubtitle: subtitle, + HeaderText: strings.TrimSpace(flagPDFHeader), + VolumeNumber: flagPDFVolume, + AdvertText: flagPDFAdvert, + GeneratedAt: now, + ShuffleSeed: shuffleSeed, + CoverColor: coverColor, + } + return cfg, nil +} + +func buildModeDifficultyLookup(categories []game.Category) map[string]map[string]float64 { + lookup := make(map[string]map[string]float64, len(categories)) + + for _, cat := range categories { + titles := []string{} + for _, item := range cat.Modes { + mode, ok := item.(game.Mode) + if !ok { + continue + } + title := strings.TrimSpace(mode.Title()) + if title == "" { + continue + } + titles = append(titles, title) + } + + if len(titles) == 0 { + continue + } + + scores := make(map[string]float64, len(titles)) + if len(titles) == 1 { + scores[normalizeDifficultyToken(titles[0])] = 0.5 + } else { + for i, title := range titles { + scores[normalizeDifficultyToken(title)] = float64(i) / float64(len(titles)-1) + } + } + + lookup[normalizeDifficultyToken(cat.Name)] = scores + } + + return lookup +} + +func annotateDifficulty(puzzles []pdfexport.Puzzle, lookup map[string]map[string]float64) { + for i := range puzzles { + mode := normalizeDifficultyToken(puzzles[i].ModeSelection) + if mode == "" || strings.Contains(mode, "mixed modes") { + puzzles[i].DifficultyScore = 0.5 + puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium + puzzles[i].DifficultySource = "mixed-mode fallback" + continue + } + + category := normalizeDifficultyToken(puzzles[i].Category) + modes, ok := lookup[category] + if !ok { + puzzles[i].DifficultyScore = 0.5 + puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium + puzzles[i].DifficultySource = "category lookup fallback" + continue + } + + score, ok := modes[mode] + if !ok { + puzzles[i].DifficultyScore = 0.5 + puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceMedium + puzzles[i].DifficultySource = "mode lookup fallback" + continue + } + + puzzles[i].DifficultyScore = score + puzzles[i].DifficultyConfidence = pdfexport.DifficultyConfidenceHigh + puzzles[i].DifficultySource = "mode-order" + } +} + +func normalizeDifficultyToken(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.ReplaceAll(s, "-", " ") + s = strings.ReplaceAll(s, "_", " ") + return strings.Join(strings.Fields(s), " ") +} + +// parseCoverColor parses a cover color string in hex ("#RRGGBB") or +// decimal ("R,G,B") format. Returns nil if s is empty (no cover pages). +func parseCoverColor(s string) (*pdfexport.RGB, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, nil + } + + // Hex format: #RRGGBB or RRGGBB + hex := strings.TrimPrefix(s, "#") + if len(hex) == 6 { + r, errR := strconv.ParseUint(hex[0:2], 16, 8) + g, errG := strconv.ParseUint(hex[2:4], 16, 8) + b, errB := strconv.ParseUint(hex[4:6], 16, 8) + if errR == nil && errG == nil && errB == nil { + return &pdfexport.RGB{R: uint8(r), G: uint8(g), B: uint8(b)}, nil + } + } + + // Decimal format: R,G,B + parts := strings.Split(s, ",") + if len(parts) == 3 { + r, errR := strconv.ParseUint(strings.TrimSpace(parts[0]), 10, 8) + g, errG := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 8) + b, errB := strconv.ParseUint(strings.TrimSpace(parts[2]), 10, 8) + if errR == nil && errG == nil && errB == nil { + return &pdfexport.RGB{R: uint8(r), G: uint8(g), B: uint8(b)}, nil + } + } + + return nil, fmt.Errorf("invalid color %q — use hex \"#RRGGBB\" or decimal \"R,G,B\"", s) +} diff --git a/cmd/export_pdf_test.go b/cmd/export_pdf_test.go new file mode 100644 index 0000000..b47999f --- /dev/null +++ b/cmd/export_pdf_test.go @@ -0,0 +1,284 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/FelineStateMachine/puzzletea/pdfexport" + + "github.com/spf13/cobra" +) + +func TestExportPDFVolumeFlagDefault(t *testing.T) { + flag := exportPDFCmd.Flags().Lookup("volume") + if flag == nil { + t.Fatal("expected --volume flag") + } + if flag.DefValue != "1" { + t.Fatalf("default volume = %q, want %q", flag.DefValue, "1") + } +} + +func TestValidatePDFVolume(t *testing.T) { + tests := []struct { + name string + volume int + wantOK bool + }{ + {name: "default volume", volume: 1, wantOK: true}, + {name: "positive volume", volume: 7, wantOK: true}, + {name: "zero volume rejected", volume: 0, wantOK: false}, + {name: "negative volume rejected", volume: -3, wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePDFVolume(tt.volume) + if tt.wantOK && err != nil { + t.Fatalf("validatePDFVolume(%d) error = %v, want nil", tt.volume, err) + } + if !tt.wantOK { + if err == nil { + t.Fatalf("validatePDFVolume(%d) expected error", tt.volume) + } + if !strings.Contains(err.Error(), "--volume") { + t.Fatalf("validatePDFVolume(%d) error = %q, want mention of --volume", tt.volume, err.Error()) + } + } + }) + } +} + +func TestBuildRenderConfigForPDFUsesTitleAsCoverSubtitle(t *testing.T) { + reset := snapshotExportPDFFlags() + defer reset() + + flagPDFTitle = "Catacombs & Pines" + flagPDFHeader = "Custom heading paragraph" + flagPDFVolume = 7 + flagPDFAdvert = "Custom advert" + flagPDFCoverColor = "" + + now := time.Date(2026, 2, 22, 11, 0, 0, 0, time.UTC) + docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Nonogram"}}} + cfg, err := buildRenderConfigForPDF(docs, "seed-1", now) + if err != nil { + t.Fatalf("buildRenderConfigForPDF error = %v", err) + } + if cfg.CoverSubtitle != "Catacombs & Pines" { + t.Fatalf("CoverSubtitle = %q, want %q", cfg.CoverSubtitle, "Catacombs & Pines") + } + if cfg.VolumeNumber != 7 { + t.Fatalf("VolumeNumber = %d, want %d", cfg.VolumeNumber, 7) + } + if cfg.HeaderText != "Custom heading paragraph" { + t.Fatalf("HeaderText = %q, want %q", cfg.HeaderText, "Custom heading paragraph") + } + if cfg.AdvertText != "Custom advert" { + t.Fatalf("AdvertText = %q, want %q", cfg.AdvertText, "Custom advert") + } + if !cfg.GeneratedAt.Equal(now) { + t.Fatalf("GeneratedAt = %v, want %v", cfg.GeneratedAt, now) + } +} + +func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) { + reset := snapshotExportPDFFlags() + defer reset() + + flagPDFTitle = "" + flagPDFVolume = 1 + flagPDFAdvert = "Find more puzzles" + flagPDFCoverColor = "" + + docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Sudoku"}}} + cfg, err := buildRenderConfigForPDF(docs, "seed-2", time.Now()) + if err != nil { + t.Fatalf("buildRenderConfigForPDF error = %v", err) + } + if cfg.CoverSubtitle != "Sudoku Puzzle Pack" { + t.Fatalf("CoverSubtitle = %q, want %q", cfg.CoverSubtitle, "Sudoku Puzzle Pack") + } +} + +func TestBuildRenderConfigForPDFCoverColorControlsCoverPages(t *testing.T) { + reset := snapshotExportPDFFlags() + defer reset() + + flagPDFTitle = "Issue 01" + flagPDFVolume = 1 + flagPDFAdvert = "Find more puzzles" + docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Sudoku"}}} + + flagPDFCoverColor = "" + cfgNoCover, err := buildRenderConfigForPDF(docs, "seed-3", time.Now()) + if err != nil { + t.Fatalf("buildRenderConfigForPDF (no cover color) error = %v", err) + } + if cfgNoCover.CoverColor != nil { + t.Fatalf("CoverColor = %+v, want nil when --cover-color is omitted", cfgNoCover.CoverColor) + } + + flagPDFCoverColor = "#112233" + cfgWithCover, err := buildRenderConfigForPDF(docs, "seed-4", time.Now()) + if err != nil { + t.Fatalf("buildRenderConfigForPDF (with cover color) error = %v", err) + } + if cfgWithCover.CoverColor == nil { + t.Fatal("CoverColor = nil, want parsed color when --cover-color is set") + } + if *cfgWithCover.CoverColor != (pdfexport.RGB{R: 0x11, G: 0x22, B: 0x33}) { + t.Fatalf("CoverColor = %+v, want {R:17 G:34 B:51}", *cfgWithCover.CoverColor) + } +} + +func TestRunExportPDFSilentlyNoOpsWhenAllPuzzlesUnsupported(t *testing.T) { + reset := snapshotExportPDFFlags() + defer reset() + + dir := t.TempDir() + input := filepath.Join(dir, "lights.jsonl") + output := filepath.Join(dir, "lights.pdf") + + record := pdfexport.JSONLRecord{ + Schema: pdfexport.ExportSchemaV1, + Pack: pdfexport.JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Lights Out", + ModeSelection: "Standard", + Count: 1, + }, + Puzzle: pdfexport.JSONLPuzzle{ + Index: 1, + Name: "glow-shore", + Game: "Lights Out", + Mode: "Standard", + Save: json.RawMessage(`{"size":5}`), + }, + } + data, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(input, append(data, '\n'), 0o644); err != nil { + t.Fatal(err) + } + + flagPDFOutput = output + flagPDFVolume = 1 + + var out bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&out) + + if err := runExportPDF(cmd, []string{input}); err != nil { + t.Fatalf("expected no-op success, got %v", err) + } + if out.String() != "" { + t.Fatalf("expected no stdout output, got %q", out.String()) + } + if _, err := os.Stat(output); !os.IsNotExist(err) { + t.Fatalf("expected no output file, stat err = %v", err) + } +} + +func TestRunExportPDFSkipsUnsupportedRecordsWhenMixed(t *testing.T) { + reset := snapshotExportPDFFlags() + defer reset() + + dir := t.TempDir() + input := filepath.Join(dir, "mixed.jsonl") + output := filepath.Join(dir, "mixed.pdf") + + records := []pdfexport.JSONLRecord{ + { + Schema: pdfexport.ExportSchemaV1, + Pack: pdfexport.JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Lights Out", + ModeSelection: "Standard", + Count: 2, + }, + Puzzle: pdfexport.JSONLPuzzle{ + Index: 1, + Name: "glow-shore", + Game: "Lights Out", + Mode: "Standard", + Save: json.RawMessage(`{"size":5}`), + }, + }, + { + Schema: pdfexport.ExportSchemaV1, + Pack: pdfexport.JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Sudoku", + ModeSelection: "Easy", + Count: 2, + }, + Puzzle: pdfexport.JSONLPuzzle{ + Index: 2, + Name: "moss-pine", + Game: "Sudoku", + Mode: "Easy", + Save: json.RawMessage(`{"provided":[{"x":0,"y":0,"v":5}]}`), + }, + }, + } + var lines []byte + for _, record := range records { + line, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + lines = append(lines, line...) + lines = append(lines, '\n') + } + if err := os.WriteFile(input, lines, 0o644); err != nil { + t.Fatal(err) + } + + flagPDFOutput = output + flagPDFVolume = 1 + + cmd := &cobra.Command{} + cmd.SetOut(&bytes.Buffer{}) + if err := runExportPDF(cmd, []string{input}); err != nil { + t.Fatalf("expected mixed export success, got %v", err) + } + + info, err := os.Stat(output) + if err != nil { + t.Fatalf("expected output file, got stat error: %v", err) + } + if info.Size() == 0 { + t.Fatal("expected non-empty output PDF") + } +} + +func snapshotExportPDFFlags() func() { + oldTitle := flagPDFTitle + oldHeader := flagPDFHeader + oldVolume := flagPDFVolume + oldAdvert := flagPDFAdvert + oldCoverColor := flagPDFCoverColor + oldOutput := flagPDFOutput + oldShuffle := flagPDFShuffleSeed + + return func() { + flagPDFTitle = oldTitle + flagPDFHeader = oldHeader + flagPDFVolume = oldVolume + flagPDFAdvert = oldAdvert + flagPDFCoverColor = oldCoverColor + flagPDFOutput = oldOutput + flagPDFShuffleSeed = oldShuffle + } +} diff --git a/cmd/new.go b/cmd/new.go index 644918b..b660239 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -18,6 +18,8 @@ import ( var ( flagSetSeed string flagWithSeed string + flagExport int + flagOutput string ) var newCmd = &cobra.Command{ @@ -28,6 +30,10 @@ var newCmd = &cobra.Command{ strings.Join(resolve.CategoryNames(app.GameCategories), "\n ")), Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { + if flagOutput != "" || cmd.Flags().Changed("export") { + return runNewExport(cmd, args) + } + cfg := loadConfig() if flagSetSeed != "" { @@ -51,6 +57,8 @@ var newCmd = &cobra.Command{ func init() { newCmd.Flags().StringVar(&flagSetSeed, "set-seed", "", "seed string for deterministic puzzle selection and generation") newCmd.Flags().StringVar(&flagWithSeed, "with-seed", "", "seed string for deterministic puzzle generation within the selected game/mode") + newCmd.Flags().IntVarP(&flagExport, "export", "e", 1, "number of puzzles to export") + newCmd.Flags().StringVarP(&flagOutput, "output", "o", "", "write puzzles to a jsonl file (defaults to stdout)") } // launchNewGame resolves the game/mode, spawns a new game, and launches the TUI. diff --git a/cmd/new_export.go b/cmd/new_export.go new file mode 100644 index 0000000..5ae86d1 --- /dev/null +++ b/cmd/new_export.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "math/rand/v2" + "os" + "path/filepath" + "strings" + "time" + + "github.com/FelineStateMachine/puzzletea/app" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/namegen" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/FelineStateMachine/puzzletea/resolve" + + "github.com/spf13/cobra" +) + +var exportNow = time.Now + +type exportModeEntry struct { + spawner game.Spawner + mode string +} + +func runNewExport(cmd *cobra.Command, args []string) error { + if err := validateNewExportFlags(cmd, args); err != nil { + return err + } + + cat, err := resolve.Category(args[0], app.GameCategories) + if err != nil { + return err + } + if !game.HasPrintAdapter(cat.Name) { + return nil + } + + modeArg := "" + if len(args) > 1 { + modeArg = args[1] + } + + entries, modeSelection, err := collectExportModes(cat, modeArg) + if err != nil { + return err + } + + generatedAt := exportNow() + records, err := buildExportRecords(cat.Name, modeSelection, entries, flagExport, flagWithSeed, generatedAt) + if err != nil { + return err + } + + if err := writeExportJSONL(cmd, flagOutput, records); err != nil { + return err + } + + return nil +} + +func validateNewExportFlags(cmd *cobra.Command, args []string) error { + if flagExport < 1 { + return fmt.Errorf("--export must be at least 1") + } + if strings.TrimSpace(flagOutput) != "" && !strings.EqualFold(filepath.Ext(flagOutput), ".jsonl") { + return fmt.Errorf("--output must use a .jsonl extension") + } + if flagSetSeed != "" { + return fmt.Errorf("--set-seed cannot be combined with export (--export/--output)") + } + if len(args) == 0 { + return fmt.Errorf("requires at least 1 arg(s), only received 0") + } + if len(args) > 2 { + return fmt.Errorf("accepts at most 2 arg(s), received %d", len(args)) + } + return nil +} + +func collectExportModes(cat game.Category, modeArg string) ([]exportModeEntry, string, error) { + if modeArg != "" { + spawner, modeTitle, err := resolve.Mode(cat, modeArg) + if err != nil { + return nil, "", err + } + return []exportModeEntry{{spawner: spawner, mode: modeTitle}}, modeTitle, nil + } + + entries := make([]exportModeEntry, 0, len(cat.Modes)) + for _, item := range cat.Modes { + mode, ok := item.(game.Mode) + if !ok { + continue + } + spawner, ok := item.(game.Spawner) + if !ok { + continue + } + entries = append(entries, exportModeEntry{ + spawner: spawner, + mode: mode.Title(), + }) + } + if len(entries) == 0 { + return nil, "", fmt.Errorf("game %q has no exportable modes", cat.Name) + } + + return entries, "mixed modes", nil +} + +func buildExportRecords( + gameType, modeSelection string, + entries []exportModeEntry, + count int, + seed string, + generatedAt time.Time, +) ([]pdfexport.JSONLRecord, error) { + var rng *rand.Rand + if seed != "" { + rng = resolve.RNGFromString(seed) + } + + nameSeed := seed + if strings.TrimSpace(nameSeed) == "" { + nameSeed = generatedAt.Format(time.RFC3339Nano) + } + nameRNG := resolve.RNGFromString("export-names:" + nameSeed) + + records := make([]pdfexport.JSONLRecord, 0, count) + for i := 0; i < count; i++ { + entry := entries[0] + if len(entries) > 1 { + var modeIndex int + if rng != nil { + modeIndex = rng.IntN(len(entries)) + } else { + modeIndex = rand.IntN(len(entries)) + } + entry = entries[modeIndex] + } + + puzzle, err := spawnExportPuzzle(entry.spawner, rng) + if err != nil { + return nil, fmt.Errorf("generate puzzle %d: %w", i+1, err) + } + + save, err := puzzle.GetSave() + if err != nil { + return nil, fmt.Errorf("serialize puzzle %d: %w", i+1, err) + } + if !json.Valid(save) { + return nil, fmt.Errorf("serialize puzzle %d: save payload is not valid JSON", i+1) + } + + records = append(records, pdfexport.JSONLRecord{ + Schema: pdfexport.ExportSchemaV1, + Pack: pdfexport.JSONLPackMeta{ + Generated: generatedAt.Format(time.RFC3339), + Version: Version, + Category: gameType, + ModeSelection: modeSelection, + Count: count, + Seed: seed, + }, + Puzzle: pdfexport.JSONLPuzzle{ + Index: i + 1, + Name: namegen.GenerateSeeded(nameRNG), + Game: gameType, + Mode: entry.mode, + Save: json.RawMessage(save), + }, + }) + } + + return records, nil +} + +func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error) { + if rng == nil { + return spawner.Spawn() + } + + seeded, ok := spawner.(game.SeededSpawner) + if !ok { + return nil, fmt.Errorf("mode does not support deterministic spawning") + } + return seeded.SpawnSeeded(rng) +} + +func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error { + var b strings.Builder + for _, record := range records { + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("encode jsonl record: %w", err) + } + b.Write(data) + b.WriteByte('\n') + } + + content := b.String() + if strings.TrimSpace(path) == "" { + if _, err := io.WriteString(cmd.OutOrStdout(), content); err != nil { + return fmt.Errorf("write export jsonl to stdout: %w", err) + } + return nil + } + + dir := filepath.Dir(path) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("write output jsonl: %w", err) + } + return nil +} diff --git a/cmd/new_export_test.go b/cmd/new_export_test.go new file mode 100644 index 0000000..43fc832 --- /dev/null +++ b/cmd/new_export_test.go @@ -0,0 +1,221 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/FelineStateMachine/puzzletea/pdfexport" + + "github.com/spf13/cobra" +) + +func TestRunNewExportRejectsUnsupportedGame(t *testing.T) { + withExportFlagReset(t) + output := filepath.Join(t.TempDir(), "lights.jsonl") + flagOutput = output + + cmd, _ := newExportTestCmd(t, false) + err := runNewExport(cmd, []string{"lights-out"}) + if err != nil { + t.Fatalf("expected silent no-op for unsupported game, got: %v", err) + } + if _, statErr := os.Stat(output); !os.IsNotExist(statErr) { + t.Fatalf("expected no output file, stat err = %v", statErr) + } +} + +func TestRunNewExportValidation(t *testing.T) { + t.Run("writes jsonl to stdout when output omitted", func(t *testing.T) { + withExportFlagReset(t) + flagExport = 2 + + cmd, out := newExportTestCmd(t, true) + err := runNewExport(cmd, []string{"nonogram", "mini"}) + if err != nil { + t.Fatalf("expected stdout export success, got error: %v", err) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if got, want := len(lines), 2; got != want { + t.Fatalf("jsonl lines = %d, want %d", got, want) + } + for i, line := range lines { + var record pdfexport.JSONLRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + t.Fatalf("line %d is not valid jsonl: %v", i+1, err) + } + if record.Schema != pdfexport.ExportSchemaV1 { + t.Fatalf("line %d schema = %q, want %q", i+1, record.Schema, pdfexport.ExportSchemaV1) + } + } + }) + + t.Run("output extension must be jsonl", func(t *testing.T) { + withExportFlagReset(t) + flagOutput = filepath.Join(t.TempDir(), "out.txt") + + cmd, _ := newExportTestCmd(t, false) + err := runNewExport(cmd, []string{"nonogram", "mini"}) + if err == nil { + t.Fatal("expected extension validation error") + } + if !strings.Contains(err.Error(), ".jsonl extension") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("set-seed cannot be combined with output", func(t *testing.T) { + withExportFlagReset(t) + flagSetSeed = "abc" + flagOutput = filepath.Join(t.TempDir(), "out.jsonl") + + cmd, _ := newExportTestCmd(t, false) + err := runNewExport(cmd, []string{"nonogram", "mini"}) + if err == nil { + t.Fatal("expected set-seed validation error") + } + if !strings.Contains(err.Error(), "--set-seed cannot be combined with export (--export/--output)") { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestRunNewExportReproducibleWithSeed(t *testing.T) { + withExportFlagReset(t) + + fixedNow := time.Date(2026, 2, 22, 11, 0, 0, 0, time.UTC) + previousNow := exportNow + exportNow = func() time.Time { return fixedNow } + t.Cleanup(func() { exportNow = previousNow }) + + fileA := filepath.Join(t.TempDir(), "a.jsonl") + fileB := filepath.Join(t.TempDir(), "b.jsonl") + + flagExport = 3 + flagWithSeed = "zine-seed-01" + flagOutput = fileA + cmdA, _ := newExportTestCmd(t, false) + if err := runNewExport(cmdA, []string{"nonogram", "mini"}); err != nil { + t.Fatalf("first export failed: %v", err) + } + + flagOutput = fileB + cmdB, _ := newExportTestCmd(t, false) + if err := runNewExport(cmdB, []string{"nonogram", "mini"}); err != nil { + t.Fatalf("second export failed: %v", err) + } + + a, err := os.ReadFile(fileA) + if err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(fileB) + if err != nil { + t.Fatal(err) + } + if string(a) != string(b) { + t.Fatal("expected deterministic jsonl output for identical seed and args") + } +} + +func TestRunNewExportOverwritesOutputFile(t *testing.T) { + withExportFlagReset(t) + + file := filepath.Join(t.TempDir(), "out.jsonl") + if err := os.WriteFile(file, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + + flagExport = 1 + flagWithSeed = "overwrite-seed" + flagOutput = file + + cmd, _ := newExportTestCmd(t, false) + if err := runNewExport(cmd, []string{"nonogram", "mini"}); err != nil { + t.Fatalf("export failed: %v", err) + } + + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if string(data) == "old" { + t.Fatal("expected output file to be overwritten") + } + if !strings.Contains(string(data), pdfexport.ExportSchemaV1) { + t.Fatal("expected jsonl export schema marker") + } +} + +func TestRunNewExportOmitsPrintPayload(t *testing.T) { + withExportFlagReset(t) + flagExport = 1 + + cmd, out := newExportTestCmd(t, true) + if err := runNewExport(cmd, []string{"sudoku", "easy"}); err != nil { + t.Fatalf("expected sudoku export success, got error: %v", err) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 1 { + t.Fatalf("jsonl lines = %d, want 1", len(lines)) + } + + var record map[string]any + if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + t.Fatal(err) + } + if _, ok := record["print"]; ok { + t.Fatal("did not expect print payload in export record") + } + + puzzle, ok := record["puzzle"].(map[string]any) + if !ok { + t.Fatalf("expected puzzle object, got %T", record["puzzle"]) + } + if _, ok := puzzle["snippet"]; ok { + t.Fatal("did not expect markdown snippet in export record") + } +} + +func withExportFlagReset(t *testing.T) { + t.Helper() + + prevSetSeed := flagSetSeed + prevWithSeed := flagWithSeed + prevExport := flagExport + prevOutput := flagOutput + + flagSetSeed = "" + flagWithSeed = "" + flagExport = 1 + flagOutput = "" + + t.Cleanup(func() { + flagSetSeed = prevSetSeed + flagWithSeed = prevWithSeed + flagExport = prevExport + flagOutput = prevOutput + }) +} + +func newExportTestCmd(t *testing.T, exportChanged bool) (*cobra.Command, *bytes.Buffer) { + t.Helper() + + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + cmd.Flags().Int("export", 1, "") + if exportChanged { + if err := cmd.Flags().Set("export", strconv.Itoa(flagExport)); err != nil { + t.Fatal(err) + } + } + return cmd, &out +} diff --git a/cmd/root.go b/cmd/root.go index 378eb40..c5aca3b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -74,7 +74,7 @@ func init() { RootCmd.Flags().StringVar(&flagSetSeed, "set-seed", "", "seed string for deterministic puzzle selection and generation") RootCmd.PersistentFlags().StringVar(&flagTheme, "theme", "", "color theme name (overrides config)") - RootCmd.AddCommand(newCmd, continueCmd, listCmd) + RootCmd.AddCommand(newCmd, continueCmd, listCmd, exportPDFCmd) } // loadConfig reads the config file and applies the active theme. The --theme diff --git a/game/print_adapter.go b/game/print_adapter.go new file mode 100644 index 0000000..363ab79 --- /dev/null +++ b/game/print_adapter.go @@ -0,0 +1,64 @@ +package game + +import ( + "reflect" + "strings" + + "codeberg.org/go-pdf/fpdf" +) + +type PrintAdapter interface { + CanonicalGameType() string + Aliases() []string + BuildPDFPayload(save []byte) (any, error) + RenderPDFBody(pdf *fpdf.Fpdf, payload any) error +} + +var printAdapterRegistry = map[string]PrintAdapter{} + +func RegisterPrintAdapter(adapter PrintAdapter) { + canonical := normalizeGameTypeToken(adapter.CanonicalGameType()) + if canonical == "" { + return + } + + printAdapterRegistry[canonical] = adapter + for _, alias := range adapter.Aliases() { + normalized := normalizeGameTypeToken(alias) + if normalized == "" { + continue + } + printAdapterRegistry[normalized] = adapter + } +} + +func LookupPrintAdapter(gameType string) (PrintAdapter, bool) { + adapter, ok := printAdapterRegistry[normalizeGameTypeToken(gameType)] + return adapter, ok +} + +func HasPrintAdapter(gameType string) bool { + _, ok := LookupPrintAdapter(gameType) + return ok +} + +func IsNilPrintPayload(payload any) bool { + if payload == nil { + return true + } + + v := reflect.ValueOf(payload) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + default: + return false + } +} + +func normalizeGameTypeToken(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.ReplaceAll(s, "-", " ") + s = strings.ReplaceAll(s, "_", " ") + return strings.Join(strings.Fields(s), " ") +} diff --git a/game/print_adapter_test.go b/game/print_adapter_test.go new file mode 100644 index 0000000..ba5028a --- /dev/null +++ b/game/print_adapter_test.go @@ -0,0 +1,63 @@ +package game + +import ( + "testing" + + "codeberg.org/go-pdf/fpdf" +) + +type testPrintAdapter struct { + canonical string + aliases []string +} + +func (a testPrintAdapter) CanonicalGameType() string { return a.canonical } +func (a testPrintAdapter) Aliases() []string { return a.aliases } +func (a testPrintAdapter) BuildPDFPayload([]byte) (any, error) { return nil, nil } +func (a testPrintAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil } + +func TestPrintAdapterRegistryLookupAndAliases(t *testing.T) { + original := printAdapterRegistry + printAdapterRegistry = map[string]PrintAdapter{} + t.Cleanup(func() { printAdapterRegistry = original }) + + adapter := testPrintAdapter{ + canonical: "Word Search", + aliases: []string{"wordsearch", "word-search"}, + } + RegisterPrintAdapter(adapter) + + if !HasPrintAdapter("Word Search") { + t.Fatal("expected canonical lookup to be supported") + } + if !HasPrintAdapter("word_search") { + t.Fatal("expected underscore alias lookup to be supported") + } + if !HasPrintAdapter("wordsearch") { + t.Fatal("expected compact alias lookup to be supported") + } + if HasPrintAdapter("lights out") { + t.Fatal("expected unknown type to be unsupported") + } +} + +func TestRegisterPrintAdapterSkipsBlankCanonical(t *testing.T) { + original := printAdapterRegistry + printAdapterRegistry = map[string]PrintAdapter{} + t.Cleanup(func() { printAdapterRegistry = original }) + + RegisterPrintAdapter(testPrintAdapter{canonical: " "}) + if len(printAdapterRegistry) != 0 { + t.Fatalf("registry size = %d, want 0", len(printAdapterRegistry)) + } +} + +func TestIsNilPrintPayload(t *testing.T) { + var ptr *int + if !IsNilPrintPayload(ptr) { + t.Fatal("expected typed nil pointer to be treated as nil payload") + } + if IsNilPrintPayload(5) { + t.Fatal("expected concrete payload to be non-nil") + } +} diff --git a/go.mod b/go.mod index 854ce3c..b8107f7 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( charm.land/bubbles/v2 v2.0.0-rc.1 charm.land/bubbletea/v2 v2.0.0-rc.2 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea + codeberg.org/go-pdf/fpdf v0.11.1 github.com/spf13/cobra v1.10.2 modernc.org/sqlite v1.44.3 ) @@ -46,7 +47,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/glamour v0.10.0 - github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/ansi v0.11.6 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect diff --git a/go.sum b/go.sum index 17eab83..fce7be7 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ charm.land/bubbletea/v2 v2.0.0-rc.2 h1:TdTbUOFzbufDJmSz/3gomL6q+fR6HwfY+P13hXQzD charm.land/bubbletea/v2 v2.0.0-rc.2/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea h1:XBmpGhIKPN8o9VjuXg+X5WXFsEqUs/YtPx0Q0zzmTTA= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea/go.mod h1:xylWHUuJWcFJqoGrKdZP8Z0y3THC6xqrnfl1IYDviTE= +codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs= +codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= diff --git a/hashiwokakero/PrintAdapter.go b/hashiwokakero/PrintAdapter.go new file mode 100644 index 0000000..aac0044 --- /dev/null +++ b/hashiwokakero/PrintAdapter.go @@ -0,0 +1,140 @@ +package hashiwokakero + +import ( + "math" + "strconv" + "unicode/utf8" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Hashiwokakero" } +func (printAdapter) Aliases() []string { + return []string{"hashi", "hashiwokakero", "hashi wokakero"} +} + +func (printAdapter) BuildPDFPayload(save []byte) (any, error) { + return pdfexport.ParseHashiPrintData(save) +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.HashiData: + renderHashiPage(pdf, data) + } + return nil +} + +func renderHashiPage(pdf *fpdf.Fpdf, data *pdfexport.HashiData) { + if data == nil || data.Width <= 0 || data.Height <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() + + spanX := max(data.Width-1, 1) + spanY := max(data.Height-1, 1) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + step := pdfexport.FitHashiCellSize(spanX, spanY, area) + if step <= 0 { + return + } + + boardW := float64(spanX) * step + boardH := float64(spanY) * step + originX, originY := pdfexport.CenteredOrigin(area, spanX, spanY, step) + islandRadius := hashiIslandRadius(step) + + drawHashiGuideDots(pdf, originX, originY, data.Width, data.Height, step) + drawHashiBoardBorder(pdf, originX, originY, boardW, boardH, islandRadius) + drawHashiIslands(pdf, originX, originY, step, islandRadius, data.Islands) + + ruleY := pdfexport.InstructionY(originY+boardH+pdfexport.InstructionLineHMM, pageH, 1) + pdfexport.SetInstructionStyle(pdf) + pdf.SetXY(area.X, ruleY) + pdf.CellFormat( + area.W, + pdfexport.InstructionLineHMM, + "Connect islands horizontally/vertically with up to two bridges and no crossings.", + "", + 0, + "C", + false, + 0, + "", + ) +} + +func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height int, step float64) { + pdf.SetFillColor(230, 230, 230) + r := math.Max(0.20, math.Min(0.55, step*0.035)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + cx := originX + float64(x)*step + cy := originY + float64(y)*step + pdf.Circle(cx, cy, r, "F") + } + } +} + +func drawHashiBoardBorder(pdf *fpdf.Fpdf, originX, originY, boardW, boardH, islandRadius float64) { + if boardW <= 0 || boardH <= 0 { + return + } + borderPad := islandRadius + 1.2 + pdf.SetDrawColor(35, 35, 35) + pdf.SetLineWidth(pdfexport.OuterBorderLineMM) + pdf.Rect(originX-borderPad, originY-borderPad, boardW+2*borderPad, boardH+2*borderPad, "D") +} + +func drawHashiIslands( + pdf *fpdf.Fpdf, + originX, + originY, + step, + radius float64, + islands []pdfexport.HashiIsland, +) { + pdf.SetDrawColor(20, 20, 20) + pdf.SetFillColor(255, 255, 255) + pdf.SetLineWidth(pdfexport.MajorGridLineMM) + + for _, island := range islands { + cx := originX + float64(island.X)*step + cy := originY + float64(island.Y)*step + pdf.Circle(cx, cy, radius, "DF") + drawHashiIslandNumber(pdf, cx, cy, radius, island.Required) + } +} + +func hashiIslandRadius(step float64) float64 { + return math.Max(1.4, math.Min(3.2, step*0.23)) +} + +func drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int) { + text := strconv.Itoa(required) + fontSize := pdfexport.StandardCellFontSize(radius*2.0, 0.95) + runeCount := utf8.RuneCountInString(text) + switch { + case runeCount >= 3: + fontSize *= 0.70 + case runeCount == 2: + fontSize *= 0.82 + } + fontSize = pdfexport.ClampStandardCellFontSize(fontSize) + + pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray) + pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) + lineH := fontSize * 0.88 + pdf.SetXY(cx-radius, cy-lineH/2) + pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "") +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/hitori/PrintAdapter.go b/hitori/PrintAdapter.go new file mode 100644 index 0000000..330ef79 --- /dev/null +++ b/hitori/PrintAdapter.go @@ -0,0 +1,106 @@ +package hitori + +import ( + "strings" + "unicode/utf8" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Hitori" } +func (printAdapter) Aliases() []string { return []string{"hitori"} } + +func (printAdapter) BuildPDFPayload(save []byte) (any, error) { + return pdfexport.ParseHitoriPrintData(save) +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.HitoriData: + renderHitoriPage(pdf, data) + } + return nil +} + +func renderHitoriPage(pdf *fpdf.Fpdf, data *pdfexport.HitoriData) { + if data == nil || data.Size <= 0 { + return + } + + size := data.Size + pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + cellSize := pdfexport.FitCompactCellSize(size, size, area) + if cellSize <= 0 { + return + } + + blockW := float64(size) * cellSize + blockH := float64(size) * cellSize + startX, startY := pdfexport.CenteredOrigin(area, size, size, cellSize) + + pdf.SetDrawColor(55, 55, 55) + pdf.SetLineWidth(pdfexport.ThinGridLineMM) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + cellX := startX + float64(x)*cellSize + cellY := startY + float64(y)*cellSize + pdf.Rect(cellX, cellY, cellSize, cellSize, "D") + + text := "" + if y < len(data.Numbers) && x < len(data.Numbers[y]) { + text = strings.TrimSpace(data.Numbers[y][x]) + } + if text == "" || text == "." { + continue + } + drawHitoriCellNumber(pdf, cellX, cellY, cellSize, text) + } + } + + pdf.SetDrawColor(35, 35, 35) + pdf.SetLineWidth(pdfexport.OuterBorderLineMM) + pdf.Rect(startX, startY, blockW, blockH, "D") + + ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1) + pdfexport.SetInstructionStyle(pdf) + pdf.SetXY(area.X, ruleY) + pdf.CellFormat( + area.W, + pdfexport.InstructionLineHMM, + "Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected.", + "", + 0, + "C", + false, + 0, + "", + ) +} + +func drawHitoriCellNumber(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) { + fontSize := pdfexport.StandardCellFontSize(cellSize, 0.58) + runeCount := utf8.RuneCountInString(text) + switch { + case runeCount >= 3: + fontSize *= 0.7 + case runeCount == 2: + fontSize *= 0.82 + } + fontSize = pdfexport.ClampStandardCellFontSize(fontSize) + + pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray) + pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) + lineH := fontSize * 0.92 + pdf.SetXY(x, y+(cellSize-lineH)/2) + pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/nonogram/PrintAdapter.go b/nonogram/PrintAdapter.go new file mode 100644 index 0000000..b7983c6 --- /dev/null +++ b/nonogram/PrintAdapter.go @@ -0,0 +1,284 @@ +package nonogram + +import ( + "strconv" + "strings" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Nonogram" } +func (printAdapter) Aliases() []string { return []string{"nonogram"} } + +func (printAdapter) BuildPDFPayload(save []byte) (any, error) { + return pdfexport.ParseNonogramPrintData(save) +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.NonogramData: + renderNonogramPage(pdf, data) + } + return nil +} + +func renderNonogramPage(pdf *fpdf.Fpdf, data *pdfexport.NonogramData) { + if data == nil || data.Width <= 0 || data.Height <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() + + rowHints := normalizeNonogramHintsForRender(data.RowHints, data.Height) + colHints := normalizeNonogramHintsForRender(data.ColHints, data.Width) + + rowHintCols := maxHintDepth(rowHints) + colHintRows := maxHintDepth(colHints) + if rowHintCols < 1 { + rowHintCols = 1 + } + if colHintRows < 1 { + colHintRows = 1 + } + + layout := layoutNonogram( + pageW, + pageH, + pageNo, + data.Width, + data.Height, + rowHintCols, + colHintRows, + ) + cellSize := layout.cellSize + if cellSize <= 0 { + return + } + + gridW := float64(data.Width) * cellSize + gridH := float64(data.Height) * cellSize + startX := layout.hintStartX + startY := layout.hintStartY + xSep := layout.gridX + ySep := layout.gridY + + for row := 0; row < colHintRows; row++ { + for col := 0; col < data.Width; col++ { + cellX := xSep + float64(col)*cellSize + cellY := startY + float64(row)*cellSize + if text := colHintText(colHints[col], colHintRows, row); text != "" { + drawNonogramHintText(pdf, cellX, cellY, cellSize, cellSize, text) + } + } + } + + for row := 0; row < data.Height; row++ { + for col := 0; col < rowHintCols; col++ { + cellX := startX + float64(col)*cellSize + cellY := ySep + float64(row)*cellSize + if text := rowHintText(rowHints[row], rowHintCols, col); text != "" { + drawNonogramHintText(pdf, cellX, cellY, cellSize, cellSize, text) + } + } + } + + drawNonogramPuzzleGrid(pdf, xSep, ySep, data.Width, data.Height, cellSize) + drawNonogramMajorLines(pdf, xSep, ySep, cellSize, data.Width, data.Height, 5) + + pdf.SetLineWidth(pdfexport.OuterBorderLineMM) + pdf.Rect(xSep, ySep, gridW, gridH, "D") + + ruleY := ySep + gridH + 3.5 + ruleY = pdfexport.InstructionY(ruleY-3.5, pageH, 1) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + pdfexport.SetInstructionStyle(pdf) + pdf.SetXY(body.X, ruleY) + pdf.CellFormat( + body.W, + pdfexport.InstructionLineHMM, + "Use row/column hints to fill blocks in order; groups are separated by at least one blank cell.", + "", + 0, + "C", + false, + 0, + "", + ) +} + +func drawNonogramPuzzleGrid( + pdf *fpdf.Fpdf, + startX, + startY float64, + width, + height int, + cellSize float64, +) { + if width <= 0 || height <= 0 || cellSize <= 0 { + return + } + + gridW := float64(width) * cellSize + gridH := float64(height) * cellSize + + pdf.SetDrawColor(45, 45, 45) + pdf.SetLineWidth(pdfexport.ThinGridLineMM) + for col := 0; col <= width; col++ { + x := startX + float64(col)*cellSize + pdf.Line(x, startY, x, startY+gridH) + } + for row := 0; row <= height; row++ { + y := startY + float64(row)*cellSize + pdf.Line(startX, y, startX+gridW, y) + } +} + +func drawNonogramHintText(pdf *fpdf.Fpdf, x, y, w, h float64, text string) { + if strings.TrimSpace(text) == "" { + return + } + + pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray) + fontSize := pdfexport.StandardCellFontSize(h, 0.70) + pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) + lineH := fontSize * 0.86 + pdf.SetXY(x, y+(h-lineH)/2) + pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "") +} + +type nonogramLayout struct { + cellSize float64 + hintStartX float64 + hintStartY float64 + gridX float64 + gridY float64 +} + +func layoutNonogram( + pageW, + pageH float64, + pageNo, + gridCols, + gridRows, + rowHintCols, + colHintRows int, +) nonogramLayout { + totalCols := rowHintCols + gridCols + totalRows := colHintRows + gridRows + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + cellSize := pdfexport.FitNonogramCellSize(totalCols, totalRows, area) + if cellSize <= 0 { + return nonogramLayout{} + } + + if rowHintCols > 0 { + centeredCapW := area.W / float64(gridCols+2*rowHintCols) + if centeredCapW > 0 && centeredCapW < cellSize { + cellSize = centeredCapW + } + } + if colHintRows > 0 { + centeredCapH := area.H / float64(gridRows+2*colHintRows) + if centeredCapH > 0 && centeredCapH < cellSize { + cellSize = centeredCapH + } + } + + gridW := float64(gridCols) * cellSize + gridH := float64(gridRows) * cellSize + gridX := area.X + (area.W-gridW)/2 + gridY := area.Y + (area.H-gridH)/2 + hintStartX := gridX - float64(rowHintCols)*cellSize + hintStartY := gridY - float64(colHintRows)*cellSize + + return nonogramLayout{ + cellSize: cellSize, + hintStartX: hintStartX, + hintStartY: hintStartY, + gridX: gridX, + gridY: gridY, + } +} + +func colHintText(hints []int, depth, row int) string { + if len(hints) == 0 { + return "" + } + start := depth - len(hints) + if row < start { + return "" + } + return strconv.Itoa(hints[row-start]) +} + +func rowHintText(hints []int, depth, col int) string { + if len(hints) == 0 { + return "" + } + start := depth - len(hints) + if col < start { + return "" + } + return strconv.Itoa(hints[col-start]) +} + +func maxHintDepth(hints [][]int) int { + maxDepth := 0 + for _, h := range hints { + if len(h) > maxDepth { + maxDepth = len(h) + } + } + return maxDepth +} + +func normalizeNonogramHintsForRender(hints [][]int, size int) [][]int { + if size <= 0 { + return nil + } + + normalized := make([][]int, size) + for i := 0; i < size; i++ { + if i >= len(hints) || len(hints[i]) == 0 { + normalized[i] = []int{0} + continue + } + normalized[i] = append([]int(nil), hints[i]...) + } + return normalized +} + +func drawNonogramMajorLines( + pdf *fpdf.Fpdf, + puzzleStartX, + puzzleStartY, + cellSize float64, + width, + height, + step int, +) { + if step <= 0 || width <= 0 || height <= 0 { + return + } + + pdf.SetDrawColor(45, 45, 45) + pdf.SetLineWidth(pdfexport.MajorGridLineMM) + + for col := step; col < width; col += step { + x := puzzleStartX + float64(col)*cellSize + pdf.Line(x, puzzleStartY, x, puzzleStartY+float64(height)*cellSize) + } + for row := step; row < height; row += step { + y := puzzleStartY + float64(row)*cellSize + pdf.Line(puzzleStartX, y, puzzleStartX+float64(width)*cellSize, y) + } +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/nonogram/print_adapter_test.go b/nonogram/print_adapter_test.go new file mode 100644 index 0000000..4888889 --- /dev/null +++ b/nonogram/print_adapter_test.go @@ -0,0 +1,139 @@ +package nonogram + +import ( + "math" + "testing" + + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +func TestNormalizeNonogramHintsForRender(t *testing.T) { + tests := []struct { + name string + hints [][]int + size int + want [][]int + }{ + { + name: "empty source defaults to zeros", + hints: nil, + size: 3, + want: [][]int{{0}, {0}, {0}}, + }, + { + name: "preserves provided hint rows", + hints: [][]int{{3, 1}, {}, {2}}, + size: 3, + want: [][]int{{3, 1}, {0}, {2}}, + }, + { + name: "pads beyond provided rows", + hints: [][]int{{1}}, + size: 3, + want: [][]int{{1}, {0}, {0}}, + }, + { + name: "non-positive size returns nil", + hints: [][]int{{1}}, + size: 0, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeNonogramHintsForRender(tt.hints, tt.size) + if len(got) != len(tt.want) { + t.Fatalf("rows = %d, want %d", len(got), len(tt.want)) + } + for row := range len(tt.want) { + if len(got[row]) != len(tt.want[row]) { + t.Fatalf("row %d len = %d, want %d", row, len(got[row]), len(tt.want[row])) + } + for col := range len(tt.want[row]) { + if got[row][col] != tt.want[row][col] { + t.Fatalf("row %d col %d = %d, want %d", row, col, got[row][col], tt.want[row][col]) + } + } + } + }) + } +} + +func TestNormalizeNonogramHintsForRenderCopiesRows(t *testing.T) { + src := [][]int{{2, 1}} + + got := normalizeNonogramHintsForRender(src, 1) + if got == nil || len(got) != 1 { + t.Fatalf("unexpected normalized hints: %#v", got) + } + + src[0][0] = 9 + if got[0][0] != 2 { + t.Fatalf("normalized hint should be copied, got %d want 2", got[0][0]) + } +} + +func TestLayoutNonogramCentersGrid(t *testing.T) { + tests := []struct { + name string + rowHintCol int + colHintRow int + }{ + {name: "shallow hints", rowHintCol: 1, colHintRow: 1}, + {name: "deep hints", rowHintCol: 5, colHintRow: 4}, + } + + const ( + pageNo = 3 + pageW = 139.7 + pageH = 215.9 + ) + + boardArea := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + layout := layoutNonogram( + pageW, + pageH, + pageNo, + 10, + 10, + tt.rowHintCol, + tt.colHintRow, + ) + if layout.cellSize <= 0 { + t.Fatal("expected non-zero cell size") + } + + gridW := 10.0 * layout.cellSize + gridH := 10.0 * layout.cellSize + centerX := layout.gridX + gridW/2 + centerY := layout.gridY + gridH/2 + wantX := boardArea.X + boardArea.W/2 + wantY := boardArea.Y + boardArea.H/2 + + if diff := math.Abs(centerX - wantX); diff > 0.8 { + t.Fatalf("grid centerX diff = %.3f, want <= 0.8", diff) + } + if diff := math.Abs(centerY - wantY); diff > 0.8 { + t.Fatalf("grid centerY diff = %.3f, want <= 0.8", diff) + } + + fullW := float64(tt.rowHintCol+10) * layout.cellSize + fullH := float64(tt.colHintRow+10) * layout.cellSize + if layout.hintStartX < boardArea.X-0.01 { + t.Fatalf("hintStartX = %.3f, want >= %.3f", layout.hintStartX, boardArea.X) + } + if layout.hintStartY < boardArea.Y-0.01 { + t.Fatalf("hintStartY = %.3f, want >= %.3f", layout.hintStartY, boardArea.Y) + } + if right := layout.hintStartX + fullW; right > boardArea.X+boardArea.W+0.01 { + t.Fatalf("hint block right = %.3f, want <= %.3f", right, boardArea.X+boardArea.W) + } + if bottom := layout.hintStartY + fullH; bottom > boardArea.Y+boardArea.H+0.01 { + t.Fatalf("hint block bottom = %.3f, want <= %.3f", bottom, boardArea.Y+boardArea.H) + } + }) + } +} diff --git a/nurikabe/PrintAdapter.go b/nurikabe/PrintAdapter.go new file mode 100644 index 0000000..de43fc2 --- /dev/null +++ b/nurikabe/PrintAdapter.go @@ -0,0 +1,102 @@ +package nurikabe + +import ( + "strconv" + "unicode/utf8" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Nurikabe" } +func (printAdapter) Aliases() []string { return []string{"nurikabe"} } + +func (printAdapter) BuildPDFPayload(save []byte) (any, error) { + return pdfexport.ParseNurikabePrintData(save) +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.NurikabeData: + renderNurikabePage(pdf, data) + } + return nil +} + +func renderNurikabePage(pdf *fpdf.Fpdf, data *pdfexport.NurikabeData) { + if data == nil || data.Width <= 0 || data.Height <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area) + if cellSize <= 0 { + return + } + + blockW := float64(data.Width) * cellSize + blockH := float64(data.Height) * cellSize + startX, startY := pdfexport.CenteredOrigin(area, data.Width, data.Height, cellSize) + + pdf.SetDrawColor(55, 55, 55) + pdf.SetLineWidth(pdfexport.ThinGridLineMM) + for y := 0; y < data.Height; y++ { + for x := 0; x < data.Width; x++ { + cellX := startX + float64(x)*cellSize + cellY := startY + float64(y)*cellSize + pdf.Rect(cellX, cellY, cellSize, cellSize, "D") + + if y >= len(data.Clues) || x >= len(data.Clues[y]) || data.Clues[y][x] <= 0 { + continue + } + drawNurikabeClue(pdf, cellX, cellY, cellSize, data.Clues[y][x]) + } + } + + pdf.SetDrawColor(35, 35, 35) + pdf.SetLineWidth(pdfexport.OuterBorderLineMM) + pdf.Rect(startX, startY, blockW, blockH, "D") + + ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1) + pdfexport.SetInstructionStyle(pdf) + pdf.SetXY(area.X, ruleY) + pdf.CellFormat( + area.W, + pdfexport.InstructionLineHMM, + "Expand each numbered island to its size; connect all sea cells into one wall.", + "", + 0, + "C", + false, + 0, + "", + ) +} + +func drawNurikabeClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) { + text := strconv.Itoa(value) + fontSize := pdfexport.StandardCellFontSize(cellSize, 0.58) + runeCount := utf8.RuneCountInString(text) + switch { + case runeCount >= 3: + fontSize *= 0.72 + case runeCount == 2: + fontSize *= 0.84 + } + fontSize = pdfexport.ClampStandardCellFontSize(fontSize) + + pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray) + pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) + lineH := fontSize * 0.92 + pdf.SetXY(x, y+(cellSize-lineH)/2) + pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/pdfexport/cover_art.go b/pdfexport/cover_art.go new file mode 100644 index 0000000..89e6d41 --- /dev/null +++ b/pdfexport/cover_art.go @@ -0,0 +1,1241 @@ +package pdfexport + +import ( + "bytes" + "encoding/binary" + "fmt" + "hash/fnv" + "image" + "image/color" + "image/png" + "math" + "math/rand" + "strings" + "time" + + "codeberg.org/go-pdf/fpdf" +) + +type coverVec2 struct { + x float64 + y float64 +} + +type coverGlow struct { + center coverVec2 + spread float64 + strength float64 + col color.RGBA +} + +type coverFieldProfile struct { + freqX float64 + freqY float64 + freqMixX float64 + freqMixY float64 + freqCurlX float64 + freqCurlY float64 + ampX float64 + ampY float64 + ampMix float64 + ampCurl float64 + phaseX float64 + phaseY float64 + phaseMix float64 + phaseCurl float64 + swirl float64 + shear float64 + pinch float64 + pivot coverVec2 +} + +type coverFlowLayer struct { + count int + steps int + step float64 + alpha uint8 + radius float64 + drift float64 + phase float64 + waveXFreq float64 + waveYFreq float64 + waveXAmp float64 + waveYAmp float64 + a color.RGBA + b color.RGBA +} + +type coverLatticeProfile struct { + enabled bool + layout int + cols int + rows int + neighbors int + jitterX float64 + jitterY float64 + center coverVec2 + radialWarp float64 + edge color.RGBA + nodeOuter color.RGBA + nodeInner color.RGBA + nodeSize float64 + coreSize float64 +} + +type coverOrbitProfile struct { + enabled bool + center coverVec2 + count int + radiusStart float64 + radiusStep float64 + arcCoverage float64 + segmentsMin int + segmentsJitter int + eccentricity float64 + wobble float64 + dotSize float64 + colorA color.RGBA + colorB color.RGBA + alphaBase uint8 + alphaStep uint8 +} + +type coverMarkProfile struct { + enabled bool + count int + gridW int + gridH int + jitter float64 + sizeMin float64 + sizeMax float64 + alphaBase uint8 + alphaRange uint8 + colorA color.RGBA + colorB color.RGBA + cutout color.RGBA +} + +type coverPalette struct { + bgTop color.RGBA + bgMid color.RGBA + bgBottom color.RGBA + accentA color.RGBA + accentB color.RGBA + ink color.RGBA + nodeOuter color.RGBA + nodeInner color.RGBA + markCutout color.RGBA + grainWarm color.RGBA + grainCool color.RGBA +} + +type coverPaletteFamily struct { + name string + bgShift [3]float64 + accentShift [2]float64 + satBias float64 + valueBias float64 +} + +type coverArchetype uint8 + +const ( + coverArchetypeConstellation coverArchetype = iota + coverArchetypeVortex + coverArchetypeBands + coverArchetypeDriftField + coverArchetypeRadialMesh + coverArchetypeSparseGlyph + coverArchetypeCount +) + +type coverModifier uint8 + +const ( + coverModifierDenseFlow coverModifier = iota + coverModifierHighOrbit + coverModifierQuietLattice + coverModifierMicroMarks + coverModifierGrainHeavy + coverModifierNegativeSpace +) + +var coverPaletteFamilies = []coverPaletteFamily{ + { + name: "tropical", + bgShift: [3]float64{0.10, 0.02, -0.14}, + accentShift: [2]float64{0.33, 0.52}, + satBias: 0.08, + valueBias: 0.03, + }, + { + name: "sunset", + bgShift: [3]float64{0.05, -0.05, -0.17}, + accentShift: [2]float64{0.19, 0.38}, + satBias: 0.10, + valueBias: 0.04, + }, + { + name: "electric-mineral", + bgShift: [3]float64{-0.16, -0.08, 0.01}, + accentShift: [2]float64{0.36, -0.28}, + satBias: 0.13, + valueBias: -0.02, + }, + { + name: "aurora", + bgShift: [3]float64{0.22, 0.10, -0.09}, + accentShift: [2]float64{0.45, 0.62}, + satBias: 0.11, + valueBias: 0.02, + }, + { + name: "ember", + bgShift: [3]float64{0.00, -0.11, -0.22}, + accentShift: [2]float64{-0.36, 0.11}, + satBias: 0.09, + valueBias: -0.03, + }, + { + name: "citrus-marine", + bgShift: [3]float64{0.14, 0.03, -0.13}, + accentShift: [2]float64{0.30, -0.22}, + satBias: 0.12, + valueBias: 0.01, + }, +} + +type coverArtDirection struct { + motif int + top color.RGBA + mid color.RGBA + bottom color.RGBA + verticalCurve float64 + glows []coverGlow + field coverFieldProfile + flowLayers []coverFlowLayer + lattice coverLatticeProfile + orbit coverOrbitProfile + marks coverMarkProfile + grainCount int + grainWarm color.RGBA + grainCool color.RGBA +} + +func drawCoverArtworkImage( + pdf *fpdf.Fpdf, + scene rectMM, + seedText string, + variant string, + base RGB, +) { + seedText = strings.TrimSpace(seedText) + if seedText == "" { + seedText = "puzzletea" + time.Now().String() + } + if strings.TrimSpace(variant) == "" { + variant = "front" + } + imageName := fmt.Sprintf( + "puzzletea-cover-artwork-%016x", + coverSeedHash(seedText+"|"+variant), + ) + artPNG := renderCoverArtworkPNG(seedText, variant, base) + options := fpdf.ImageOptions{ + ImageType: "PNG", + ReadDpi: true, + } + pdf.RegisterImageOptionsReader(imageName, options, bytes.NewReader(artPNG)) + pdf.ImageOptions(imageName, scene.x, scene.y, scene.w, scene.h, false, options, 0, "") +} + +func renderCoverArtworkPNG(seedText, variant string, base RGB) []byte { + const ( + width = 1200 + height = 1400 + ) + seed := coverSeedHash(seedText + "|" + variant) + direction := newCoverArtDirection(seed, base) + img := image.NewRGBA(image.Rect(0, 0, width, height)) + + paintCoverBackground(img, width, height, direction) + drawCoverFlowTrails(img, width, height, direction, coverStreamRNG(seed, "flow")) + drawCoverPuzzleLattice(img, width, height, direction, coverStreamRNG(seed, "lattice")) + drawCoverOrbitBands(img, width, height, direction, coverStreamRNG(seed, "orbit")) + drawCoverSeedMarks(img, width, height, direction, coverStreamRNG(seed, "marks")) + drawCoverFilmGrain(img, width, height, direction, coverStreamRNG(seed, "grain")) + + var out bytes.Buffer + if err := png.Encode(&out, img); err != nil { + return []byte{} + } + return out.Bytes() +} + +func newCoverArtDirection(seed uint64, base RGB) coverArtDirection { + paletteRNG := coverStreamRNG(seed, "palette") + structureRNG := coverStreamRNG(seed, "structure") + palette := newCoverPalette(base, paletteRNG) + primary, modifiers := pickCoverComposition(structureRNG) + + glows := make([]coverGlow, 0, 5) + anchorColor := palette.accentA + if structureRNG.Intn(2) == 0 { + anchorColor = palette.accentB + } + glows = append(glows, coverGlow{ + center: coverVec2{ + x: 0.12 + structureRNG.Float64()*0.76, + y: 0.10 + structureRNG.Float64()*0.78, + }, + spread: 0.24 + structureRNG.Float64()*0.30, + strength: 0.46 + structureRNG.Float64()*0.28, + col: jitterRGBA(anchorColor, structureRNG, 26), + }) + + glowCount := 2 + structureRNG.Intn(3) + for i := 0; i < glowCount; i++ { + col := palette.accentA + if i%2 == 1 { + col = palette.accentB + } + col = jitterRGBA(col, structureRNG, 28) + glows = append(glows, coverGlow{ + center: coverVec2{ + x: 0.06 + structureRNG.Float64()*0.88, + y: 0.08 + structureRNG.Float64()*0.84, + }, + spread: 0.42 + structureRNG.Float64()*0.70, + strength: 0.12 + structureRNG.Float64()*0.28, + col: col, + }) + } + + field := coverFieldProfile{ + freqX: 3.8 + structureRNG.Float64()*6.8, + freqY: 3.6 + structureRNG.Float64()*6.5, + freqMixX: 0.5 + structureRNG.Float64()*1.8, + freqMixY: 0.5 + structureRNG.Float64()*1.8, + freqCurlX: 0.4 + structureRNG.Float64()*2.0, + freqCurlY: 0.4 + structureRNG.Float64()*2.0, + ampX: 0.60 + structureRNG.Float64()*1.00, + ampY: 0.60 + structureRNG.Float64()*1.00, + ampMix: 0.26 + structureRNG.Float64()*0.92, + ampCurl: 0.24 + structureRNG.Float64()*0.88, + phaseX: 0.22 + structureRNG.Float64()*1.45, + phaseY: 0.22 + structureRNG.Float64()*1.45, + phaseMix: 0.20 + structureRNG.Float64()*1.20, + phaseCurl: 0.20 + structureRNG.Float64()*1.20, + swirl: (structureRNG.Float64()*2 - 1) * 0.40, + shear: (structureRNG.Float64()*2 - 1) * 0.36, + pinch: (structureRNG.Float64()*2 - 1) * 0.56, + pivot: coverVec2{ + x: 0.20 + structureRNG.Float64()*0.60, + y: 0.18 + structureRNG.Float64()*0.64, + }, + } + + flowLayers := make([]coverFlowLayer, 0, 4) + layerCount := 2 + structureRNG.Intn(3) + for i := 0; i < layerCount; i++ { + t := float64(i) / float64(maxInt(1, layerCount-1)) + a := jitterRGBA(lerpRGB(palette.accentA, palette.bgMid, 0.28+t*0.42), structureRNG, 24) + b := jitterRGBA(lerpRGB(palette.accentB, palette.bgTop, 0.16+t*0.38), structureRNG, 24) + flowLayers = append(flowLayers, coverFlowLayer{ + count: 220 + structureRNG.Intn(360), + steps: 96 + structureRNG.Intn(132), + step: 1.10 + structureRNG.Float64()*1.80, + alpha: uint8(22 + structureRNG.Intn(42)), + radius: 0.78 + structureRNG.Float64()*0.98, + drift: 0.40 + structureRNG.Float64()*1.72, + phase: structureRNG.Float64() * math.Pi * 2, + waveXFreq: 4.6 + structureRNG.Float64()*7.8, + waveYFreq: 4.6 + structureRNG.Float64()*7.8, + waveXAmp: 0.06 + structureRNG.Float64()*0.34, + waveYAmp: 0.06 + structureRNG.Float64()*0.34, + a: a, + b: b, + }) + } + + lattice := coverLatticeProfile{ + enabled: true, + layout: structureRNG.Intn(3), + cols: 7 + structureRNG.Intn(6), + rows: 6 + structureRNG.Intn(7), + neighbors: 2 + structureRNG.Intn(3), + jitterX: 24 + structureRNG.Float64()*48, + jitterY: 28 + structureRNG.Float64()*56, + center: coverVec2{x: 0.28 + structureRNG.Float64()*0.44, y: 0.24 + structureRNG.Float64()*0.50}, + radialWarp: (structureRNG.Float64()*2 - 1) * 0.40, + edge: color.RGBA{R: palette.ink.R, G: palette.ink.G, B: palette.ink.B, A: uint8(52 + structureRNG.Intn(76))}, + nodeOuter: palette.nodeOuter, + nodeInner: palette.nodeInner, + nodeSize: 3.3 + structureRNG.Float64()*3.8, + coreSize: 1.4 + structureRNG.Float64()*1.7, + } + + orbit := coverOrbitProfile{ + enabled: true, + center: coverVec2{x: 0.30 + structureRNG.Float64()*0.38, y: 0.26 + structureRNG.Float64()*0.38}, + count: 10 + structureRNG.Intn(18), + radiusStart: 36 + structureRNG.Float64()*52, + radiusStep: 13 + structureRNG.Float64()*20, + arcCoverage: 0.48 + structureRNG.Float64()*0.48, + segmentsMin: 34 + structureRNG.Intn(40), + segmentsJitter: 42 + structureRNG.Intn(56), + eccentricity: 0.66 + structureRNG.Float64()*0.56, + wobble: 2.6 + structureRNG.Float64()*8.8, + dotSize: 0.84 + structureRNG.Float64()*0.86, + colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.22), structureRNG, 18), + colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.34), structureRNG, 18), + alphaBase: uint8(18 + structureRNG.Intn(28)), + alphaStep: uint8(1 + structureRNG.Intn(3)), + } + + marks := coverMarkProfile{ + enabled: true, + count: 28 + structureRNG.Intn(58), + gridW: 8 + structureRNG.Intn(9), + gridH: 10 + structureRNG.Intn(10), + jitter: 8 + structureRNG.Float64()*30, + sizeMin: 1.7 + structureRNG.Float64()*1.5, + sizeMax: 3.4 + structureRNG.Float64()*3.0, + alphaBase: uint8(40 + structureRNG.Intn(40)), + alphaRange: uint8(20 + structureRNG.Intn(30)), + colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.18), structureRNG, 22), + colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.30), structureRNG, 22), + cutout: palette.markCutout, + } + + direction := coverArtDirection{ + motif: int(primary), + top: palette.bgTop, + mid: palette.bgMid, + bottom: palette.bgBottom, + verticalCurve: 1.18 + structureRNG.Float64()*1.14, + glows: glows, + field: field, + flowLayers: flowLayers, + lattice: lattice, + orbit: orbit, + marks: marks, + grainCount: 34000 + structureRNG.Intn(32000), + grainWarm: palette.grainWarm, + grainCool: palette.grainCool, + } + + applyCoverPrimaryArchetype(&direction, primary, structureRNG) + for _, modifier := range modifiers { + applyCoverModifier(&direction, modifier, structureRNG) + } + + return direction +} + +func coverArchetypeForSeed(seed uint64) coverArchetype { + primary, _ := coverCompositionForSeed(seed) + return primary +} + +func coverCompositionForSeed(seed uint64) (coverArchetype, []coverModifier) { + structureRNG := coverStreamRNG(seed, "structure") + return pickCoverComposition(structureRNG) +} + +func pickCoverComposition(rng *rand.Rand) (coverArchetype, []coverModifier) { + primary := pickWeightedCoverArchetype(rng) + modifierCount := 0 + roll := rng.Float64() + switch { + case roll < 0.58: + modifierCount = 1 + default: + modifierCount = 2 + } + + modifierPool := []coverModifier{ + coverModifierDenseFlow, + coverModifierHighOrbit, + coverModifierQuietLattice, + coverModifierMicroMarks, + coverModifierGrainHeavy, + coverModifierNegativeSpace, + } + rng.Shuffle(len(modifierPool), func(i, j int) { + modifierPool[i], modifierPool[j] = modifierPool[j], modifierPool[i] + }) + + modifiers := make([]coverModifier, 0, modifierCount) + for _, modifier := range modifierPool { + if len(modifiers) >= modifierCount { + break + } + if primary == coverArchetypeSparseGlyph && modifier == coverModifierNegativeSpace { + continue + } + if primary == coverArchetypeBands && modifier == coverModifierHighOrbit { + continue + } + modifiers = append(modifiers, modifier) + } + return primary, modifiers +} + +func pickWeightedCoverArchetype(rng *rand.Rand) coverArchetype { + weights := [...]int{18, 16, 14, 18, 17, 17} + total := 0 + for _, weight := range weights { + total += weight + } + pick := rng.Intn(total) + for idx, weight := range weights { + if pick < weight { + return coverArchetype(idx) + } + pick -= weight + } + return coverArchetypeSparseGlyph +} + +func newCoverPalette(base RGB, rng *rand.Rand) coverPalette { + baseColor := rgbToColor(base) + baseHue, baseSat, baseVal := rgbToHSV(baseColor) + family := coverPaletteFamilies[rng.Intn(len(coverPaletteFamilies))] + baseHue = wrapHue(baseHue + randCentered(rng, 0.32)) + + baseSat = clamp(0.14, 0.50, baseSat*0.62+0.11+family.satBias*0.18+rng.Float64()*0.07) + baseVal = clamp(0.34, 0.86, baseVal*0.84+0.12+family.valueBias*0.15+randCentered(rng, 0.05)) + + top := hsvToRGB( + wrapHue(baseHue+family.bgShift[0]+randCentered(rng, 0.10)), + clamp(0.14, 0.34, baseSat*0.50+0.04+family.satBias*0.28+rng.Float64()*0.08), + clamp(0.74, 0.98, baseVal+0.16+family.valueBias*0.24+rng.Float64()*0.14), + ) + mid := hsvToRGB( + wrapHue(baseHue+family.bgShift[1]+randCentered(rng, 0.14)), + clamp(0.20, 0.50, baseSat*0.74+0.02+family.satBias*0.40+rng.Float64()*0.10), + clamp(0.46, 0.88, baseVal-0.02+family.valueBias*0.12+randCentered(rng, 0.12)), + ) + bottom := hsvToRGB( + wrapHue(baseHue+family.bgShift[2]+randCentered(rng, 0.16)), + clamp(0.18, 0.54, baseSat*0.82+0.01+family.satBias*0.35+rng.Float64()*0.10), + clamp(0.08, 0.36, baseVal*0.32+0.02+family.valueBias*0.10+randCentered(rng, 0.10)), + ) + accentA := hsvToRGB( + wrapHue(baseHue+family.accentShift[0]+randCentered(rng, 0.18)), + clamp(0.30, 0.62, baseSat+0.12+family.satBias*0.40+rng.Float64()*0.15), + clamp(0.62, 0.96, baseVal+0.14+rng.Float64()*0.12), + ) + accentB := hsvToRGB( + wrapHue(baseHue+family.accentShift[1]+randCentered(rng, 0.20)), + clamp(0.28, 0.60, baseSat+0.10+family.satBias*0.38+rng.Float64()*0.16), + clamp(0.56, 0.92, baseVal+0.08+rng.Float64()*0.12), + ) + ink := hsvToRGB( + wrapHue(baseHue+0.50+family.bgShift[2]*0.35), + clamp(0.22, 0.44, 0.24+baseSat*0.22), + clamp(0.06, 0.22, 0.12+(1-baseVal)*0.10), + ) + + return coverPalette{ + bgTop: top, + bgMid: mid, + bgBottom: bottom, + accentA: accentA, + accentB: accentB, + ink: ink, + nodeOuter: blendRGB(top, color.RGBA{R: 255, G: 244, B: 220, A: 255}, 0.26), + nodeInner: blendRGB(ink, bottom, 0.34), + markCutout: blendRGB(bottom, color.RGBA{R: 6, G: 11, B: 20, A: 255}, 0.30), + grainWarm: blendRGB(top, color.RGBA{R: 255, G: 248, B: 232, A: 255}, 0.36), + grainCool: blendRGB(bottom, ink, 0.44), + } +} + +func applyCoverPrimaryArchetype( + direction *coverArtDirection, + primary coverArchetype, + rng *rand.Rand, +) { + switch primary { + case coverArchetypeConstellation: + direction.lattice.layout = 0 + direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1) + direction.lattice.edge.A = minUint8(160, direction.lattice.edge.A+22) + for i := range direction.flowLayers { + direction.flowLayers[i].count = maxInt(160, int(float64(direction.flowLayers[i].count)*0.76)) + direction.flowLayers[i].alpha = maxUint8(16, direction.flowLayers[i].alpha-6) + } + direction.orbit.enabled = false + direction.orbit.count = maxInt(4, direction.orbit.count/2) + direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*0.72 + 0.12) + direction.marks.count += 12 + direction.field.swirl *= 0.88 + case coverArchetypeVortex: + direction.lattice.enabled = false + direction.orbit.count += 14 + direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.20 + 0.08) + direction.orbit.radiusStep *= 0.84 + direction.field.swirl *= 1.42 + for i := range direction.flowLayers { + direction.flowLayers[i].alpha = minUint8(86, direction.flowLayers[i].alpha+10) + direction.flowLayers[i].drift *= 1.18 + } + direction.marks.count = maxInt(8, direction.marks.count-16) + case coverArchetypeBands: + direction.lattice.layout = 2 + direction.lattice.enabled = false + direction.orbit.enabled = false + direction.orbit.count = maxInt(5, direction.orbit.count/2) + direction.field.shear *= 0.44 + direction.field.pinch *= 0.74 + for i := range direction.flowLayers { + direction.flowLayers[i].waveYFreq = 1.8 + rng.Float64()*2.3 + direction.flowLayers[i].waveYAmp += 0.14 + rng.Float64()*0.16 + direction.flowLayers[i].waveXAmp *= 0.66 + direction.flowLayers[i].step *= 0.94 + direction.flowLayers[i].count = maxInt(180, int(float64(direction.flowLayers[i].count)*0.84)) + } + direction.marks.count = maxInt(8, direction.marks.count-14) + case coverArchetypeDriftField: + direction.lattice.enabled = false + for i := range direction.flowLayers { + direction.flowLayers[i].count += 130 + direction.flowLayers[i].steps += 18 + direction.flowLayers[i].alpha = minUint8(90, direction.flowLayers[i].alpha+12) + direction.flowLayers[i].drift *= 1.24 + } + direction.orbit.count = maxInt(8, direction.orbit.count-4) + direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1) + direction.marks.count = maxInt(10, direction.marks.count-12) + direction.grainCount += 5000 + case coverArchetypeRadialMesh: + direction.lattice.layout = 1 + direction.lattice.rows += 2 + direction.lattice.cols += 1 + direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1) + direction.lattice.center = coverVec2{ + x: 0.44 + rng.Float64()*0.12, + y: 0.38 + rng.Float64()*0.14, + } + direction.orbit.enabled = true + direction.orbit.count += 8 + direction.orbit.eccentricity = clamp(0.56, 1.46, direction.orbit.eccentricity*1.16) + direction.field.pinch *= 1.24 + for i := range direction.flowLayers { + direction.flowLayers[i].count = maxInt(110, int(float64(direction.flowLayers[i].count)*0.45)) + direction.flowLayers[i].alpha = maxUint8(14, direction.flowLayers[i].alpha-6) + } + direction.marks.count = maxInt(10, direction.marks.count-14) + case coverArchetypeSparseGlyph: + for i := range direction.flowLayers { + direction.flowLayers[i].count = maxInt(120, int(float64(direction.flowLayers[i].count)*0.55)) + direction.flowLayers[i].steps = maxInt(72, int(float64(direction.flowLayers[i].steps)*0.70)) + direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8) + } + direction.lattice.enabled = rng.Float64() > 0.68 + if direction.lattice.enabled { + direction.lattice.neighbors = 2 + direction.lattice.edge.A = maxUint8(14, direction.lattice.edge.A-26) + } + direction.orbit.enabled = rng.Float64() > 0.80 + direction.orbit.count = maxInt(5, direction.orbit.count/2) + direction.orbit.arcCoverage *= 0.66 + direction.marks.count += 40 + direction.marks.sizeMax += 1.5 + direction.marks.jitter *= 0.76 + direction.field.swirl *= 0.72 + direction.grainCount += 9000 + } +} + +func applyCoverModifier( + direction *coverArtDirection, + modifier coverModifier, + rng *rand.Rand, +) { + switch modifier { + case coverModifierDenseFlow: + for i := range direction.flowLayers { + direction.flowLayers[i].count += 96 + direction.flowLayers[i].steps += 16 + direction.flowLayers[i].alpha = minUint8(94, direction.flowLayers[i].alpha+10) + } + direction.grainCount += 3000 + case coverModifierHighOrbit: + direction.orbit.enabled = true + direction.orbit.count += 10 + direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.15 + 0.05) + direction.orbit.dotSize += 0.12 + direction.orbit.alphaBase = minUint8(120, direction.orbit.alphaBase+6) + case coverModifierQuietLattice: + if direction.lattice.enabled { + direction.lattice.edge.A = maxUint8(12, direction.lattice.edge.A-30) + direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1) + } + if rng.Float64() < 0.35 { + direction.lattice.enabled = false + } + case coverModifierMicroMarks: + direction.marks.count += 26 + direction.marks.sizeMin = clamp(0.8, 4.0, direction.marks.sizeMin*0.78) + direction.marks.sizeMax = clamp(1.2, 5.2, direction.marks.sizeMax*0.74) + direction.marks.jitter *= 0.86 + direction.marks.alphaBase = minUint8(108, direction.marks.alphaBase+8) + case coverModifierGrainHeavy: + direction.grainCount += 12000 + direction.grainWarm = blendRGB(direction.grainWarm, color.RGBA{R: 255, G: 250, B: 240, A: 255}, 0.12) + case coverModifierNegativeSpace: + for i := range direction.flowLayers { + direction.flowLayers[i].count = maxInt(140, int(float64(direction.flowLayers[i].count)*0.62)) + direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8) + } + direction.orbit.count = maxInt(4, direction.orbit.count-6) + direction.orbit.alphaBase = maxUint8(8, direction.orbit.alphaBase-4) + direction.marks.count = maxInt(10, direction.marks.count-12) + direction.verticalCurve = clamp(1.0, 2.8, direction.verticalCurve+0.26) + for i := range direction.glows { + direction.glows[i].strength *= 0.72 + } + } +} + +func randCentered(rng *rand.Rand, spread float64) float64 { + return (rng.Float64()*2 - 1) * spread +} + +func paintCoverBackground(img *image.RGBA, w, h int, direction coverArtDirection) { + for y := range h { + ty := float64(y) / float64(h-1) + vertical := blendRGB( + lerpRGB(direction.top, direction.mid, ty*1.12), + direction.bottom, + powClamp(ty, direction.verticalCurve), + ) + for x := range w { + tx := float64(x) / float64(w-1) + col := vertical + for _, glow := range direction.glows { + amount := radialFalloff(tx, ty, glow.center.x, glow.center.y, glow.spread) + col = blendRGB(col, glow.col, amount*glow.strength) + } + img.SetRGBA(x, y, col) + } + } +} + +func drawCoverFlowTrails( + img *image.RGBA, + w, h int, + direction coverArtDirection, + rng *rand.Rand, +) { + for _, layer := range direction.flowLayers { + for i := 0; i < layer.count; i++ { + x := rng.Float64() * float64(w) + y := rng.Float64() * float64(h) + for step := 0; step < layer.steps; step++ { + nx := x / float64(w) + ny := y / float64(h) + angle := coverFieldAngle(nx, ny, direction.field, layer.phase) + angle += math.Sin((ny+layer.phase)*layer.waveYFreq) * layer.waveYAmp + angle += math.Cos((nx-layer.phase)*layer.waveXFreq) * layer.waveXAmp + + x += math.Cos(angle) * layer.step + y += math.Sin(angle) * layer.step + x += math.Cos((ny-layer.phase)*math.Pi*2) * layer.drift * 0.04 + y += math.Sin((nx+layer.phase)*math.Pi*2) * layer.drift * 0.08 + if x < 1 || x >= float64(w-1) || y < 1 || y >= float64(h-1) { + break + } + t := float64(step) / float64(layer.steps) + c := lerpRGB(layer.a, layer.b, t) + c.A = layer.alpha + drawDisc(img, x, y, layer.radius, c) + } + } + } +} + +func drawCoverPuzzleLattice( + img *image.RGBA, + w, h int, + direction coverArtDirection, + rng *rand.Rand, +) { + if !direction.lattice.enabled { + return + } + nodes := buildCoverLatticeNodes(w, h, direction.lattice, rng) + if len(nodes) == 0 { + return + } + + for i := range nodes { + for _, j := range nearestN(nodes, i, direction.lattice.neighbors) { + if j > i { + drawLine(img, nodes[i], nodes[j], direction.lattice.edge) + } + } + } + for _, n := range nodes { + drawDisc(img, n.x, n.y, direction.lattice.nodeSize, direction.lattice.nodeOuter) + drawDisc(img, n.x, n.y, direction.lattice.coreSize, direction.lattice.nodeInner) + } +} + +func buildCoverLatticeNodes( + w, h int, + profile coverLatticeProfile, + rng *rand.Rand, +) []coverVec2 { + nodes := make([]coverVec2, 0, profile.cols*profile.rows) + switch profile.layout { + case 1: + minDim := math.Min(float64(w), float64(h)) + rings := maxInt(4, profile.rows+1) + spokes := maxInt(8, profile.cols+4) + center := coverVec2{ + x: profile.center.x * float64(w), + y: profile.center.y * float64(h), + } + for ring := 1; ring <= rings; ring++ { + ringT := float64(ring) / float64(rings+1) + radius := ringT * minDim * (0.16 + 0.62*ringT) + offset := rng.Float64() * math.Pi * 2 + for spoke := 0; spoke < spokes; spoke++ { + ang := offset + float64(spoke)/float64(spokes)*math.Pi*2 + ang += math.Sin(float64(ring)*0.9+float64(spoke)*0.45) * profile.radialWarp + jitter := (rng.Float64() - 0.5) * profile.jitterX + x := center.x + math.Cos(ang)*(radius+jitter) + y := center.y + math.Sin(ang)*(radius+jitter*0.45) + nodes = append(nodes, coverVec2{x: x, y: y}) + } + } + case 2: + total := maxInt(38, profile.cols*profile.rows) + for i := 0; i < total; i++ { + tx := (rng.Float64()*0.84 + 0.08) + ty := (rng.Float64()*0.84 + 0.08) + warpX := math.Sin(ty*math.Pi*4+float64(i)*0.22) * profile.radialWarp * 0.12 + warpY := math.Cos(tx*math.Pi*3+float64(i)*0.18) * profile.radialWarp * 0.10 + x := (tx+warpX)*float64(w) + (rng.Float64()-0.5)*profile.jitterX + y := (ty+warpY)*float64(h) + (rng.Float64()-0.5)*profile.jitterY + nodes = append(nodes, coverVec2{x: x, y: y}) + } + default: + for row := 0; row < profile.rows; row++ { + for col := 0; col < profile.cols; col++ { + x := (float64(col) + 1) / (float64(profile.cols) + 1) * float64(w) + y := (float64(row) + 1) / (float64(profile.rows) + 1) * float64(h) + x += (rng.Float64() - 0.5) * profile.jitterX + y += (rng.Float64() - 0.5) * profile.jitterY + nodes = append(nodes, coverVec2{x: x, y: y}) + } + } + } + return nodes +} + +func drawCoverOrbitBands( + img *image.RGBA, + w, h int, + direction coverArtDirection, + rng *rand.Rand, +) { + if !direction.orbit.enabled { + return + } + center := coverVec2{ + x: float64(w) * direction.orbit.center.x, + y: float64(h) * direction.orbit.center.y, + } + for i := 0; i < direction.orbit.count; i++ { + radius := direction.orbit.radiusStart + float64(i)*direction.orbit.radiusStep + start := rng.Float64() * math.Pi * 2 + sweep := direction.orbit.arcCoverage * (0.74 + rng.Float64()*0.52) * math.Pi * 2 + sweep = math.Min(sweep, math.Pi*2) + segments := direction.orbit.segmentsMin + rng.Intn(direction.orbit.segmentsJitter) + t := float64(i) / float64(maxInt(1, direction.orbit.count-1)) + col := lerpRGB(direction.orbit.colorA, direction.orbit.colorB, t) + col.A = minUint8(220, direction.orbit.alphaBase+uint8(i)*direction.orbit.alphaStep) + for s := 0; s < segments; s++ { + a := start + float64(s)/float64(segments)*sweep + jx := math.Sin(float64(i)*0.43+a*2.9) * direction.orbit.wobble + jy := math.Cos(float64(i)*0.37+a*2.3) * direction.orbit.wobble * 0.82 + x := center.x + math.Cos(a)*radius*direction.orbit.eccentricity + jx + y := center.y + math.Sin(a)*radius + jy + drawDisc(img, x, y, direction.orbit.dotSize, col) + } + } +} + +func drawCoverSeedMarks( + img *image.RGBA, + w, h int, + direction coverArtDirection, + rng *rand.Rand, +) { + if !direction.marks.enabled { + return + } + for i := 0; i < direction.marks.count; i++ { + gx := 1 + rng.Intn(direction.marks.gridW) + gy := 1 + rng.Intn(direction.marks.gridH) + x := float64(gx) / float64(direction.marks.gridW+1) * float64(w) + y := float64(gy) / float64(direction.marks.gridH+1) * float64(h) + x += (rng.Float64() - 0.5) * direction.marks.jitter + y += (rng.Float64() - 0.5) * direction.marks.jitter + + size := direction.marks.sizeMin + if direction.marks.sizeMax > direction.marks.sizeMin { + size += rng.Float64() * (direction.marks.sizeMax - direction.marks.sizeMin) + } + col := lerpRGB(direction.marks.colorA, direction.marks.colorB, rng.Float64()) + col.A = minUint8(220, direction.marks.alphaBase+uint8(rng.Intn(int(direction.marks.alphaRange)+1))) + style := (i + rng.Intn(4) + direction.motif) % 4 + switch style { + case 0: + drawDisc(img, x, y, size*0.48, col) + case 1: + drawDisc(img, x, y, size, col) + drawDisc(img, x, y, size*0.52, direction.marks.cutout) + case 2: + drawLine(img, coverVec2{x: x - size, y: y}, coverVec2{x: x + size, y: y}, col) + drawLine(img, coverVec2{x: x, y: y - size}, coverVec2{x: x, y: y + size}, col) + default: + drawLine(img, coverVec2{x: x - size, y: y - size}, coverVec2{x: x + size, y: y + size}, col) + drawLine(img, coverVec2{x: x - size, y: y + size}, coverVec2{x: x + size, y: y - size}, col) + drawDisc(img, x, y, size*0.32, direction.marks.cutout) + } + } +} + +func drawCoverFilmGrain( + img *image.RGBA, + w, h int, + direction coverArtDirection, + rng *rand.Rand, +) { + for i := 0; i < direction.grainCount; i++ { + x := rng.Intn(w) + y := rng.Intn(h) + alpha := uint8(8 + rng.Intn(18)) + if rng.Intn(2) == 0 { + blendPixel(img, x, y, color.RGBA{ + R: direction.grainWarm.R, + G: direction.grainWarm.G, + B: direction.grainWarm.B, + A: alpha, + }) + continue + } + blendPixel(img, x, y, color.RGBA{ + R: direction.grainCool.R, + G: direction.grainCool.G, + B: direction.grainCool.B, + A: alpha, + }) + } +} + +func coverFieldAngle(x, y float64, field coverFieldProfile, phase float64) float64 { + ax := math.Sin((x*field.freqX+phase*field.phaseX)*math.Pi*2) * field.ampX + ay := math.Cos((y*field.freqY-phase*field.phaseY)*math.Pi*2) * field.ampY + mix := math.Sin((x*field.freqMixX+y*field.freqMixY+phase*field.phaseMix)*math.Pi*2) * field.ampMix + curl := math.Cos((x*field.freqCurlX-y*field.freqCurlY+phase*field.phaseCurl)*math.Pi*2) * field.ampCurl + radial := math.Atan2(y-field.pivot.y, x-field.pivot.x) + distance := math.Hypot(x-field.pivot.x, y-field.pivot.y) + swirl := radial * field.swirl + pinch := (0.5 - distance) * field.pinch + shear := (x - y) * field.shear + return ax + ay + mix + curl + swirl + pinch + shear +} + +func nearestN(nodes []coverVec2, idx, n int) []int { + n = maxInt(1, n) + bestDistance := make([]float64, n) + bestIndex := make([]int, n) + for i := range bestDistance { + bestDistance[i] = math.MaxFloat64 + bestIndex[i] = -1 + } + for j := range nodes { + if j == idx { + continue + } + dx := nodes[idx].x - nodes[j].x + dy := nodes[idx].y - nodes[j].y + distance := dx*dx + dy*dy + for k := range bestDistance { + if distance >= bestDistance[k] { + continue + } + for shift := len(bestDistance) - 1; shift > k; shift-- { + bestDistance[shift] = bestDistance[shift-1] + bestIndex[shift] = bestIndex[shift-1] + } + bestDistance[k] = distance + bestIndex[k] = j + break + } + } + out := make([]int, 0, len(bestIndex)) + for _, best := range bestIndex { + if best >= 0 { + out = append(out, best) + } + } + return out +} + +func drawDisc(img *image.RGBA, cx, cy, radius float64, c color.RGBA) { + minX := int(math.Floor(cx - radius)) + maxX := int(math.Ceil(cx + radius)) + minY := int(math.Floor(cy - radius)) + maxY := int(math.Ceil(cy + radius)) + r2 := radius * radius + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + dx := float64(x) + 0.5 - cx + dy := float64(y) + 0.5 - cy + if dx*dx+dy*dy <= r2 { + blendPixel(img, x, y, c) + } + } + } +} + +func drawLine(img *image.RGBA, a, b coverVec2, c color.RGBA) { + dx := b.x - a.x + dy := b.y - a.y + steps := int(math.Max(math.Abs(dx), math.Abs(dy))) + if steps <= 0 { + blendPixel(img, int(a.x), int(a.y), c) + return + } + for i := 0; i <= steps; i++ { + t := float64(i) / float64(steps) + drawDisc(img, a.x+dx*t, a.y+dy*t, 0.82, c) + } +} + +func blendPixel(img *image.RGBA, x, y int, src color.RGBA) { + if !image.Pt(x, y).In(img.Rect) { + return + } + dst := img.RGBAAt(x, y) + alpha := float64(src.A) / 255 + inv := 1 - alpha + img.SetRGBA(x, y, color.RGBA{ + R: uint8(float64(src.R)*alpha + float64(dst.R)*inv), + G: uint8(float64(src.G)*alpha + float64(dst.G)*inv), + B: uint8(float64(src.B)*alpha + float64(dst.B)*inv), + A: 255, + }) +} + +func rgbToColor(c RGB) color.RGBA { + return color.RGBA{R: c.R, G: c.G, B: c.B, A: 255} +} + +func rgbToHSV(c color.RGBA) (h, s, v float64) { + r := float64(c.R) / 255 + g := float64(c.G) / 255 + b := float64(c.B) / 255 + maxC := math.Max(r, math.Max(g, b)) + minC := math.Min(r, math.Min(g, b)) + chroma := maxC - minC + + v = maxC + if maxC == 0 { + s = 0 + } else { + s = chroma / maxC + } + if chroma == 0 { + return 0, s, v + } + + switch maxC { + case r: + h = math.Mod((g-b)/chroma, 6) + case g: + h = (b-r)/chroma + 2 + default: + h = (r-g)/chroma + 4 + } + h /= 6 + if h < 0 { + h += 1 + } + return h, s, v +} + +func hsvToRGB(h, s, v float64) color.RGBA { + h = wrapHue(h) + s = clamp01(s) + v = clamp01(v) + if s == 0 { + ch := uint8(math.Round(v * 255)) + return color.RGBA{R: ch, G: ch, B: ch, A: 255} + } + + h6 := h * 6 + segment := math.Floor(h6) + f := h6 - segment + p := v * (1 - s) + q := v * (1 - s*f) + t := v * (1 - s*(1-f)) + + var r, g, b float64 + switch int(segment) % 6 { + case 0: + r, g, b = v, t, p + case 1: + r, g, b = q, v, p + case 2: + r, g, b = p, v, t + case 3: + r, g, b = p, q, v + case 4: + r, g, b = t, p, v + default: + r, g, b = v, p, q + } + + return color.RGBA{ + R: uint8(math.Round(clamp01(r) * 255)), + G: uint8(math.Round(clamp01(g) * 255)), + B: uint8(math.Round(clamp01(b) * 255)), + A: 255, + } +} + +func wrapHue(h float64) float64 { + if h >= 0 && h < 1 { + return h + } + h = math.Mod(h, 1) + if h < 0 { + h += 1 + } + return h +} + +func lerpRGB(a, b color.RGBA, t float64) color.RGBA { + t = clamp01(t) + return color.RGBA{ + R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t), + G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t), + B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t), + A: 255, + } +} + +func blendRGB(base, top color.RGBA, amount float64) color.RGBA { + return lerpRGB(base, top, clamp01(amount)) +} + +func radialFalloff(x, y, cx, cy, spread float64) float64 { + dx := x - cx + dy := y - cy + distance := math.Sqrt(dx*dx + dy*dy) + if distance >= spread { + return 0 + } + q := 1 - distance/spread + return q * q +} + +func powClamp(v, p float64) float64 { + return math.Pow(clamp01(v), p) +} + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func clamp(min, max, v float64) float64 { + if v < min { + return min + } + if v > max { + return max + } + return v +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxUint8(a, b uint8) uint8 { + if a > b { + return a + } + return b +} + +func minUint8(a, b uint8) uint8 { + if a < b { + return a + } + return b +} + +func jitterRGBA(c color.RGBA, rng *rand.Rand, spread int) color.RGBA { + if spread <= 0 { + return c + } + half := spread / 2 + return color.RGBA{ + R: shiftChannel(c.R, rng.Intn(spread)-half), + G: shiftChannel(c.G, rng.Intn(spread)-half), + B: shiftChannel(c.B, rng.Intn(spread)-half), + A: c.A, + } +} + +func shiftChannel(ch uint8, delta int) uint8 { + v := int(ch) + delta + if v < 0 { + return 0 + } + if v > 255 { + return 255 + } + return uint8(v) +} + +func coverStreamRNG(seed uint64, stream string) *rand.Rand { + h := fnv.New64a() + var raw [8]byte + binary.LittleEndian.PutUint64(raw[:], seed) + _, _ = h.Write(raw[:]) + _, _ = h.Write([]byte(stream)) + return rand.New(rand.NewSource(int64(h.Sum64()))) +} + +func coverSeedHash(text string) uint64 { + h := fnv.New64a() + h.Write([]byte(text)) + return h.Sum64() +} diff --git a/pdfexport/cover_art_test.go b/pdfexport/cover_art_test.go new file mode 100644 index 0000000..f9d801c --- /dev/null +++ b/pdfexport/cover_art_test.go @@ -0,0 +1,203 @@ +package pdfexport + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/png" + "math" + "testing" +) + +// --- Cover art seeding (P1) --- + +func TestRenderCoverArtworkPNGDeterministic(t *testing.T) { + base := RGB{R: 86, G: 124, B: 149} + seed := "quiet-fjord" + + first := renderCoverArtworkPNG(seed, "front", base) + second := renderCoverArtworkPNG(seed, "front", base) + + if len(first) == 0 || len(second) == 0 { + t.Fatalf("renderCoverArtworkPNG returned empty output") + } + if !bytes.Equal(first, second) { + t.Fatalf("expected identical PNG bytes for identical seed and variant") + } +} + +func TestRenderCoverArtworkPNGVariesBySeedAndVariant(t *testing.T) { + if testing.Short() { + t.Skip("skipping cover art variation check in short mode") + } + + base := RGB{R: 90, G: 128, B: 154} + frontA := renderCoverArtworkPNG("quiet-fjord", "front", base) + frontB := renderCoverArtworkPNG("quiet-fjord-alt", "front", base) + backA := renderCoverArtworkPNG("quiet-fjord", "back", base) + + assertArtworkDiff(t, frontA, frontB, 0.03) + assertArtworkDiff(t, frontA, backA, 0.03) +} + +func TestCoverArtworkBatchSaturationFloor(t *testing.T) { + if testing.Short() { + t.Skip("skipping batch saturation check in short mode") + } + + base := RGB{R: 90, G: 128, B: 154} + seeds := coverSeedSamples(30) + var total float64 + for _, seed := range seeds { + img := decodePNG(t, renderCoverArtworkPNG(seed, "front", base)) + total += sampledMeanSaturation(img, 14) + } + meanSaturation := total / float64(len(seeds)) + if meanSaturation < 0.24 { + t.Fatalf("mean saturation too low: got %.4f want >= 0.2400", meanSaturation) + } + if meanSaturation > 0.36 { + t.Fatalf("mean saturation unexpectedly high: got %.4f want <= 0.3600", meanSaturation) + } +} + +func TestCoverArtworkBatchDiversityFloor(t *testing.T) { + if testing.Short() { + t.Skip("skipping batch diversity check in short mode") + } + + base := RGB{R: 90, G: 128, B: 154} + seeds := coverSeedSamples(30) + images := make([]image.Image, 0, len(seeds)) + for _, seed := range seeds { + images = append(images, decodePNG(t, renderCoverArtworkPNG(seed, "front", base))) + } + + minDiff := math.MaxFloat64 + var total float64 + var pairs int + for i := range images { + for j := i + 1; j < len(images); j++ { + diff := sampledMeanChannelDiff(images[i], images[j], 12) + if diff < minDiff { + minDiff = diff + } + total += diff + pairs++ + } + } + meanDiff := total / float64(pairs) + if meanDiff < 0.090 { + t.Fatalf("mean pairwise diff too low: got %.4f want >= 0.0900", meanDiff) + } + if minDiff < 0.040 { + t.Fatalf("minimum pairwise diff too low: got %.4f want >= 0.0400", minDiff) + } +} + +func TestCoverArtworkArchetypeCoverage(t *testing.T) { + seen := make(map[coverArchetype]bool, int(coverArchetypeCount)) + for i := 1; i <= 120; i++ { + seed := fmt.Sprintf("coverage-%03d|front", i) + seen[coverArchetypeForSeed(coverSeedHash(seed))] = true + } + + if len(seen) != int(coverArchetypeCount) { + t.Fatalf("archetype coverage incomplete: got %d want %d", len(seen), coverArchetypeCount) + } +} + +func TestRenderCoverArtworkPNGHandlesLowAndHighChromaBase(t *testing.T) { + lowChroma := RGB{R: 128, G: 128, B: 128} + highChroma := RGB{R: 245, G: 62, B: 58} + + low := renderCoverArtworkPNG("chroma-check", "front", lowChroma) + high := renderCoverArtworkPNG("chroma-check", "front", highChroma) + + if len(low) == 0 || len(high) == 0 { + t.Fatalf("renderCoverArtworkPNG returned empty output for chroma check") + } + assertArtworkDiff(t, low, high, 0.03) +} + +func assertArtworkDiff(t *testing.T, left, right []byte, minMeanDiff float64) { + t.Helper() + + if bytes.Equal(left, right) { + t.Fatalf("unexpected byte-identical images for different inputs") + } + + imgA := decodePNG(t, left) + imgB := decodePNG(t, right) + diff := sampledMeanChannelDiff(imgA, imgB, 12) + if diff < minMeanDiff { + t.Fatalf("mean sampled channel diff too low: got %.4f want >= %.4f", diff, minMeanDiff) + } +} + +func decodePNG(t *testing.T, data []byte) image.Image { + t.Helper() + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("png.Decode error: %v", err) + } + return img +} + +func sampledMeanChannelDiff(a, b image.Image, step int) float64 { + bounds := a.Bounds() + if step < 1 { + step = 1 + } + + var total float64 + var samples int + for y := bounds.Min.Y; y < bounds.Max.Y; y += step { + for x := bounds.Min.X; x < bounds.Max.X; x += step { + ar, ag, ab, _ := a.At(x, y).RGBA() + br, bg, bb, _ := b.At(x, y).RGBA() + total += math.Abs(float64(ar)-float64(br)) / 65535 + total += math.Abs(float64(ag)-float64(bg)) / 65535 + total += math.Abs(float64(ab)-float64(bb)) / 65535 + samples += 3 + } + } + + if samples == 0 { + return 0 + } + return total / float64(samples) +} + +func sampledMeanSaturation(img image.Image, step int) float64 { + bounds := img.Bounds() + if step < 1 { + step = 1 + } + + var total float64 + var samples int + for y := bounds.Min.Y; y < bounds.Max.Y; y += step { + for x := bounds.Min.X; x < bounds.Max.X; x += step { + r, g, b, _ := img.At(x, y).RGBA() + c := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255} + _, sat, _ := rgbToHSV(c) + total += sat + samples++ + } + } + + if samples == 0 { + return 0 + } + return total / float64(samples) +} + +func coverSeedSamples(n int) []string { + out := make([]string, 0, n) + for i := 1; i <= n; i++ { + out = append(out, fmt.Sprintf("test-cover-%02d", i)) + } + return out +} diff --git a/pdfexport/font.go b/pdfexport/font.go new file mode 100644 index 0000000..654e8ad --- /dev/null +++ b/pdfexport/font.go @@ -0,0 +1,20 @@ +package pdfexport + +const ( + standardCellFontMin = 5.2 + standardCellFontMax = 8.2 +) + +func standardCellFontSize(cellSize, scale float64) float64 { + return clampStandardCellFontSize(cellSize * scale) +} + +func clampStandardCellFontSize(fontSize float64) float64 { + if fontSize < standardCellFontMin { + return standardCellFontMin + } + if fontSize > standardCellFontMax { + return standardCellFontMax + } + return fontSize +} diff --git a/pdfexport/font_test.go b/pdfexport/font_test.go new file mode 100644 index 0000000..dd4aa69 --- /dev/null +++ b/pdfexport/font_test.go @@ -0,0 +1,73 @@ +package pdfexport + +import "testing" + +func TestStandardCellFontSizeBounds(t *testing.T) { + tests := []struct { + name string + cellSize float64 + scale float64 + want float64 + }{ + { + name: "clamps low", + cellSize: 3.0, + scale: 0.6, + want: 5.2, + }, + { + name: "keeps in range", + cellSize: 10.0, + scale: 0.6, + want: 6.0, + }, + { + name: "clamps high", + cellSize: 20.0, + scale: 0.7, + want: 8.2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := standardCellFontSize(tt.cellSize, tt.scale) + if got != tt.want { + t.Fatalf("font size = %.3f, want %.3f", got, tt.want) + } + }) + } +} + +func TestClampStandardCellFontSizeBounds(t *testing.T) { + tests := []struct { + name string + in float64 + want float64 + }{ + { + name: "below min", + in: 3.9, + want: 5.2, + }, + { + name: "in range", + in: 6.5, + want: 6.5, + }, + { + name: "above max", + in: 9.1, + want: 8.2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := clampStandardCellFontSize(tt.in) + if got != tt.want { + t.Fatalf("clamp = %.3f, want %.3f", got, tt.want) + } + }) + } +} diff --git a/pdfexport/fonts.go b/pdfexport/fonts.go new file mode 100644 index 0000000..6bdbaa6 --- /dev/null +++ b/pdfexport/fonts.go @@ -0,0 +1,34 @@ +package pdfexport + +import ( + _ "embed" + "fmt" + + "codeberg.org/go-pdf/fpdf" +) + +const ( + sansFontFamily = "AtkinsonHyperlegibleNext" + coverFontFamily = "DMSerifDisplay" +) + +var ( + //go:embed fonts/AtkinsonHyperlegibleNext-Regular.ttf + atkinsonRegularTTF []byte + + //go:embed fonts/AtkinsonHyperlegibleNext-Bold.ttf + atkinsonBoldTTF []byte + + //go:embed fonts/DMSerifDisplay-Regular.ttf + dmSerifDisplayTTF []byte +) + +func registerPDFFonts(pdf *fpdf.Fpdf) error { + pdf.AddUTF8FontFromBytes(sansFontFamily, "", atkinsonRegularTTF) + pdf.AddUTF8FontFromBytes(sansFontFamily, "B", atkinsonBoldTTF) + pdf.AddUTF8FontFromBytes(coverFontFamily, "", dmSerifDisplayTTF) + if pdf.Err() { + return fmt.Errorf("register pdf fonts: %w", pdf.Error()) + } + return nil +} diff --git a/pdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttf b/pdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttf new file mode 100644 index 0000000..a23aed3 Binary files /dev/null and b/pdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttf differ diff --git a/pdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt b/pdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt new file mode 100644 index 0000000..e71810b --- /dev/null +++ b/pdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/pdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf b/pdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf new file mode 100644 index 0000000..3fa771f Binary files /dev/null and b/pdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf differ diff --git a/pdfexport/fonts/DMSerifDisplay-OFL.txt b/pdfexport/fonts/DMSerifDisplay-OFL.txt new file mode 100644 index 0000000..be384f0 --- /dev/null +++ b/pdfexport/fonts/DMSerifDisplay-OFL.txt @@ -0,0 +1,93 @@ +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. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/pdfexport/fonts/DMSerifDisplay-Regular.ttf b/pdfexport/fonts/DMSerifDisplay-Regular.ttf new file mode 100644 index 0000000..bc5ed0c Binary files /dev/null and b/pdfexport/fonts/DMSerifDisplay-Regular.ttf differ diff --git a/pdfexport/jsonl.go b/pdfexport/jsonl.go new file mode 100644 index 0000000..4066bb9 --- /dev/null +++ b/pdfexport/jsonl.go @@ -0,0 +1,143 @@ +package pdfexport + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/FelineStateMachine/puzzletea/game" +) + +const ExportSchemaV1 = "puzzletea.export.v1" + +type JSONLRecord struct { + Schema string `json:"schema"` + Pack JSONLPackMeta `json:"pack"` + Puzzle JSONLPuzzle `json:"puzzle"` +} + +type JSONLPackMeta struct { + Generated string `json:"generated"` + Version string `json:"version"` + Category string `json:"category"` + ModeSelection string `json:"mode_selection"` + Count int `json:"count"` + Seed string `json:"seed"` +} + +type JSONLPuzzle struct { + Index int `json:"index"` + Name string `json:"name"` + Game string `json:"game"` + Mode string `json:"mode"` + Save json.RawMessage `json:"save"` +} + +func ParseJSONLFiles(paths []string) ([]PackDocument, error) { + docs := make([]PackDocument, 0, len(paths)) + for _, path := range paths { + doc, err := ParseJSONLFile(path) + if err != nil { + return nil, err + } + docs = append(docs, doc) + } + return docs, nil +} + +func ParseJSONLFile(path string) (PackDocument, error) { + if !strings.EqualFold(filepath.Ext(path), ".jsonl") { + return PackDocument{}, fmt.Errorf("%s: expected .jsonl input", path) + } + + f, err := os.Open(path) + if err != nil { + return PackDocument{}, fmt.Errorf("open input jsonl: %w", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 16*1024*1024) + + doc := PackDocument{SourcePath: path} + doc.Metadata.SourceFileName = filepath.Base(path) + puzzles := []Puzzle{} + lineNo := 0 + seenAny := false + + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + seenAny = true + + var record JSONLRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + return PackDocument{}, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err) + } + if record.Schema != ExportSchemaV1 { + return PackDocument{}, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema) + } + + if doc.Metadata.GeneratedRaw == "" { + doc.Metadata.GeneratedRaw = record.Pack.Generated + doc.Metadata.Version = record.Pack.Version + doc.Metadata.Category = record.Pack.Category + doc.Metadata.ModeSelection = record.Pack.ModeSelection + doc.Metadata.Count = record.Pack.Count + doc.Metadata.Seed = record.Pack.Seed + } + + category := strings.TrimSpace(record.Puzzle.Game) + if category == "" { + category = strings.TrimSpace(record.Pack.Category) + } + mode := strings.TrimSpace(record.Puzzle.Mode) + if mode == "" { + mode = strings.TrimSpace(record.Pack.ModeSelection) + } + + p := Puzzle{ + SourcePath: path, + SourceFileName: filepath.Base(path), + Category: category, + ModeSelection: mode, + Name: record.Puzzle.Name, + Index: record.Puzzle.Index, + SaveData: append([]byte(nil), record.Puzzle.Save...), + } + + adapter, ok := game.LookupPrintAdapter(category) + if !ok { + continue + } + payload, err := adapter.BuildPDFPayload(p.SaveData) + if err != nil { + return PackDocument{}, fmt.Errorf("%s:%d: build print payload: %w", path, lineNo, err) + } + if game.IsNilPrintPayload(payload) { + continue + } + p.PrintPayload = payload + + puzzles = append(puzzles, p) + } + + if err := scanner.Err(); err != nil { + return PackDocument{}, fmt.Errorf("read input jsonl: %w", err) + } + if !seenAny { + return PackDocument{}, fmt.Errorf("%s: input jsonl is empty", path) + } + if doc.Metadata.Count == 0 { + doc.Metadata.Count = len(puzzles) + } + doc.Puzzles = puzzles + return doc, nil +} diff --git a/pdfexport/jsonl_test.go b/pdfexport/jsonl_test.go new file mode 100644 index 0000000..11c89e6 --- /dev/null +++ b/pdfexport/jsonl_test.go @@ -0,0 +1,421 @@ +package pdfexport + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" +) + +func TestParseJSONLFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "pack.jsonl") + record := JSONLRecord{ + Schema: ExportSchemaV1, + Pack: JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Nonogram", + ModeSelection: "Mini", + Count: 1, + Seed: "seed-1", + }, + Puzzle: JSONLPuzzle{ + Index: 1, + Name: "ember-newt", + Game: "Nonogram", + Mode: "Mini", + Save: json.RawMessage(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`), + }, + } + writeSingleJSONLRecord(t, path, record) + + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatal(err) + } + if got, want := len(doc.Puzzles), 1; got != want { + t.Fatalf("puzzles = %d, want %d", got, want) + } + payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData) + if !ok || payload == nil { + t.Fatal("expected nonogram print payload from save hydration") + } +} + +func TestParseJSONLFileHydratesNonogramFromSaveWithoutSnippet(t *testing.T) { + path := filepath.Join(t.TempDir(), "nonogram-save-only.jsonl") + record := JSONLRecord{ + Schema: ExportSchemaV1, + Pack: JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Nonogram", + ModeSelection: "Mini", + Count: 1, + }, + Puzzle: JSONLPuzzle{ + Index: 1, + Name: "fern-owl", + Game: "Nonogram", + Mode: "Mini", + Save: json.RawMessage(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`), + }, + } + writeSingleJSONLRecord(t, path, record) + + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatal(err) + } + if got, want := len(doc.Puzzles), 1; got != want { + t.Fatalf("puzzles = %d, want %d", got, want) + } + payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData) + if !ok || payload == nil { + t.Fatal("expected nonogram print payload from save hydration") + } +} + +func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) { + path := filepath.Join(t.TempDir(), "pack.md") + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ParseJSONLFile(path) + if err == nil { + t.Fatal("expected extension validation error") + } + if !strings.Contains(err.Error(), "expected .jsonl input") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "takuzu-pack.jsonl") + record := JSONLRecord{ + Schema: ExportSchemaV1, + Pack: JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Takuzu", + ModeSelection: "Beginner", + Count: 1, + Seed: "seed-2", + }, + Puzzle: JSONLPuzzle{ + Index: 1, + Name: "binary-wave", + Game: "Takuzu", + Mode: "Beginner", + Save: json.RawMessage(`{"size":2,"state":"01\n10","provided":"##\n#."}`), + }, + } + writeSingleJSONLRecord(t, path, record) + + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatal(err) + } + if got, want := len(doc.Puzzles), 1; got != want { + t.Fatalf("puzzles = %d, want %d", got, want) + } + payload, ok := doc.Puzzles[0].PrintPayload.(*TakuzuData) + if !ok || payload == nil { + t.Fatal("expected takuzu print payload from save hydration") + } + if got, want := payload.Givens[1][1], ""; got != want { + t.Fatalf("takuzu row 1 col 1 = %q, want empty", got) + } +} + +func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "sudoku.jsonl") + record := JSONLRecord{ + Schema: ExportSchemaV1, + Pack: JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Sudoku", + ModeSelection: "Easy", + Count: 1, + }, + Puzzle: JSONLPuzzle{ + Index: 1, + Name: "moss-pine", + Game: "Sudoku", + Mode: "Easy", + Save: json.RawMessage(`{"grid":"500000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":5}]}`), + }, + } + writeSingleJSONLRecord(t, path, record) + + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatal(err) + } + if got, want := len(doc.Puzzles), 1; got != want { + t.Fatalf("puzzles = %d, want %d", got, want) + } + payload, ok := doc.Puzzles[0].PrintPayload.(*SudokuData) + if !ok || payload == nil { + t.Fatal("expected sudoku print payload from save hydration") + } + if got, want := payload.Givens[0][0], 5; got != want { + t.Fatalf("sudoku givens[0][0] = %d, want %d", got, want) + } +} + +func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "wordsearch.jsonl") + record := JSONLRecord{ + Schema: ExportSchemaV1, + Pack: JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Word Search", + ModeSelection: "Standard", + Count: 1, + }, + Puzzle: JSONLPuzzle{ + Index: 1, + Name: "sage-sky", + Game: "Word Search", + Mode: "Standard", + Save: json.RawMessage(`{"width":3,"height":3,"grid":"abc\ndef\nghi","words":[{"text":"ace"},{"text":"fig"}]}`), + }, + } + writeSingleJSONLRecord(t, path, record) + + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatal(err) + } + if got, want := len(doc.Puzzles), 1; got != want { + t.Fatalf("puzzles = %d, want %d", got, want) + } + payload, ok := doc.Puzzles[0].PrintPayload.(*WordSearchData) + if !ok || payload == nil { + t.Fatal("expected word search print payload from save hydration") + } + if got, want := len(payload.Words), 2; got != want { + t.Fatalf("word count = %d, want %d", got, want) + } + if got, want := payload.Words[0], "ACE"; got != want { + t.Fatalf("first word = %q, want %q", got, want) + } +} + +func TestParseJSONLFileHydratesNurikabeFromSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "nurikabe.jsonl") + record := JSONLRecord{ + Schema: ExportSchemaV1, + Pack: JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Nurikabe", + ModeSelection: "Mini", + Count: 1, + }, + Puzzle: JSONLPuzzle{ + Index: 1, + Name: "mist-pond", + Game: "Nurikabe", + Mode: "Mini", + Save: json.RawMessage(`{"width":2,"height":2,"clues":"1,0\n0,2","marks":"??\n??"}`), + }, + } + writeSingleJSONLRecord(t, path, record) + + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatal(err) + } + if got, want := len(doc.Puzzles), 1; got != want { + t.Fatalf("puzzles = %d, want %d", got, want) + } + payload, ok := doc.Puzzles[0].PrintPayload.(*NurikabeData) + if !ok || payload == nil { + t.Fatal("expected nurikabe print payload from save hydration") + } + if got, want := payload.Clues[1][1], 2; got != want { + t.Fatalf("nurikabe clues[1][1] = %d, want %d", got, want) + } +} + +func TestParseJSONLFileHydratesShikakuFromSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "shikaku.jsonl") + record := JSONLRecord{ + Schema: ExportSchemaV1, + Pack: JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Shikaku", + ModeSelection: "Mini", + Count: 1, + }, + Puzzle: JSONLPuzzle{ + Index: 1, + Name: "ridge-glen", + Game: "Shikaku", + Mode: "Mini", + Save: json.RawMessage(`{"width":2,"height":2,"clues":[{"x":0,"y":0,"value":1},{"x":1,"y":1,"value":4}]}`), + }, + } + writeSingleJSONLRecord(t, path, record) + + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatal(err) + } + if got, want := len(doc.Puzzles), 1; got != want { + t.Fatalf("puzzles = %d, want %d", got, want) + } + payload, ok := doc.Puzzles[0].PrintPayload.(*ShikakuData) + if !ok || payload == nil { + t.Fatal("expected shikaku print payload from save hydration") + } + if got, want := payload.Clues[1][1], 4; got != want { + t.Fatalf("shikaku clues[1][1] = %d, want %d", got, want) + } +} + +func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "hashi.jsonl") + record := JSONLRecord{ + Schema: ExportSchemaV1, + Pack: JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Hashiwokakero", + ModeSelection: "Standard", + Count: 1, + }, + Puzzle: JSONLPuzzle{ + Index: 1, + Name: "reef-palm", + Game: "Hashiwokakero", + Mode: "Standard", + Save: json.RawMessage(`{"width":7,"height":7,"islands":[{"x":0,"y":0,"required":3},{"x":6,"y":6,"required":2}],"bridges":[]}`), + }, + } + writeSingleJSONLRecord(t, path, record) + + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatal(err) + } + if got, want := len(doc.Puzzles), 1; got != want { + t.Fatalf("puzzles = %d, want %d", got, want) + } + payload, ok := doc.Puzzles[0].PrintPayload.(*HashiData) + if !ok || payload == nil { + t.Fatal("expected hashi print payload from save hydration") + } + if got, want := len(payload.Islands), 2; got != want { + t.Fatalf("hashi island count = %d, want %d", got, want) + } +} + +func TestParseJSONLFileSilentlySkipsUnsupportedGame(t *testing.T) { + path := filepath.Join(t.TempDir(), "lights.jsonl") + record := JSONLRecord{ + Schema: ExportSchemaV1, + Pack: JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Lights Out", + ModeSelection: "Standard", + Count: 1, + }, + Puzzle: JSONLPuzzle{ + Index: 1, + Name: "glow-shore", + Game: "Lights Out", + Mode: "Standard", + Save: json.RawMessage(`{"size":5}`), + }, + } + writeSingleJSONLRecord(t, path, record) + + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatalf("expected silent no-op for unsupported game, got: %v", err) + } + if got := len(doc.Puzzles); got != 0 { + t.Fatalf("puzzles = %d, want 0", got) + } +} + +func writeSingleJSONLRecord(t *testing.T, path string, record JSONLRecord) { + t.Helper() + data, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatal(err) + } +} + +var registerJSONLAdaptersOnce sync.Once + +func init() { + ensureJSONLTestAdapters() +} + +func ensureJSONLTestAdapters() { + registerJSONLAdaptersOnce.Do(func() { + register := func(category string, build func(save []byte) (any, error), aliases ...string) { + game.RegisterPrintAdapter(jsonlTestAdapter{ + category: category, + aliases: aliases, + build: build, + }) + } + + register("Nonogram", buildPayloadAdapter("Nonogram", func(save []byte) (any, error) { + return ParseNonogramPrintData(save) + }), "nonogram") + register("Takuzu", buildPayloadAdapter("Takuzu", func(save []byte) (any, error) { + return ParseTakuzuPrintData(save) + }), "takuzu") + register("Sudoku", buildPayloadAdapter("Sudoku", func(save []byte) (any, error) { + return ParseSudokuPrintData(save) + }), "sudoku") + register("Word Search", buildPayloadAdapter("Word Search", func(save []byte) (any, error) { + return ParseWordSearchPrintData(save) + }), "wordsearch") + register("Nurikabe", buildPayloadAdapter("Nurikabe", func(save []byte) (any, error) { + return ParseNurikabePrintData(save) + }), "nurikabe") + register("Shikaku", buildPayloadAdapter("Shikaku", func(save []byte) (any, error) { + return ParseShikakuPrintData(save) + }), "shikaku") + register("Hashiwokakero", buildPayloadAdapter("Hashiwokakero", func(save []byte) (any, error) { + return ParseHashiPrintData(save) + }), "hashi") + }) +} + +func buildPayloadAdapter(_ string, parse func(save []byte) (any, error)) func([]byte) (any, error) { + return parse +} + +type jsonlTestAdapter struct { + category string + aliases []string + build func(save []byte) (any, error) +} + +func (a jsonlTestAdapter) CanonicalGameType() string { return a.category } +func (a jsonlTestAdapter) Aliases() []string { return a.aliases } +func (a jsonlTestAdapter) BuildPDFPayload(save []byte) (any, error) { + return a.build(save) +} +func (a jsonlTestAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil } diff --git a/pdfexport/order.go b/pdfexport/order.go new file mode 100644 index 0000000..860e7b0 --- /dev/null +++ b/pdfexport/order.go @@ -0,0 +1,104 @@ +package pdfexport + +import ( + "hash/fnv" + "math/rand/v2" + "sort" + "strings" + "time" +) + +func OrderPuzzlesForPrint(puzzles []Puzzle, seed string) []Puzzle { + ordered := append([]Puzzle(nil), puzzles...) + if len(ordered) <= 1 { + return ordered + } + + sort.SliceStable(ordered, func(i, j int) bool { + if ordered[i].DifficultyScore != ordered[j].DifficultyScore { + return ordered[i].DifficultyScore < ordered[j].DifficultyScore + } + if c := strings.Compare(normalizeToken(ordered[i].Category), normalizeToken(ordered[j].Category)); c != 0 { + return c < 0 + } + if c := strings.Compare(normalizeToken(ordered[i].ModeSelection), normalizeToken(ordered[j].ModeSelection)); c != 0 { + return c < 0 + } + if c := strings.Compare(ordered[i].SourceFileName, ordered[j].SourceFileName); c != 0 { + return c < 0 + } + return ordered[i].Index < ordered[j].Index + }) + + rng := seededRand(seed) + + const bandSize = 6 + for start := 0; start < len(ordered); start += bandSize { + end := min(start+bandSize, len(ordered)) + shuffleBand(ordered[start:end], rng) + } + + // Reduce same-category runs across band edges while preserving + // the overall difficulty trajectory. + for i := 1; i < len(ordered); i++ { + if !sameCategory(ordered[i-1], ordered[i]) { + continue + } + for j := i + 1; j < min(i+4, len(ordered)); j++ { + if sameCategory(ordered[i-1], ordered[j]) { + continue + } + ordered[i], ordered[j] = ordered[j], ordered[i] + break + } + } + + return ordered +} + +func shuffleBand(puzzles []Puzzle, rng *rand.Rand) { + if len(puzzles) <= 1 { + return + } + + perm := rng.Perm(len(puzzles)) + shuffled := make([]Puzzle, len(puzzles)) + for i, idx := range perm { + shuffled[i] = puzzles[idx] + } + copy(puzzles, shuffled) + + for i := 1; i < len(puzzles); i++ { + if !sameCategory(puzzles[i-1], puzzles[i]) { + continue + } + for j := i + 1; j < len(puzzles); j++ { + if sameCategory(puzzles[i-1], puzzles[j]) { + continue + } + puzzles[i], puzzles[j] = puzzles[j], puzzles[i] + break + } + } +} + +func seededRand(seed string) *rand.Rand { + if strings.TrimSpace(seed) == "" { + seed = time.Now().Format(time.RFC3339Nano) + } + h := fnv.New64a() + h.Write([]byte(seed)) + s := h.Sum64() + return rand.New(rand.NewPCG(s, ^s)) +} + +func sameCategory(a, b Puzzle) bool { + return normalizeToken(a.Category) == normalizeToken(b.Category) +} + +func normalizeToken(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.ReplaceAll(s, "-", " ") + s = strings.ReplaceAll(s, "_", " ") + return strings.Join(strings.Fields(s), " ") +} diff --git a/pdfexport/order_test.go b/pdfexport/order_test.go new file mode 100644 index 0000000..7a71304 --- /dev/null +++ b/pdfexport/order_test.go @@ -0,0 +1,36 @@ +package pdfexport + +import ( + "reflect" + "testing" +) + +func TestOrderPuzzlesForPrintDeterministicWithSeed(t *testing.T) { + input := []Puzzle{ + {Name: "a", Category: "Sudoku", DifficultyScore: 0.0}, + {Name: "b", Category: "Sudoku", DifficultyScore: 0.1}, + {Name: "c", Category: "Nonogram", DifficultyScore: 0.2}, + {Name: "d", Category: "Nonogram", DifficultyScore: 0.3}, + {Name: "e", Category: "Takuzu", DifficultyScore: 0.4}, + {Name: "f", Category: "Takuzu", DifficultyScore: 0.5}, + {Name: "g", Category: "Hitori", DifficultyScore: 0.6}, + {Name: "h", Category: "Hitori", DifficultyScore: 0.7}, + {Name: "i", Category: "Shikaku", DifficultyScore: 0.8}, + {Name: "j", Category: "Shikaku", DifficultyScore: 0.9}, + } + + orderedA := OrderPuzzlesForPrint(input, "zine-seed") + orderedB := OrderPuzzlesForPrint(input, "zine-seed") + if !reflect.DeepEqual(orderedA, orderedB) { + t.Fatal("expected deterministic ordering with same seed") + } + + orderedC := OrderPuzzlesForPrint(input, "different-seed") + if reflect.DeepEqual(orderedA, orderedC) { + t.Fatal("expected different ordering with a different seed") + } + + if got, want := len(orderedA), len(input); got != want { + t.Fatalf("length = %d, want %d", got, want) + } +} diff --git a/pdfexport/parse.go b/pdfexport/parse.go new file mode 100644 index 0000000..eb99384 --- /dev/null +++ b/pdfexport/parse.go @@ -0,0 +1,496 @@ +package pdfexport + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +var ( + puzzleHeadingPattern = regexp.MustCompile(`^##\s+(.+?)\s+-\s+(\d+)\s*$`) + nonogramRowHeaderRegex = regexp.MustCompile(`^R\d+$`) + nonogramColHeaderRegex = regexp.MustCompile(`^C\d+$`) + tableSepCellRegex = regexp.MustCompile(`^:?-{3,}:?$`) +) + +func ParseFiles(paths []string) ([]PackDocument, error) { + docs := make([]PackDocument, 0, len(paths)) + for _, path := range paths { + doc, err := ParseFile(path) + if err != nil { + return nil, err + } + docs = append(docs, doc) + } + return docs, nil +} + +func ParseFile(path string) (PackDocument, error) { + data, err := os.ReadFile(path) + if err != nil { + return PackDocument{}, fmt.Errorf("read input markdown: %w", err) + } + return ParseMarkdown(path, string(data)) +} + +func ParseMarkdown(path, content string) (PackDocument, error) { + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + lines := strings.Split(content, "\n") + + firstContentLine := firstNonEmptyLine(lines) + if firstContentLine < 0 { + return PackDocument{}, parseError(path, 1, "input markdown is empty") + } + if strings.TrimSpace(lines[firstContentLine]) != "# PuzzleTea Export" { + return PackDocument{}, parseError(path, firstContentLine+1, "expected markdown title '# PuzzleTea Export'") + } + + headings := findHeadingLines(lines) + if len(headings) == 0 { + return PackDocument{}, parseError(path, firstContentLine+1, "expected at least one puzzle section heading") + } + + meta, err := parseMetadata(lines[firstContentLine+1:headings[0]], path, firstContentLine+2) + if err != nil { + return PackDocument{}, err + } + meta.SourceFileName = filepath.Base(path) + + puzzles := make([]Puzzle, 0, len(headings)) + for i, start := range headings { + end := len(lines) + if i+1 < len(headings) { + end = headings[i+1] + } + + puzzle, err := parsePuzzleSection(lines[start:end], path, start+1, meta) + if err != nil { + return PackDocument{}, err + } + puzzles = append(puzzles, puzzle) + } + + if len(puzzles) == 0 { + return PackDocument{}, parseError(path, firstContentLine+1, "no puzzle sections were parsed") + } + + return PackDocument{ + SourcePath: path, + Metadata: meta, + Puzzles: puzzles, + }, nil +} + +func parseMetadata(lines []string, path string, startLine int) (PackMetadata, error) { + meta := PackMetadata{} + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if !strings.HasPrefix(trimmed, "- ") { + continue + } + + entry := strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) + key, value, ok := strings.Cut(entry, ":") + if !ok { + continue + } + + lineNo := startLine + i + key = strings.ToLower(strings.TrimSpace(key)) + value = strings.TrimSpace(value) + + switch key { + case "generated": + meta.GeneratedRaw = value + if ts, err := time.Parse(time.RFC3339, value); err == nil { + meta.GeneratedAt = ts + } + case "version": + meta.Version = value + case "category": + meta.Category = value + case "mode selection": + meta.ModeSelection = value + case "count": + count, err := strconv.Atoi(value) + if err != nil { + return PackMetadata{}, parseError(path, lineNo, "invalid Count value %q", value) + } + meta.Count = count + case "seed": + meta.Seed = value + case "export format": + meta.Format = value + } + } + + if strings.TrimSpace(meta.Category) == "" { + return PackMetadata{}, parseError(path, startLine, "missing required metadata field: Category") + } + if strings.TrimSpace(meta.ModeSelection) == "" { + return PackMetadata{}, parseError(path, startLine, "missing required metadata field: Mode Selection") + } + + return meta, nil +} + +func parsePuzzleSection(section []string, path string, startLine int, meta PackMetadata) (Puzzle, error) { + if len(section) == 0 { + return Puzzle{}, parseError(path, startLine, "empty puzzle section") + } + + heading := strings.TrimSpace(section[0]) + matches := puzzleHeadingPattern.FindStringSubmatch(heading) + if len(matches) != 3 { + return Puzzle{}, parseError(path, startLine, "invalid puzzle heading %q", heading) + } + + index, err := strconv.Atoi(matches[2]) + if err != nil { + return Puzzle{}, parseError(path, startLine, "invalid puzzle index %q", matches[2]) + } + + bodyLines := append([]string(nil), section[1:]...) + trimSectionBody(&bodyLines) + body := strings.Join(bodyLines, "\n") + + p := Puzzle{ + SourcePath: path, + SourceFileName: meta.SourceFileName, + Category: meta.Category, + ModeSelection: meta.ModeSelection, + Name: matches[1], + Index: index, + Body: body, + } + + if strings.EqualFold(strings.TrimSpace(meta.Category), "nonogram") { + nonogram, err := parseNonogramBody(bodyLines, path, startLine+1) + if err != nil { + return Puzzle{}, err + } + p.PrintPayload = nonogram + return p, nil + } + + table, err := parseGridTableBody(bodyLines, path, startLine+1) + if err != nil { + return Puzzle{}, err + } + p.PrintPayload = table + + return p, nil +} + +func parseNonogramBody(bodyLines []string, path string, bodyStartLine int) (*NonogramData, error) { + tableLines, tableLineNumbers := findFirstMarkdownTable(bodyLines, bodyStartLine) + if len(tableLines) < 3 { + lineNo := bodyStartLine + if len(tableLineNumbers) > 0 { + lineNo = tableLineNumbers[0] + } + return nil, parseError(path, lineNo, "expected nonogram markdown table with header, separator, and data rows") + } + + header := parseTableRow(tableLines[0]) + if len(header) == 0 { + return nil, parseError(path, tableLineNumbers[0], "nonogram header row is empty") + } + + rowHintCols := 0 + colCount := 0 + for _, cell := range header { + switch { + case nonogramRowHeaderRegex.MatchString(cell): + rowHintCols++ + case nonogramColHeaderRegex.MatchString(cell): + colCount++ + } + } + if rowHintCols < 1 || colCount < 1 { + return nil, parseError(path, tableLineNumbers[0], "expected nonogram header cells like R1.. and C1..") + } + + expectedCols := rowHintCols + colCount + dataRows := make([][]string, 0, len(tableLines)-2) + for i := 2; i < len(tableLines); i++ { + cells := parseTableRow(tableLines[i]) + if len(cells) < expectedCols { + return nil, parseError(path, tableLineNumbers[i], "expected %d columns, found %d", expectedCols, len(cells)) + } + if len(cells) > expectedCols { + cells = cells[:expectedCols] + } + dataRows = append(dataRows, cells) + } + + if len(dataRows) == 0 { + return nil, parseError(path, tableLineNumbers[0], "nonogram table has no data rows") + } + + colHintRows := 0 + for _, row := range dataRows { + if rowHintPlaceholderRow(row[:rowHintCols]) { + colHintRows++ + continue + } + break + } + + height := len(dataRows) - colHintRows + if height <= 0 { + return nil, parseError(path, tableLineNumbers[len(tableLineNumbers)-1], "nonogram table does not contain puzzle rows") + } + + rowHints := make([][]int, height) + grid := make([][]string, height) + for y := 0; y < height; y++ { + row := dataRows[colHintRows+y] + hints := parseHintCells(row[:rowHintCols]) + if len(hints) == 0 { + hints = []int{0} + } + rowHints[y] = hints + + gridRow := make([]string, colCount) + for x := 0; x < colCount; x++ { + cell := strings.TrimSpace(row[rowHintCols+x]) + switch cell { + case "", ".": + gridRow[x] = " " + default: + gridRow[x] = cell + } + } + grid[y] = gridRow + } + + colHints := make([][]int, colCount) + for x := 0; x < colCount; x++ { + hints := make([]int, 0, colHintRows) + for r := 0; r < colHintRows; r++ { + if v, ok := parseHintValue(dataRows[r][rowHintCols+x]); ok { + hints = append(hints, v) + } + } + if len(hints) == 0 { + hints = []int{0} + } + colHints[x] = hints + } + + return &NonogramData{ + Width: colCount, + Height: height, + RowHints: rowHints, + ColHints: colHints, + Grid: grid, + }, nil +} + +func parseGridTableBody(bodyLines []string, path string, bodyStartLine int) (*GridTable, error) { + tableLines, tableLineNumbers := findFirstMarkdownTable(bodyLines, bodyStartLine) + if len(tableLines) == 0 { + return nil, nil + } + if len(tableLines) < 2 { + return nil, parseError(path, tableLineNumbers[0], "expected markdown table to include data rows") + } + + rows := make([][]string, 0, len(tableLines)) + for i, line := range tableLines { + cells := parseTableRow(line) + if len(cells) == 0 { + return nil, parseError(path, tableLineNumbers[i], "empty table row") + } + rows = append(rows, cells) + } + + hasHeaderRow := false + if len(rows) > 1 && isMarkdownSeparatorRow(rows[1]) { + hasHeaderRow = true + rows = append(rows[:1], rows[2:]...) + } + if len(rows) < 2 { + return nil, parseError(path, tableLineNumbers[0], "table must include header and data rows") + } + + width := 0 + for _, row := range rows { + if len(row) > width { + width = len(row) + } + } + for i := range rows { + if len(rows[i]) >= width { + continue + } + padded := make([]string, width) + copy(padded, rows[i]) + rows[i] = padded + } + + return &GridTable{ + Rows: rows, + HasHeaderRow: hasHeaderRow, + HasHeaderCol: detectHeaderColumn(rows, hasHeaderRow), + }, nil +} + +func findFirstMarkdownTable(lines []string, startLine int) ([]string, []int) { + table := []string{} + lineNumbers := []int{} + started := false + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "|") { + started = true + table = append(table, line) + lineNumbers = append(lineNumbers, startLine+i) + continue + } + if started { + break + } + } + + return table, lineNumbers +} + +func parseTableRow(line string) []string { + trimmed := strings.TrimSpace(line) + trimmed = strings.TrimPrefix(trimmed, "|") + trimmed = strings.TrimSuffix(trimmed, "|") + if trimmed == "" { + return []string{} + } + + parts := strings.Split(trimmed, "|") + cells := make([]string, 0, len(parts)) + for _, part := range parts { + cells = append(cells, strings.TrimSpace(part)) + } + return cells +} + +func isMarkdownSeparatorRow(cells []string) bool { + if len(cells) == 0 { + return false + } + for _, cell := range cells { + if !tableSepCellRegex.MatchString(strings.TrimSpace(cell)) { + return false + } + } + return true +} + +func rowHintPlaceholderRow(cells []string) bool { + for _, cell := range cells { + trimmed := strings.TrimSpace(cell) + if trimmed != "" && trimmed != "." { + return false + } + } + return true +} + +func parseHintCells(cells []string) []int { + hints := make([]int, 0, len(cells)) + for _, cell := range cells { + if v, ok := parseHintValue(cell); ok { + hints = append(hints, v) + } + } + return hints +} + +func detectHeaderColumn(rows [][]string, hasHeaderRow bool) bool { + if len(rows) < 2 { + return false + } + + start := 1 + if !hasHeaderRow { + start = 0 + } + + total := 0 + numeric := 0 + for i := start; i < len(rows); i++ { + if len(rows[i]) == 0 { + continue + } + total++ + if _, err := strconv.Atoi(strings.TrimSpace(rows[i][0])); err == nil { + numeric++ + } + } + + return total > 0 && numeric*100/total >= 70 +} + +func parseHintValue(cell string) (int, bool) { + trimmed := strings.TrimSpace(cell) + if trimmed == "" || trimmed == "." { + return 0, false + } + v, err := strconv.Atoi(trimmed) + if err != nil { + return 0, false + } + return v, true +} + +func trimSectionBody(lines *[]string) { + for len(*lines) > 0 { + trimmed := strings.TrimSpace((*lines)[0]) + if trimmed != "" && trimmed != "---" { + break + } + *lines = (*lines)[1:] + } + for len(*lines) > 0 { + trimmed := strings.TrimSpace((*lines)[len(*lines)-1]) + if trimmed != "" && trimmed != "---" { + break + } + *lines = (*lines)[:len(*lines)-1] + } +} + +func firstNonEmptyLine(lines []string) int { + for i, line := range lines { + if strings.TrimSpace(line) != "" { + return i + } + } + return -1 +} + +func findHeadingLines(lines []string) []int { + indexes := []int{} + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "## ") { + indexes = append(indexes, i) + } + } + return indexes +} + +func parseError(path string, line int, format string, args ...any) error { + if line < 1 { + line = 1 + } + return fmt.Errorf("%s:%d: %s", path, line, fmt.Sprintf(format, args...)) +} diff --git a/pdfexport/parse_test.go b/pdfexport/parse_test.go new file mode 100644 index 0000000..b1451fa --- /dev/null +++ b/pdfexport/parse_test.go @@ -0,0 +1,134 @@ +package pdfexport + +import ( + "os" + "path/filepath" + "strconv" + "testing" +) + +func TestParseMarkdownNonogram(t *testing.T) { + doc, err := ParseMarkdown("sample.md", sampleNonogramDoc("Standard", 1)) + if err != nil { + t.Fatal(err) + } + + if got, want := doc.Metadata.Category, "Nonogram"; got != want { + t.Fatalf("category = %q, want %q", got, want) + } + if got, want := len(doc.Puzzles), 1; got != want { + t.Fatalf("puzzles = %d, want %d", got, want) + } + + p := doc.Puzzles[0] + nonogram, ok := p.PrintPayload.(*NonogramData) + if !ok || nonogram == nil { + t.Fatal("expected parsed nonogram data") + } + if nonogram.Width != 2 || nonogram.Height != 2 { + t.Fatalf("nonogram size = %dx%d, want 2x2", nonogram.Width, nonogram.Height) + } + + if got, want := nonogram.RowHints[0][0], 1; got != want { + t.Fatalf("first row first hint = %d, want %d", got, want) + } + if got, want := nonogram.ColHints[0][0], 1; got != want { + t.Fatalf("first col first hint = %d, want %d", got, want) + } + + if got, want := nonogram.Grid[0][0], " "; got != want { + t.Fatalf("grid dot replacement = %q, want %q", got, want) + } +} + +func TestParseFilesMultipleInputs(t *testing.T) { + temp := t.TempDir() + + fileA := filepath.Join(temp, "pack-a.md") + if err := os.WriteFile(fileA, []byte(sampleNonogramDoc("Standard", 1)), 0o644); err != nil { + t.Fatal(err) + } + + fileB := filepath.Join(temp, "pack-b.md") + if err := os.WriteFile(fileB, []byte(sampleNonogramDoc("Classic", 2)), 0o644); err != nil { + t.Fatal(err) + } + + docs, err := ParseFiles([]string{fileA, fileB}) + if err != nil { + t.Fatal(err) + } + if got, want := len(docs), 2; got != want { + t.Fatalf("docs = %d, want %d", got, want) + } + + if got, want := docs[0].Metadata.SourceFileName, "pack-a.md"; got != want { + t.Fatalf("first source file = %q, want %q", got, want) + } + if got, want := docs[1].Puzzles[0].ModeSelection, "Classic"; got != want { + t.Fatalf("second mode selection = %q, want %q", got, want) + } +} + +func TestParseMarkdownTakuzuTable(t *testing.T) { + doc, err := ParseMarkdown("takuzu.md", sampleTakuzuDoc()) + if err != nil { + t.Fatal(err) + } + + if got, want := doc.Metadata.Category, "Takuzu"; got != want { + t.Fatalf("category = %q, want %q", got, want) + } + + p := doc.Puzzles[0] + table, ok := p.PrintPayload.(*GridTable) + if !ok || table == nil { + t.Fatal("expected parsed grid table for takuzu") + } + if !table.HasHeaderRow { + t.Fatal("expected takuzu table to detect a header row") + } + if !table.HasHeaderCol { + t.Fatal("expected takuzu table to detect a header column") + } + + if got, want := table.Rows[1][1], "."; got != want { + t.Fatalf("table cell = %q, want %q", got, want) + } +} + +func sampleNonogramDoc(mode string, idx int) string { + return "# PuzzleTea Export\n\n" + + "- Generated: 2026-02-21T20:42:05-07:00\n" + + "- Version: v1.6.0\n" + + "- Category: Nonogram\n" + + "- Mode Selection: " + mode + "\n" + + "- Count: 1\n" + + "- Seed: zine\n\n" + + "## ember-newt - " + strconv.Itoa(idx) + "\n\n" + + "### Puzzle Grid with Integrated Hints\n\n" + + "| R1 | R2 | C1 | C2 |\n" + + "| --- | --- | --- | --- |\n" + + "| . | . | 1 | 2 |\n" + + "| . | . | 3 | 4 |\n" + + "| 1 | 1 | . | . |\n" + + "| . | 2 | . | . |\n\n" + + "Row hints are right-aligned beside each row.\n" +} + +func sampleTakuzuDoc() string { + return "# PuzzleTea Export\n\n" + + "- Generated: 2026-02-21T20:42:05-07:00\n" + + "- Version: v1.6.0\n" + + "- Category: Takuzu\n" + + "- Mode Selection: Beginner\n" + + "- Count: 1\n" + + "- Seed: zine\n\n" + + "## scarlet-lichen - 1\n\n" + + "### Given Grid\n\n" + + "| | 1 | 2 | 3 |\n" + + "| --- | --- | --- | --- |\n" + + "| 1 | . | 0 | . |\n" + + "| 2 | 1 | . | 0 |\n\n" + + "Goal: fill with 0/1.\n" +} diff --git a/pdfexport/printdata.go b/pdfexport/printdata.go new file mode 100644 index 0000000..91a12e1 --- /dev/null +++ b/pdfexport/printdata.go @@ -0,0 +1,569 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +type nurikabeSave struct { + Width int `json:"width"` + Height int `json:"height"` + Clues string `json:"clues"` +} + +type nonogramSave struct { + State string `json:"state"` + Width int `json:"width"` + Height int `json:"height"` + RowHints [][]int `json:"row-hints"` + ColHints [][]int `json:"col-hints"` +} + +type hashiSave struct { + Width int `json:"width"` + Height int `json:"height"` + Islands []hashiIsland `json:"islands"` +} + +type hashiIsland struct { + X int `json:"x"` + Y int `json:"y"` + Required int `json:"required"` +} + +type shikakuSave struct { + Width int `json:"width"` + Height int `json:"height"` + Clues []shikakuClue `json:"clues"` +} + +type shikakuClue struct { + X int `json:"x"` + Y int `json:"y"` + Value int `json:"value"` +} + +type hitoriSave struct { + Size int `json:"size"` + Numbers string `json:"numbers"` +} + +type takuzuSave struct { + Size int `json:"size"` + State string `json:"state"` + Provided string `json:"provided"` +} + +type sudokuSave struct { + Provided []sudokuCell `json:"provided"` +} + +type sudokuCell struct { + X int `json:"x"` + Y int `json:"y"` + V int `json:"v"` +} + +type wordSearchSave struct { + Width int `json:"width"` + Height int `json:"height"` + Grid string `json:"grid"` + Words []wordSearchWord `json:"words"` +} + +type wordSearchWord struct { + Text string `json:"text"` +} + +func ParseNonogramPrintData(saveData []byte) (*NonogramData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save nonogramSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode nonogram save: %w", err) + } + + stateRows := splitNonogramStateRows(save.State) + + width := save.Width + if width <= 0 { + width = len(save.ColHints) + } + if width <= 0 { + width = maxRuneWidth(stateRows) + } + + height := save.Height + if height <= 0 { + height = len(save.RowHints) + } + if height <= 0 { + height = len(stateRows) + } + + if width <= 0 || height <= 0 { + return nil, nil + } + + return &NonogramData{ + Width: width, + Height: height, + RowHints: normalizeNonogramHintRows(save.RowHints, height), + ColHints: normalizeNonogramHintRows(save.ColHints, width), + Grid: normalizeNonogramStateGrid(stateRows, width, height), + }, nil +} + +func ParseNurikabePrintData(saveData []byte) (*NurikabeData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save nurikabeSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode nurikabe save: %w", err) + } + + width := save.Width + height := save.Height + if width <= 0 || height <= 0 { + return nil, nil + } + + clues, err := parseNurikabeClues(save.Clues, width, height) + if err != nil { + return nil, err + } + + return &NurikabeData{ + Width: width, + Height: height, + Clues: clues, + }, nil +} + +func ParseShikakuPrintData(saveData []byte) (*ShikakuData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save shikakuSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode shikaku save: %w", err) + } + if save.Width <= 0 || save.Height <= 0 { + return nil, nil + } + + clues := make([][]int, save.Height) + for y := 0; y < save.Height; y++ { + clues[y] = make([]int, save.Width) + } + + for _, clue := range save.Clues { + if clue.X < 0 || clue.X >= save.Width || clue.Y < 0 || clue.Y >= save.Height { + continue + } + if clue.Value <= 0 { + continue + } + clues[clue.Y][clue.X] = clue.Value + } + + return &ShikakuData{ + Width: save.Width, + Height: save.Height, + Clues: clues, + }, nil +} + +func ParseHashiPrintData(saveData []byte) (*HashiData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save hashiSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode hashiwokakero save: %w", err) + } + if save.Width <= 0 || save.Height <= 0 { + return nil, nil + } + + islands := make([]HashiIsland, 0, len(save.Islands)) + for _, island := range save.Islands { + if island.X < 0 || island.X >= save.Width || island.Y < 0 || island.Y >= save.Height { + continue + } + if island.Required <= 0 { + continue + } + islands = append(islands, HashiIsland(island)) + } + + return &HashiData{ + Width: save.Width, + Height: save.Height, + Islands: islands, + }, nil +} + +func ParseHitoriPrintData(saveData []byte) (*HitoriData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save hitoriSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode hitori save: %w", err) + } + + rows := splitNormalizedLines(save.Numbers) + size := save.Size + if size <= 0 { + size = len(rows) + } + if size <= 0 { + return nil, nil + } + + numbers := make([][]string, size) + for y := 0; y < size; y++ { + numbers[y] = make([]string, size) + if y >= len(rows) { + continue + } + + rowValues := parseHitoriRowValues(rows[y]) + for x := 0; x < size && x < len(rowValues); x++ { + numbers[y][x] = rowValues[x] + } + } + + return &HitoriData{ + Size: size, + Numbers: numbers, + }, nil +} + +func ParseTakuzuPrintData(saveData []byte) (*TakuzuData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save takuzuSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode takuzu save: %w", err) + } + + stateRows := splitNormalizedLines(save.State) + providedRows := splitNormalizedLines(save.Provided) + + size := save.Size + if size <= 0 { + size = max(len(stateRows), len(providedRows)) + } + if size <= 0 { + return nil, nil + } + + givens := make([][]string, size) + for y := 0; y < size; y++ { + givens[y] = make([]string, size) + + var stateRunes []rune + if y < len(stateRows) { + stateRunes = []rune(stateRows[y]) + } + + var providedRunes []rune + if y < len(providedRows) { + providedRunes = []rune(providedRows[y]) + } + + for x := 0; x < size; x++ { + if x >= len(providedRunes) || providedRunes[x] != '#' { + continue + } + if x >= len(stateRunes) { + continue + } + if stateRunes[x] != '0' && stateRunes[x] != '1' { + continue + } + givens[y][x] = string(stateRunes[x]) + } + } + + return &TakuzuData{ + Size: size, + Givens: givens, + GroupEveryTwo: true, + }, nil +} + +func ParseSudokuPrintData(saveData []byte) (*SudokuData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save sudokuSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode sudoku save: %w", err) + } + + var givens [9][9]int + for _, cell := range save.Provided { + if !isSudokuCellInBounds(cell.X, cell.Y) { + continue + } + if cell.V < 1 || cell.V > 9 { + continue + } + givens[cell.Y][cell.X] = cell.V + } + + return &SudokuData{Givens: givens}, nil +} + +func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save wordSearchSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode word search save: %w", err) + } + + rows := strings.Split(strings.ReplaceAll(strings.ReplaceAll(save.Grid, "\r\n", "\n"), "\r", "\n"), "\n") + if len(rows) == 1 && rows[0] == "" { + rows = nil + } + + width := save.Width + for _, row := range rows { + if n := len([]rune(row)); n > width { + width = n + } + } + + height := save.Height + if len(rows) > height { + height = len(rows) + } + if width <= 0 || height <= 0 { + return nil, nil + } + + grid := make([][]string, height) + for y := 0; y < height; y++ { + grid[y] = make([]string, width) + runes := []rune{} + if y < len(rows) { + runes = []rune(rows[y]) + } + for x := 0; x < width; x++ { + grid[y][x] = " " + if x >= len(runes) { + continue + } + r := runes[x] + if unicode.IsSpace(r) { + continue + } + grid[y][x] = string(unicode.ToUpper(r)) + } + } + + words := make([]string, 0, len(save.Words)) + for _, word := range save.Words { + text := strings.ToUpper(strings.TrimSpace(word.Text)) + if text == "" { + continue + } + words = append(words, text) + } + + return &WordSearchData{ + Width: width, + Height: height, + Grid: grid, + Words: words, + }, nil +} + +func isSudokuCellInBounds(x, y int) bool { + return x >= 0 && x < 9 && y >= 0 && y < 9 +} + +func splitNormalizedLines(raw string) []string { + normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") + if strings.TrimSpace(normalized) == "" { + return nil + } + return strings.Split(normalized, "\n") +} + +func parseHitoriRowValues(row string) []string { + row = strings.TrimSpace(row) + if row == "" { + return nil + } + + if strings.Contains(row, " ") || strings.Contains(row, ",") { + fields := strings.Fields(strings.ReplaceAll(row, ",", " ")) + if len(fields) > 1 { + values := make([]string, len(fields)) + for i, field := range fields { + values[i] = normalizeHitoriToken(field) + } + return values + } + } + + runes := []rune(row) + values := make([]string, len(runes)) + for i, r := range runes { + values[i] = normalizeHitoriRune(r) + } + return values +} + +func normalizeHitoriToken(token string) string { + token = strings.TrimSpace(token) + if token == "" || token == "." { + return "" + } + if utf8.RuneCountInString(token) == 1 { + r, _ := utf8.DecodeRuneInString(token) + return normalizeHitoriRune(r) + } + return token +} + +func normalizeHitoriRune(r rune) string { + switch { + case r == '.': + return "" + case r >= '0' && r <= '9': + return string(r) + default: + value := int(r - '0') + if value >= 10 && value <= 35 { + return fmt.Sprintf("%d", value) + } + return string(r) + } +} + +func parseNurikabeClues(raw string, width, height int) ([][]int, error) { + if width <= 0 || height <= 0 { + return nil, fmt.Errorf("invalid clue dimensions: %dx%d", width, height) + } + + clues := make([][]int, height) + for y := 0; y < height; y++ { + clues[y] = make([]int, width) + } + + rows := splitNormalizedLines(raw) + for y := 0; y < len(rows) && y < height; y++ { + parts := strings.Split(rows[y], ",") + for x := 0; x < len(parts) && x < width; x++ { + token := strings.TrimSpace(parts[x]) + if token == "" { + continue + } + value, err := strconv.Atoi(token) + if err != nil { + return nil, fmt.Errorf("invalid clue value %q at (%d,%d): %w", token, x, y, err) + } + if value < 0 { + return nil, fmt.Errorf("negative clue value %d at (%d,%d)", value, x, y) + } + clues[y][x] = value + } + } + + return clues, nil +} + +func splitNonogramStateRows(raw string) []string { + normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") + if normalized == "" { + return nil + } + return strings.Split(normalized, "\n") +} + +func maxRuneWidth(rows []string) int { + maxWidth := 0 + for _, row := range rows { + if n := len([]rune(row)); n > maxWidth { + maxWidth = n + } + } + return maxWidth +} + +func normalizeNonogramHintRows(src [][]int, size int) [][]int { + if size <= 0 { + return nil + } + + normalized := make([][]int, size) + for i := 0; i < size; i++ { + if i >= len(src) { + normalized[i] = []int{0} + continue + } + + filtered := make([]int, 0, len(src[i])) + for _, value := range src[i] { + if value > 0 { + filtered = append(filtered, value) + } + } + if len(filtered) == 0 { + filtered = []int{0} + } + normalized[i] = filtered + } + + return normalized +} + +func normalizeNonogramStateGrid(rows []string, width, height int) [][]string { + if width <= 0 || height <= 0 { + return nil + } + + grid := make([][]string, height) + for y := 0; y < height; y++ { + grid[y] = make([]string, width) + for x := 0; x < width; x++ { + grid[y][x] = " " + } + + if y >= len(rows) { + continue + } + + runes := []rune(rows[y]) + for x := 0; x < width && x < len(runes); x++ { + if runes[x] == ' ' { + continue + } + grid[y][x] = string(runes[x]) + } + } + + return grid +} diff --git a/pdfexport/printdata_test.go b/pdfexport/printdata_test.go new file mode 100644 index 0000000..f6caf81 --- /dev/null +++ b/pdfexport/printdata_test.go @@ -0,0 +1,183 @@ +package pdfexport + +import "testing" + +func TestParseNonogramPrintData(t *testing.T) { + save := []byte(`{"state":" .-\n.. ","row-hints":[[1],[2]],"col-hints":[[2],[1],[1]]}`) + + data, err := ParseNonogramPrintData(save) + if err != nil { + t.Fatal(err) + } + if data == nil { + t.Fatal("expected nonogram print data") + } + if got, want := data.Width, 3; got != want { + t.Fatalf("width = %d, want %d", got, want) + } + if got, want := data.Height, 2; got != want { + t.Fatalf("height = %d, want %d", got, want) + } + if got, want := data.RowHints[0][0], 1; got != want { + t.Fatalf("row hint[0][0] = %d, want %d", got, want) + } + if got, want := data.ColHints[0][0], 2; got != want { + t.Fatalf("col hint[0][0] = %d, want %d", got, want) + } + if got, want := data.Grid[0][0], " "; got != want { + t.Fatalf("grid[0][0] = %q, want %q", got, want) + } + if got, want := data.Grid[0][1], "."; got != want { + t.Fatalf("grid[0][1] = %q, want %q", got, want) + } + if got, want := data.Grid[0][2], "-"; got != want { + t.Fatalf("grid[0][2] = %q, want %q", got, want) + } +} + +func TestParseNurikabePrintData(t *testing.T) { + save := []byte(`{"width":3,"height":2,"clues":"1,0,2\n0,3,0","marks":"???\n???"}`) + + data, err := ParseNurikabePrintData(save) + if err != nil { + t.Fatal(err) + } + if data == nil { + t.Fatal("expected nurikabe print data") + } + if got, want := data.Width, 3; got != want { + t.Fatalf("width = %d, want %d", got, want) + } + if got, want := data.Height, 2; got != want { + t.Fatalf("height = %d, want %d", got, want) + } + if got, want := data.Clues[0][0], 1; got != want { + t.Fatalf("row 0 col 0 = %d, want %d", got, want) + } + if got, want := data.Clues[0][2], 2; got != want { + t.Fatalf("row 0 col 2 = %d, want %d", got, want) + } + if got, want := data.Clues[1][1], 3; got != want { + t.Fatalf("row 1 col 1 = %d, want %d", got, want) + } +} + +func TestParseHashiPrintData(t *testing.T) { + 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}]}`) + + data, err := ParseHashiPrintData(save) + if err != nil { + t.Fatal(err) + } + if data == nil { + t.Fatal("expected hashi print data") + } + if got, want := data.Width, 7; got != want { + t.Fatalf("width = %d, want %d", got, want) + } + if got, want := data.Height, 7; got != want { + t.Fatalf("height = %d, want %d", got, want) + } + if got, want := len(data.Islands), 2; got != want { + t.Fatalf("island count = %d, want %d", got, want) + } + if got, want := data.Islands[0].Required, 3; got != want { + t.Fatalf("island[0].required = %d, want %d", got, want) + } +} + +func TestParseShikakuPrintDataDuplicateClueCoordinatesUseLatest(t *testing.T) { + 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}]}`) + + data, err := ParseShikakuPrintData(save) + if err != nil { + t.Fatal(err) + } + if data == nil { + t.Fatal("expected shikaku print data") + } + if got, want := data.Clues[1][1], 5; got != want { + t.Fatalf("row 1 col 1 = %d, want %d", got, want) + } + if got, want := data.Clues[0][2], 4; got != want { + t.Fatalf("row 0 col 2 = %d, want %d", got, want) + } +} + +func TestParseHitoriPrintData(t *testing.T) { + save := []byte(`{"size":4,"numbers":"1 2 10\n4 . .\n7\n"}`) + + data, err := ParseHitoriPrintData(save) + if err != nil { + t.Fatal(err) + } + if data == nil { + t.Fatal("expected hitori print data") + } + if got, want := data.Size, 4; got != want { + t.Fatalf("size = %d, want %d", got, want) + } + + if got, want := data.Numbers[0][2], "10"; got != want { + t.Fatalf("row 0 col 2 = %q, want %q", got, want) + } + if got, want := data.Numbers[1][1], ""; got != want { + t.Fatalf("row 1 col 1 = %q, want empty", got) + } + if got, want := data.Numbers[2][0], "7"; got != want { + t.Fatalf("row 2 col 0 = %q, want %q", got, want) + } + if got, want := data.Numbers[3][3], ""; got != want { + t.Fatalf("row 3 col 3 = %q, want empty", got) + } +} + +func TestParseHitoriPrintDataCompactRuneEncoding(t *testing.T) { + save := []byte(`{"size":3,"numbers":"12:\n4..\n789"}`) + + data, err := ParseHitoriPrintData(save) + if err != nil { + t.Fatal(err) + } + if data == nil { + t.Fatal("expected hitori print data") + } + + if got, want := data.Numbers[0][2], "10"; got != want { + t.Fatalf("row 0 col 2 = %q, want %q", got, want) + } + if got, want := data.Numbers[1][1], ""; got != want { + t.Fatalf("row 1 col 1 = %q, want empty", got) + } +} + +func TestParseTakuzuPrintData(t *testing.T) { + save := []byte(`{"size":4,"state":"01..\n10..\n0011\n1111","provided":"#.\n.##\n####\n#"}`) + + data, err := ParseTakuzuPrintData(save) + if err != nil { + t.Fatal(err) + } + if data == nil { + t.Fatal("expected takuzu print data") + } + if got, want := data.Size, 4; got != want { + t.Fatalf("size = %d, want %d", got, want) + } + if !data.GroupEveryTwo { + t.Fatal("expected GroupEveryTwo to be true") + } + + if got, want := data.Givens[0][0], "0"; got != want { + t.Fatalf("row 0 col 0 = %q, want %q", got, want) + } + if got, want := data.Givens[1][2], ""; got != want { + t.Fatalf("row 1 col 2 = %q, want empty", got) + } + if got, want := data.Givens[2][3], "1"; got != want { + t.Fatalf("row 2 col 3 = %q, want %q", got, want) + } + if got, want := data.Givens[3][1], ""; got != want { + t.Fatalf("row 3 col 1 = %q, want empty", got) + } +} diff --git a/pdfexport/render.go b/pdfexport/render.go new file mode 100644 index 0000000..d4a32c0 --- /dev/null +++ b/pdfexport/render.go @@ -0,0 +1,169 @@ +package pdfexport + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" +) + +func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) error { + if strings.TrimSpace(outputPath) == "" { + return fmt.Errorf("output path is required") + } + + printablePuzzles := filterPrintablePuzzles(puzzles) + if len(printablePuzzles) == 0 { + return nil + } + + if cfg.GeneratedAt.IsZero() { + cfg.GeneratedAt = time.Now() + } + if cfg.VolumeNumber < 1 { + cfg.VolumeNumber = 1 + } + if strings.TrimSpace(cfg.CoverSubtitle) == "" { + cfg.CoverSubtitle = defaultTitle(docs) + } + if strings.TrimSpace(cfg.Title) == "" { + cfg.Title = fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber) + } + if strings.TrimSpace(cfg.AdvertText) == "" { + cfg.AdvertText = "Find more puzzles at github.com/FelineStateMachine/puzzletea" + } + + pdf := fpdf.NewCustom(&fpdf.InitType{ + OrientationStr: "P", + UnitStr: "mm", + Size: fpdf.SizeType{ + Wd: halfLetterWidthMM, + Ht: halfLetterHeightMM, + }, + }) + if err := registerPDFFonts(pdf); err != nil { + return err + } + pdf.SetAutoPageBreak(false, 0) + pdf.SetCreator("PuzzleTea", true) + pdf.SetAuthor("PuzzleTea", true) + pdf.SetTitle(cfg.Title, true) + footerExcludedPages := map[int]struct{}{} + pdf.SetFooterFunc(func() { + pageNo := pdf.PageNo() + if _, skip := footerExcludedPages[pageNo]; skip { + return + } + pdf.SetY(-8) + pdf.SetFont(sansFontFamily, "", 8) + pdf.SetTextColor(footerTextGray, footerTextGray, footerTextGray) + pdf.CellFormat(0, 4, strconv.Itoa(pageNo), "", 0, "C", false, 0, "") + }) + + includeCover := cfg.CoverColor != nil + if includeCover { + renderCoverPage(pdf, printablePuzzles, cfg, *cfg.CoverColor) + footerExcludedPages[pdf.PageNo()] = struct{}{} + } + + renderTitlePage(pdf, docs, printablePuzzles, cfg) + footerExcludedPages[pdf.PageNo()] = struct{}{} + + for _, puzzle := range printablePuzzles { + if err := renderPuzzlePage(pdf, puzzle); err != nil { + return fmt.Errorf("render puzzle %q (%s #%d): %w", puzzle.Name, puzzle.Category, puzzle.Index, err) + } + } + + totalPagesWithoutPadding := pdf.PageNo() + if includeCover { + totalPagesWithoutPadding++ // include upcoming back cover + } + for range saddleStitchPadCount(totalPagesWithoutPadding) { + renderPadPage(pdf) + footerExcludedPages[pdf.PageNo()] = struct{}{} + } + + if includeCover { + renderBackCoverPage(pdf, cfg, *cfg.CoverColor) + footerExcludedPages[pdf.PageNo()] = struct{}{} + } + + dir := filepath.Dir(outputPath) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + } + + if err := pdf.OutputFileAndClose(outputPath); err != nil { + return fmt.Errorf("write pdf file: %w", err) + } + return nil +} + +func saddleStitchPadCount(totalPages int) int { + if totalPages <= 0 { + return 0 + } + + remainder := totalPages % 4 + if remainder == 0 { + return 0 + } + return 4 - remainder +} + +func filterPrintablePuzzles(puzzles []Puzzle) []Puzzle { + printable := make([]Puzzle, 0, len(puzzles)) + for _, puzzle := range puzzles { + if game.IsNilPrintPayload(puzzle.PrintPayload) { + continue + } + if _, ok := game.LookupPrintAdapter(puzzle.Category); !ok { + continue + } + printable = append(printable, puzzle) + } + return printable +} + +func renderPadPage(pdf *fpdf.Fpdf) { + pdf.AddPage() +} + +func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error { + if game.IsNilPrintPayload(puzzle.PrintPayload) { + return nil + } + adapter, ok := game.LookupPrintAdapter(puzzle.Category) + if !ok { + return nil + } + + pdf.AddPage() + pageW, _ := pdf.GetPageSize() + + setPuzzleTitleStyle(pdf) + pdf.SetXY(0, 10) + title := fmt.Sprintf("%s %d: %s", puzzle.Category, puzzle.Index, puzzle.Name) + pdf.CellFormat(pageW, 7, title, "", 0, "C", false, 0, "") + + setPuzzleSubtitleStyle(pdf) + pdf.SetXY(0, 17) + subtitleParts := []string{fmt.Sprintf("Difficulty Score: %d/10", difficultyScoreOutOfTen(puzzle.DifficultyScore))} + if !isMixedModes(puzzle.ModeSelection) { + subtitleParts = append([]string{fmt.Sprintf("Mode: %s", puzzle.ModeSelection)}, subtitleParts...) + } + subtitle := strings.Join(subtitleParts, " | ") + pdf.CellFormat(pageW, 5, subtitle, "", 0, "C", false, 0, "") + if err := adapter.RenderPDFBody(pdf, puzzle.PrintPayload); err != nil { + return err + } + return nil +} diff --git a/pdfexport/render_cover.go b/pdfexport/render_cover.go new file mode 100644 index 0000000..0190c47 --- /dev/null +++ b/pdfexport/render_cover.go @@ -0,0 +1,148 @@ +package pdfexport + +import ( + "fmt" + "strings" + + "codeberg.org/go-pdf/fpdf" +) + +func splitCoverTextLines(pdf *fpdf.Fpdf, text string, maxW float64) []string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return []string{"PuzzleTea Collection"} + } + chunks := pdf.SplitLines([]byte(trimmed), maxW) + if len(chunks) == 0 { + return []string{trimmed} + } + lines := make([]string, 0, len(chunks)) + for _, c := range chunks { + lines = append(lines, string(c)) + } + return lines +} + +func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxLines int) []string { + if maxLines < 1 { + maxLines = 1 + } + lines := splitCoverTextLines(pdf, subtitle, maxW) + if len(lines) <= maxLines { + return lines + } + + out := make([]string, 0, maxLines) + out = append(out, lines[:maxLines-1]...) + out = append(out, strings.Join(lines[maxLines-1:], " ")) + return out +} + +func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig, coverColor RGB) { + ink := RGB{R: 8, G: 8, B: 8} + + pdf.AddPage() + pageW, pageH := pdf.GetPageSize() + + pdf.SetFillColor(int(coverColor.R), int(coverColor.G), int(coverColor.B)) + pdf.Rect(0, 0, pageW, pageH, "F") + + frameInset := 7.5 + drawCoverFrame(pdf, frameInset, pageW, pageH, ink) + + scene := rectMM{x: frameInset + 4.0, y: frameInset + 10.0, w: pageW - (frameInset+4.0)*2, h: 132.0} + drawCoverArtwork(pdf, scene, cfg.ShuffleSeed, coverColor, ink) + + subtitle := strings.TrimSpace(cfg.CoverSubtitle) + if subtitle == "" { + subtitle = "PuzzleTea Collection" + } + + labelW := pageW - 2*(frameInset+6.0) + fontSize := 20.0 + for fontSize >= 13.0 { + pdf.SetFont(coverFontFamily, "", fontSize) + if len(splitCoverTextLines(pdf, subtitle, labelW)) <= 2 { + break + } + fontSize -= 1.0 + } + + pdf.SetFont(sansFontFamily, "B", 9.8) + pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetXY(frameInset+6.0, frameInset+2.8) + pdf.CellFormat(labelW, 5.0, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "") + + pdf.SetFont(coverFontFamily, "", fontSize) + titleLines := splitCoverSubtitleLines(pdf, subtitle, labelW, 2) + lineH := fontSize * 0.45 + y := scene.y + scene.h + 8.5 + for _, line := range titleLines { + pdf.SetXY(frameInset+6.0, y) + pdf.CellFormat(labelW, lineH, line, "", 0, "L", false, 0, "") + y += lineH + } + + pdf.SetFont(sansFontFamily, "B", 7.8) + pdf.SetXY(frameInset+6.0, pageH-frameInset-7.0) + pdf.CellFormat(labelW, 4.0, "PuzzleTea", "", 0, "L", false, 0, "") +} + +func drawCoverFrame(pdf *fpdf.Fpdf, inset, pageW, pageH float64, ink RGB) { + pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetLineWidth(1.1) + pdf.Rect(inset, inset, pageW-2*inset, pageH-2*inset, "D") + + pdf.SetLineWidth(0.28) + inner := inset + 1.7 + pdf.Rect(inner, inner, pageW-2*inner, pageH-2*inner, "D") +} + +func drawCoverArtwork(pdf *fpdf.Fpdf, scene rectMM, seed string, bg, ink RGB) { + drawCoverArtworkImage(pdf, scene, seed, "front", bg) + + pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetLineWidth(0.40) + pdf.Rect(scene.x, scene.y, scene.w, scene.h, "D") + + pdf.SetLineWidth(0.20) + inset := 1.8 + pdf.Rect(scene.x+inset, scene.y+inset, scene.w-2*inset, scene.h-2*inset, "D") +} + +func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) { + ink := RGB{R: 8, G: 8, B: 8} + + pdf.AddPage() + pageW, pageH := pdf.GetPageSize() + pdf.SetFillColor(int(coverColor.R), int(coverColor.G), int(coverColor.B)) + pdf.Rect(0, 0, pageW, pageH, "F") + + frameInset := 7.5 + drawCoverFrame(pdf, frameInset, pageW, pageH, ink) + + motif := rectMM{x: frameInset + 5.5, y: frameInset + 14.0, w: pageW - 2*(frameInset+5.5), h: 96.0} + drawBackMotif(pdf, motif, cfg.ShuffleSeed, coverColor, ink) + + labelW := pageW - 2*(frameInset+6.0) + pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetFont(sansFontFamily, "B", 8.4) + pdf.SetXY(frameInset+6.0, pageH-frameInset-23.0) + pdf.CellFormat(labelW, 4.2, "PuzzleTea", "", 0, "L", false, 0, "") + + pdf.SetFont(sansFontFamily, "", 8.0) + pdf.SetXY(frameInset+6.0, pageH-frameInset-17.0) + pdf.CellFormat(labelW, 4.2, cfg.AdvertText, "", 0, "L", false, 0, "") + + pdf.SetFont(sansFontFamily, "B", 8.2) + pdf.SetXY(frameInset+6.0, pageH-frameInset-10.5) + pdf.CellFormat(labelW, 4.2, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "") +} + +func drawBackMotif(pdf *fpdf.Fpdf, scene rectMM, seed string, bg, ink RGB) { + drawCoverArtworkImage(pdf, scene, seed, "back", bg) + + pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetLineWidth(0.34) + pdf.Rect(scene.x, scene.y, scene.w, scene.h, "D") +} diff --git a/pdfexport/render_cover_test.go b/pdfexport/render_cover_test.go new file mode 100644 index 0000000..10114f2 --- /dev/null +++ b/pdfexport/render_cover_test.go @@ -0,0 +1,29 @@ +package pdfexport + +import ( + "reflect" + "testing" + + "codeberg.org/go-pdf/fpdf" +) + +func TestSplitCoverSubtitleLinesClampsToMaxLines(t *testing.T) { + pdf := fpdf.New("P", "mm", "A4", "") + if err := registerPDFFonts(pdf); err != nil { + t.Fatalf("registerPDFFonts error: %v", err) + } + pdf.AddPage() + pdf.SetFont(coverFontFamily, "", 18) + + subtitle := "The Catacombs of the Last Bastion and Other Grim Architectural Delights" + maxW := 78.0 + got := splitCoverSubtitleLines(pdf, subtitle, maxW, 2) + if len(got) != 2 { + t.Fatalf("line count = %d, want 2 (%v)", len(got), got) + } + + gotAgain := splitCoverSubtitleLines(pdf, subtitle, maxW, 2) + if !reflect.DeepEqual(got, gotAgain) { + t.Fatalf("splitCoverSubtitleLines not stable: %v vs %v", got, gotAgain) + } +} diff --git a/pdfexport/render_error_test.go b/pdfexport/render_error_test.go new file mode 100644 index 0000000..3d6d25d --- /dev/null +++ b/pdfexport/render_error_test.go @@ -0,0 +1,48 @@ +package pdfexport + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" +) + +func TestWritePDFReturnsAdapterRenderError(t *testing.T) { + const category = "Render Error Test" + game.RegisterPrintAdapter(renderErrorTestAdapter{category: category}) + + output := filepath.Join(t.TempDir(), "error.pdf") + puzzles := []Puzzle{ + { + Category: category, + Name: "broken-page", + Index: 1, + PrintPayload: struct{}{}, + }, + } + + err := WritePDF(output, nil, puzzles, RenderConfig{VolumeNumber: 1}) + if err == nil { + t.Fatal("expected adapter render error") + } + if !strings.Contains(err.Error(), "failed to render body") { + t.Fatalf("error = %q, want render failure context", err.Error()) + } + if !strings.Contains(err.Error(), "broken-page") { + t.Fatalf("error = %q, want puzzle name context", err.Error()) + } +} + +type renderErrorTestAdapter struct { + category string +} + +func (a renderErrorTestAdapter) CanonicalGameType() string { return a.category } +func (a renderErrorTestAdapter) Aliases() []string { return nil } +func (a renderErrorTestAdapter) BuildPDFPayload([]byte) (any, error) { return nil, nil } +func (a renderErrorTestAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { + return errors.New("failed to render body") +} diff --git a/pdfexport/render_kit.go b/pdfexport/render_kit.go new file mode 100644 index 0000000..782d62c --- /dev/null +++ b/pdfexport/render_kit.go @@ -0,0 +1,92 @@ +package pdfexport + +import "codeberg.org/go-pdf/fpdf" + +type Rect struct { + X float64 + Y float64 + W float64 + H float64 +} + +const ( + SansFontFamily = sansFontFamily + + PrimaryTextGray = primaryTextGray + SecondaryTextGray = secondaryTextGray + RuleTextGray = ruleTextGray + DimTextGray = dimTextGray + + ThinGridLineMM = thinGridLineMM + MajorGridLineMM = majorGridLineMM + OuterBorderLineMM = outerBorderLineMM + + InstructionLineHMM = instructionLineHMM + + PuzzleBottomInsetMM = puzzleBottomInsetMM + WordSearchGridGapMM = wordSearchGridListGap + + PuzzleWordBankFontSize = puzzleWordBankFontSize + PuzzleWordBankHeadSize = puzzleWordBankHeadSize +) + +func PuzzleBodyRect(pageW, pageH float64, pageNo int) Rect { + area := puzzleBodyRect(pageW, pageH, pageNo) + return Rect{X: area.x, Y: area.y, W: area.w, H: area.h} +} + +func PuzzleBoardRect(pageW, pageH float64, pageNo, ruleLines int) Rect { + area := puzzleBoardRect(pageW, pageH, pageNo, ruleLines) + return Rect{X: area.x, Y: area.y, W: area.w, H: area.h} +} + +func CenteredOrigin(area Rect, cols, rows int, cellSize float64) (float64, float64) { + internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H} + return centeredOrigin(internal, cols, rows, cellSize) +} + +func InstructionY(boardBottom, pageH float64, lineCount int) float64 { + return instructionY(boardBottom, pageH, lineCount) +} + +func FitCompactCellSize(cols, rows int, area Rect) float64 { + internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H} + return fitBoardCellSize(cols, rows, internal, boardFamilyCompact) +} + +func FitSudokuCellSize(cols, rows int, area Rect) float64 { + internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H} + return fitBoardCellSize(cols, rows, internal, boardFamilySudoku) +} + +func FitHashiCellSize(cols, rows int, area Rect) float64 { + internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H} + return fitBoardCellSize(cols, rows, internal, boardFamilyHashi) +} + +func FitNonogramCellSize(cols, rows int, area Rect) float64 { + internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H} + return fitBoardCellSize(cols, rows, internal, boardFamilyNonogram) +} + +func FitWordSearchCellSize(cols, rows int, area Rect) float64 { + internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H} + return fitBoardCellSize(cols, rows, internal, boardFamilyWordSearch) +} + +func FitTableCellSize(cols, rows int, area Rect) float64 { + internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H} + return fitBoardCellSize(cols, rows, internal, boardFamilyTable) +} + +func StandardCellFontSize(cellSize, scale float64) float64 { + return standardCellFontSize(cellSize, scale) +} + +func ClampStandardCellFontSize(fontSize float64) float64 { + return clampStandardCellFontSize(fontSize) +} + +func SetInstructionStyle(pdf *fpdf.Fpdf) { + setInstructionStyle(pdf) +} diff --git a/pdfexport/render_layout_test.go b/pdfexport/render_layout_test.go new file mode 100644 index 0000000..9a4d51c --- /dev/null +++ b/pdfexport/render_layout_test.go @@ -0,0 +1,143 @@ +package pdfexport + +import ( + "math" + "testing" +) + +func TestThinGridLineFloor(t *testing.T) { + if thinGridLineMM < 0.22 { + t.Fatalf("thinGridLineMM = %.2f, want >= 0.22", thinGridLineMM) + } +} + +func TestCenteredOriginKeepsBoardCentered(t *testing.T) { + pageArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, 2, 1) + tests := []struct { + name string + cols int + rows int + family boardFamily + }{ + {name: "compact", cols: 8, rows: 8, family: boardFamilyCompact}, + {name: "sudoku", cols: 9, rows: 9, family: boardFamilySudoku}, + {name: "hashi-span", cols: 6, rows: 6, family: boardFamilyHashi}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cell := fitBoardCellSize(tt.cols, tt.rows, pageArea, tt.family) + if cell <= 0 { + t.Fatal("expected positive cell size") + } + x, y := centeredOrigin(pageArea, tt.cols, tt.rows, cell) + + centerX := x + float64(tt.cols)*cell/2 + centerY := y + float64(tt.rows)*cell/2 + wantX := pageArea.x + pageArea.w/2 + wantY := pageArea.y + pageArea.h/2 + if diff := math.Abs(centerX - wantX); diff > 0.01 { + t.Fatalf("centerX diff = %.4f, want <= 0.01", diff) + } + if diff := math.Abs(centerY - wantY); diff > 0.01 { + t.Fatalf("centerY diff = %.4f, want <= 0.01", diff) + } + }) + } +} + +func TestPuzzleBodyRectMirrorsGutterByParity(t *testing.T) { + even := puzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 2) + odd := puzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 3) + + if even.w <= 0 || odd.w <= 0 { + t.Fatal("expected positive body widths") + } + if math.Abs(even.w-odd.w) > 0.001 { + t.Fatalf("body widths differ: even=%.3f odd=%.3f", even.w, odd.w) + } + + evenCenterX := even.x + even.w/2 + oddCenterX := odd.x + odd.w/2 + pageCenterX := halfLetterWidthMM / 2 + + if evenCenterX >= pageCenterX { + t.Fatalf("even page center = %.3f, want left of %.3f", evenCenterX, pageCenterX) + } + if oddCenterX <= pageCenterX { + t.Fatalf("odd page center = %.3f, want right of %.3f", oddCenterX, pageCenterX) + } +} + +func TestSaddleStitchPadCount(t *testing.T) { + tests := []struct { + name string + pages int + want int + }{ + {name: "already multiple of four", pages: 36, want: 0}, + {name: "remainder one", pages: 35, want: 1}, + {name: "remainder two", pages: 34, want: 2}, + {name: "remainder three", pages: 33, want: 3}, + {name: "non-positive", pages: 0, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := saddleStitchPadCount(tt.pages) + if got != tt.want { + t.Fatalf("saddleStitchPadCount(%d) = %d, want %d", tt.pages, got, tt.want) + } + }) + } +} + +func TestSaddleStitchPadCountForStandardPackLayout(t *testing.T) { + tests := []struct { + name string + puzzleRows int + wantPad int + }{ + {name: "single puzzle", puzzleRows: 1, wantPad: 0}, + {name: "two puzzles", puzzleRows: 2, wantPad: 3}, + {name: "thirty-two puzzles", puzzleRows: 32, wantPad: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + totalWithoutPad := tt.puzzleRows + 3 // cover + title + back cover + got := saddleStitchPadCount(totalWithoutPad) + if got != tt.wantPad { + t.Fatalf("pad pages = %d, want %d", got, tt.wantPad) + } + if (totalWithoutPad+got)%4 != 0 { + t.Fatalf("total pages = %d, want multiple of 4", totalWithoutPad+got) + } + }) + } +} + +func TestSaddleStitchPadCountForTitleOnlyPackLayout(t *testing.T) { + tests := []struct { + name string + puzzleRows int + wantPad int + }{ + {name: "single puzzle", puzzleRows: 1, wantPad: 2}, + {name: "two puzzles", puzzleRows: 2, wantPad: 1}, + {name: "thirty-two puzzles", puzzleRows: 32, wantPad: 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + totalWithoutPad := tt.puzzleRows + 1 // title page + puzzle pages + got := saddleStitchPadCount(totalWithoutPad) + if got != tt.wantPad { + t.Fatalf("pad pages = %d, want %d", got, tt.wantPad) + } + if (totalWithoutPad+got)%4 != 0 { + t.Fatalf("total pages = %d, want multiple of 4", totalWithoutPad+got) + } + }) + } +} diff --git a/pdfexport/render_metadata.go b/pdfexport/render_metadata.go new file mode 100644 index 0000000..335b55e --- /dev/null +++ b/pdfexport/render_metadata.go @@ -0,0 +1,17 @@ +package pdfexport + +import "math" + +func difficultyScoreOutOfTen(score float64) int { + if score < 0 { + score = 0 + } + if score > 1 { + score = 1 + } + return int(math.Round(score * 10)) +} + +func isMixedModes(mode string) bool { + return normalizeToken(mode) == "mixed modes" +} diff --git a/pdfexport/render_title.go b/pdfexport/render_title.go new file mode 100644 index 0000000..df88c95 --- /dev/null +++ b/pdfexport/render_title.go @@ -0,0 +1,284 @@ +package pdfexport + +import ( + "fmt" + "math" + "sort" + "strconv" + "strings" + + "codeberg.org/go-pdf/fpdf" +) + +func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) { + pdf.AddPage() + pageW, pageH := pdf.GetPageSize() + margin := 12.0 + contentWidth := pageW - 2*margin + + pdf.SetTextColor(20, 20, 20) + pdf.SetFont(sansFontFamily, "B", 22) + pdf.SetXY(0, 24) + pdf.CellFormat(pageW, 10, fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber), "", 0, "C", false, 0, "") + + pdf.SetTextColor(50, 50, 50) + pdf.SetFont(coverFontFamily, "", 16) + pdf.SetXY(0, 35) + pdf.CellFormat(pageW, 8, cfg.CoverSubtitle, "", 0, "C", false, 0, "") + + pdf.SetFont(sansFontFamily, "", 11) + pdf.SetTextColor(70, 70, 70) + pdf.SetXY(0, 44) + pdf.CellFormat(pageW, 6, "PuzzleTea Puzzle Pack", "", 0, "C", false, 0, "") + + versionLine := fmt.Sprintf("PuzzleTea Version: %s", strings.Join(summarizeVersions(docs), ", ")) + pdf.SetFont(sansFontFamily, "", 10) + wrappedVersions := pdf.SplitLines([]byte(versionLine), contentWidth) + if len(wrappedVersions) == 0 { + wrappedVersions = [][]byte{[]byte(versionLine)} + } + + headerLineH := 4.8 + headerGap := 1.2 + headerStartY := 54.8 + metaY := 56.0 + if header := strings.TrimSpace(cfg.HeaderText); header != "" { + pdf.SetFont(sansFontFamily, "", 9.2) + pdf.SetTextColor(74, 74, 74) + wrappedHeader := pdf.SplitLines([]byte(header), contentWidth) + if len(wrappedHeader) == 0 { + wrappedHeader = [][]byte{[]byte(header)} + } + + metaY = headerStartY + float64(len(wrappedHeader))*headerLineH + headerGap + sourceStartY := titlePageSourceTableStartY(metaY, len(wrappedVersions)) + sourceMaxY := pageH - 45 + if spare := titlePageSourceTableWhitespace(sourceMaxY, sourceStartY, len(docs)); spare > 0 { + headerGap += spare + } + + headerY := headerStartY + for _, line := range wrappedHeader { + pdf.SetXY(margin, headerY) + pdf.CellFormat(contentWidth, headerLineH, string(line), "", 0, "C", false, 0, "") + headerY += headerLineH + } + metaY = headerY + headerGap + } + + pdf.SetTextColor(25, 25, 25) + pdf.SetFont(sansFontFamily, "", 10) + pdf.SetXY(margin, metaY) + pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format("January 2, 2006")), "", 0, "L", false, 0, "") + metaY += 6 + for _, line := range wrappedVersions { + pdf.SetXY(margin, metaY) + pdf.CellFormat(contentWidth, 5.2, string(line), "", 0, "L", false, 0, "") + metaY += 5.2 + } + metaY += 0.8 + + pdf.SetXY(margin, metaY) + pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Puzzles: %d", len(puzzles)), "", 0, "L", false, 0, "") + metaY += 6 + metaY += 1.8 + + pdf.SetFont(sansFontFamily, "B", 10) + pdf.SetTextColor(45, 45, 45) + pdf.SetXY(margin, metaY) + pdf.CellFormat(contentWidth, 6, "Source Exports", "", 0, "L", false, 0, "") + metaY += 7 + + renderSourceExportsTable(pdf, docs, margin, metaY, contentWidth, pageH-45) + + pdf.SetTextColor(50, 50, 50) + pdf.SetFont(sansFontFamily, "B", 12) + pdf.SetXY(margin, pageH-30) + pdf.CellFormat(contentWidth, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "") + + pdf.SetFont(sansFontFamily, "", 10) + pdf.SetXY(margin, pageH-22) + pdf.CellFormat(contentWidth, 6, cfg.AdvertText, "", 0, "C", false, 0, "") +} + +func titlePageSourceTableStartY(metaY float64, versionLineCount int) float64 { + y := metaY + y += 6 + y += float64(max(versionLineCount, 1)) * 5.2 + y += 0.8 + y += 6 + y += 1.8 + y += 7 + return y +} + +func titlePageSourceTableWhitespace(maxY, sourceStartY float64, docCount int) float64 { + const ( + headerHeight = 5.2 + rowHeight = 4.8 + ) + + availableRowsHeight := maxY - sourceStartY - headerHeight + if availableRowsHeight < rowHeight { + return 0 + } + + maxRows := int(math.Floor(availableRowsHeight / rowHeight)) + if maxRows < 1 { + return 0 + } + + rowCount := min(docCount, maxRows) + used := headerHeight + float64(rowCount)*rowHeight + if spare := maxY - sourceStartY - used; spare > 0 { + return spare + } + return 0 +} + +func summarizeVersions(docs []PackDocument) []string { + set := map[string]struct{}{} + for _, doc := range docs { + version := strings.TrimSpace(doc.Metadata.Version) + if version == "" { + continue + } + set[version] = struct{}{} + } + + versions := make([]string, 0, len(set)) + for version := range set { + versions = append(versions, version) + } + sort.Strings(versions) + if len(versions) == 0 { + return []string{"Unknown"} + } + return versions +} + +func defaultTitle(docs []PackDocument) string { + if len(docs) == 1 { + category := strings.TrimSpace(docs[0].Metadata.Category) + if category != "" { + return fmt.Sprintf("%s Puzzle Pack", category) + } + } + return "PuzzleTea Mixed Puzzle Pack" +} + +func renderSourceExportsTable( + pdf *fpdf.Fpdf, + docs []PackDocument, + x, y, width, maxY float64, +) float64 { + if width <= 0 || y >= maxY { + return y + } + + headers := []string{"Source", "Category", "Mode", "Count", "Seed"} + columnRatios := []float64{0.33, 0.20, 0.22, 0.10, 0.15} + columnWidths := make([]float64, len(columnRatios)) + usedWidth := 0.0 + for i := 0; i < len(columnRatios)-1; i++ { + columnWidths[i] = width * columnRatios[i] + usedWidth += columnWidths[i] + } + columnWidths[len(columnWidths)-1] = width - usedWidth + + headerHeight := 5.2 + rowHeight := 4.8 + availableRowsHeight := maxY - y - headerHeight + if availableRowsHeight < rowHeight { + return y + } + maxRows := int(math.Floor(availableRowsHeight / rowHeight)) + if maxRows < 1 { + return y + } + + rowCount := len(docs) + if rowCount > maxRows { + rowCount = maxRows + } + + pdf.SetDrawColor(125, 125, 125) + pdf.SetLineWidth(thinGridLineMM) + pdf.SetFillColor(245, 245, 245) + pdf.SetTextColor(45, 45, 45) + pdf.SetFont(sansFontFamily, "B", 8.9) + + curX := x + for i, header := range headers { + pdf.SetXY(curX, y) + pdf.CellFormat(columnWidths[i], headerHeight, header, "1", 0, "C", true, 0, "") + curX += columnWidths[i] + } + + pdf.SetFont(sansFontFamily, "", 8.6) + pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray) + for i := 0; i < rowCount; i++ { + rowY := y + headerHeight + float64(i)*rowHeight + mode := "" + if !isMixedModes(docs[i].Metadata.ModeSelection) { + mode = docs[i].Metadata.ModeSelection + } + + values := []string{ + docs[i].Metadata.SourceFileName, + docs[i].Metadata.Category, + mode, + strconv.Itoa(docs[i].Metadata.Count), + emptyAs(docs[i].Metadata.Seed, "none"), + } + + curX = x + for col := range values { + cellText := fitTableCellText(pdf, values[col], columnWidths[col]-1.6) + align := "L" + if col == 3 { + align = "C" + } + pdf.SetXY(curX, rowY) + pdf.CellFormat(columnWidths[col], rowHeight, cellText, "1", 0, align, false, 0, "") + curX += columnWidths[col] + } + } + + return y + headerHeight + float64(rowCount)*rowHeight +} + +func fitTableCellText(pdf *fpdf.Fpdf, text string, maxWidth float64) string { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + if maxWidth <= 0 { + return "" + } + if pdf.GetStringWidth(text) <= maxWidth { + return text + } + + ellipsis := "..." + if pdf.GetStringWidth(ellipsis) > maxWidth { + return ellipsis + } + + runes := []rune(text) + for len(runes) > 0 { + candidate := string(runes) + ellipsis + if pdf.GetStringWidth(candidate) <= maxWidth { + return candidate + } + runes = runes[:len(runes)-1] + } + return ellipsis +} + +func emptyAs(v, fallback string) string { + if strings.TrimSpace(v) == "" { + return fallback + } + return v +} diff --git a/pdfexport/render_tokens.go b/pdfexport/render_tokens.go new file mode 100644 index 0000000..704432d --- /dev/null +++ b/pdfexport/render_tokens.go @@ -0,0 +1,177 @@ +package pdfexport + +import ( + "math" + + "codeberg.org/go-pdf/fpdf" +) + +const ( + halfLetterWidthMM = 139.7 + halfLetterHeightMM = 215.9 + + footerTextGray = 78 + secondaryTextGray = 60 + ruleTextGray = 54 + dimTextGray = 92 + primaryTextGray = 20 + + pageMarginXMM = 10.0 + bindingGutterExtraMM = 2.6 + puzzleTopMM = 28.0 + puzzleBottomInsetMM = 16.0 + instructionGapMM = 4.2 + instructionLineHMM = 4.6 + wordSearchGridListGap = 4.0 + + thinGridLineMM = 0.24 + majorGridLineMM = 0.34 + outerBorderLineMM = 0.62 +) + +const ( + puzzleTitleFontSize = 13.0 + puzzleSubtitleFontSize = 9.0 + puzzleInstructionFontSize = 8.2 + puzzleWordBankFontSize = 8.8 + puzzleWordBankHeadSize = 9.2 +) + +type ( + boardFamily int + rectMM struct { + x float64 + y float64 + w float64 + h float64 + } +) + +const ( + boardFamilyCompact boardFamily = iota + boardFamilySudoku + boardFamilyHashi + boardFamilyNonogram + boardFamilyWordSearch + boardFamilyTable +) + +type boardSizing struct { + minCell float64 + maxCell float64 + targetFill float64 +} + +func puzzleBodyRect(pageW, pageH float64, pageNo int) rectMM { + leftMargin, rightMargin := puzzleHorizontalMargins(pageNo) + return rectMM{ + x: leftMargin, + y: puzzleTopMM, + w: pageW - leftMargin - rightMargin, + h: pageH - puzzleTopMM - puzzleBottomInsetMM, + } +} + +func puzzleBoardRect(pageW, pageH float64, pageNo, ruleLines int) rectMM { + rect := puzzleBodyRect(pageW, pageH, pageNo) + if ruleLines > 0 { + rect.h -= instructionGapMM + float64(ruleLines)*instructionLineHMM + if rect.h < 0 { + rect.h = 0 + } + } + return rect +} + +func puzzleHorizontalMargins(pageNo int) (left, right float64) { + left = pageMarginXMM + right = pageMarginXMM + if pageNo <= 1 { + return left, right + } + if pageNo%2 == 0 { + // Even pages sit on the left side in a spread, so inside edge is right. + right += bindingGutterExtraMM + return left, right + } + // Odd pages sit on the right side in a spread, so inside edge is left. + left += bindingGutterExtraMM + return left, right +} + +func centeredOrigin(area rectMM, cols, rows int, cellSize float64) (float64, float64) { + boardW := float64(cols) * cellSize + boardH := float64(rows) * cellSize + return area.x + (area.w-boardW)/2, area.y + (area.h-boardH)/2 +} + +func instructionY(boardBottom, pageH float64, lineCount int) float64 { + y := boardBottom + instructionGapMM + maxY := pageH - puzzleBottomInsetMM - float64(max(lineCount-1, 0))*instructionLineHMM + if y > maxY { + return maxY + } + return y +} + +func fitBoardCellSize(cols, rows int, area rectMM, family boardFamily) float64 { + if cols <= 0 || rows <= 0 || area.w <= 0 || area.h <= 0 { + return 0 + } + + sizeCfg := boardSizingFor(family) + maxFit := math.Min(area.w/float64(cols), area.h/float64(rows)) + size := clampFloat(maxFit, sizeCfg.minCell, sizeCfg.maxCell) + target := (area.h * sizeCfg.targetFill) / float64(rows) + if target > size { + size = math.Min(target, maxFit) + } + + size = clampFloat(size, sizeCfg.minCell, sizeCfg.maxCell) + if size > maxFit { + return maxFit + } + return size +} + +func boardSizingFor(family boardFamily) boardSizing { + switch family { + case boardFamilySudoku: + return boardSizing{minCell: 10.8, maxCell: 12.8, targetFill: 0.66} + case boardFamilyHashi: + return boardSizing{minCell: 6.0, maxCell: 16.2, targetFill: 0.58} + case boardFamilyNonogram: + return boardSizing{minCell: 3.6, maxCell: 8.8, targetFill: 0.64} + case boardFamilyWordSearch: + return boardSizing{minCell: 4.8, maxCell: 10.2, targetFill: 0.66} + case boardFamilyTable: + return boardSizing{minCell: 3.6, maxCell: 11.0, targetFill: 0.60} + default: + return boardSizing{minCell: 5.4, maxCell: 13.4, targetFill: 0.58} + } +} + +func clampFloat(v, minV, maxV float64) float64 { + if v < minV { + return minV + } + if v > maxV { + return maxV + } + return v +} + +func setPuzzleTitleStyle(pdf *fpdf.Fpdf) { + pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) + pdf.SetFont(sansFontFamily, "B", puzzleTitleFontSize) +} + +func setPuzzleSubtitleStyle(pdf *fpdf.Fpdf) { + pdf.SetTextColor(secondaryTextGray, secondaryTextGray, secondaryTextGray) + pdf.SetFont(sansFontFamily, "", puzzleSubtitleFontSize) +} + +func setInstructionStyle(pdf *fpdf.Fpdf) { + pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray) + pdf.SetFont(sansFontFamily, "", puzzleInstructionFontSize) +} diff --git a/pdfexport/types.go b/pdfexport/types.go new file mode 100644 index 0000000..812fc38 --- /dev/null +++ b/pdfexport/types.go @@ -0,0 +1,116 @@ +package pdfexport + +import "time" + +type PackMetadata struct { + GeneratedRaw string + GeneratedAt time.Time + Version string + Category string + ModeSelection string + Count int + Seed string + Format string + SourceFileName string +} + +type PackDocument struct { + SourcePath string + Metadata PackMetadata + Puzzles []Puzzle +} + +type DifficultyConfidence string + +const ( + DifficultyConfidenceHigh DifficultyConfidence = "high" + DifficultyConfidenceMedium DifficultyConfidence = "medium" +) + +type Puzzle struct { + SourcePath string + SourceFileName string + Category string + ModeSelection string + Name string + Index int + Body string + SaveData []byte + PrintPayload any + DifficultyScore float64 + DifficultyConfidence DifficultyConfidence + DifficultySource string +} + +type NonogramData struct { + Width int + Height int + RowHints [][]int + ColHints [][]int + Grid [][]string +} + +type NurikabeData struct { + Width int + Height int + Clues [][]int +} + +type ShikakuData struct { + Width int + Height int + Clues [][]int +} + +type HashiIsland struct { + X int + Y int + Required int +} + +type HashiData struct { + Width int + Height int + Islands []HashiIsland +} + +type HitoriData struct { + Size int + Numbers [][]string +} + +type TakuzuData struct { + Size int + Givens [][]string + GroupEveryTwo bool +} + +type SudokuData struct { + Givens [9][9]int `json:"givens"` +} + +type WordSearchData struct { + Width int `json:"width"` + Height int `json:"height"` + Grid [][]string `json:"grid"` + Words []string `json:"words"` +} + +type GridTable struct { + Rows [][]string + HasHeaderRow bool + HasHeaderCol bool +} + +type RGB struct{ R, G, B uint8 } + +type RenderConfig struct { + Title string + CoverSubtitle string + HeaderText string + VolumeNumber int + AdvertText string + GeneratedAt time.Time + ShuffleSeed string + CoverColor *RGB // nil = omit front/back covers +} diff --git a/shikaku/PrintAdapter.go b/shikaku/PrintAdapter.go new file mode 100644 index 0000000..5b92be5 --- /dev/null +++ b/shikaku/PrintAdapter.go @@ -0,0 +1,102 @@ +package shikaku + +import ( + "strconv" + "unicode/utf8" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Shikaku" } +func (printAdapter) Aliases() []string { return []string{"shikaku"} } + +func (printAdapter) BuildPDFPayload(save []byte) (any, error) { + return pdfexport.ParseShikakuPrintData(save) +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.ShikakuData: + renderShikakuPage(pdf, data) + } + return nil +} + +func renderShikakuPage(pdf *fpdf.Fpdf, data *pdfexport.ShikakuData) { + if data == nil || data.Width <= 0 || data.Height <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area) + if cellSize <= 0 { + return + } + + blockW := float64(data.Width) * cellSize + blockH := float64(data.Height) * cellSize + startX, startY := pdfexport.CenteredOrigin(area, data.Width, data.Height, cellSize) + + pdf.SetDrawColor(55, 55, 55) + pdf.SetLineWidth(pdfexport.ThinGridLineMM) + for y := 0; y < data.Height; y++ { + for x := 0; x < data.Width; x++ { + cellX := startX + float64(x)*cellSize + cellY := startY + float64(y)*cellSize + pdf.Rect(cellX, cellY, cellSize, cellSize, "D") + + if y >= len(data.Clues) || x >= len(data.Clues[y]) || data.Clues[y][x] <= 0 { + continue + } + drawShikakuClue(pdf, cellX, cellY, cellSize, data.Clues[y][x]) + } + } + + pdf.SetDrawColor(35, 35, 35) + pdf.SetLineWidth(pdfexport.OuterBorderLineMM) + pdf.Rect(startX, startY, blockW, blockH, "D") + + ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1) + pdfexport.SetInstructionStyle(pdf) + pdf.SetXY(area.X, ruleY) + pdf.CellFormat( + area.W, + pdfexport.InstructionLineHMM, + "Partition into rectangles where each clue equals its rectangle area.", + "", + 0, + "C", + false, + 0, + "", + ) +} + +func drawShikakuClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) { + text := strconv.Itoa(value) + fontSize := pdfexport.StandardCellFontSize(cellSize, 0.58) + runeCount := utf8.RuneCountInString(text) + switch { + case runeCount >= 3: + fontSize *= 0.72 + case runeCount == 2: + fontSize *= 0.84 + } + fontSize = pdfexport.ClampStandardCellFontSize(fontSize) + + pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray) + pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) + lineH := fontSize * 0.92 + pdf.SetXY(x, y+(cellSize-lineH)/2) + pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/sudoku/PrintAdapter.go b/sudoku/PrintAdapter.go new file mode 100644 index 0000000..2877454 --- /dev/null +++ b/sudoku/PrintAdapter.go @@ -0,0 +1,112 @@ +package sudoku + +import ( + "strconv" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Sudoku" } +func (printAdapter) Aliases() []string { return []string{"sudoku"} } + +func (printAdapter) BuildPDFPayload(save []byte) (any, error) { + return pdfexport.ParseSudokuPrintData(save) +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.SudokuData: + renderSudokuPage(pdf, data) + } + return nil +} + +func renderSudokuPage(pdf *fpdf.Fpdf, data *pdfexport.SudokuData) { + if data == nil { + return + } + + pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + cellSize := pdfexport.FitSudokuCellSize(9, 9, area) + if cellSize <= 0 { + return + } + + boardH := 9.0 * cellSize + startX, startY := pdfexport.CenteredOrigin(area, 9, 9, cellSize) + + drawSudokuGridLines(pdf, startX, startY, cellSize) + drawSudokuGivens(pdf, startX, startY, cellSize, data.Givens) + + ruleY := pdfexport.InstructionY(startY+boardH, pageH, 1) + pdfexport.SetInstructionStyle(pdf) + pdf.SetXY(area.X, ruleY) + pdf.CellFormat( + area.W, + pdfexport.InstructionLineHMM, + "Fill rows, columns, and 3x3 boxes with 1-9", + "", + 0, + "C", + false, + 0, + "", + ) +} + +func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) { + pdf.SetDrawColor(25, 25, 25) + + for i := range 10 { + x := startX + float64(i)*cellSize + pdf.SetLineWidth(sudokuLineWidthFor(i)) + pdf.Line(x, startY, x, startY+9.0*cellSize) + } + + for i := range 10 { + y := startY + float64(i)*cellSize + pdf.SetLineWidth(sudokuLineWidthFor(i)) + pdf.Line(startX, y, startX+9.0*cellSize, y) + } +} + +func sudokuLineWidthFor(index int) float64 { + switch { + case index == 0 || index == 9: + return 0.72 + case index%3 == 0: + return 0.56 + default: + return pdfexport.ThinGridLineMM + } +} + +func drawSudokuGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [9][9]int) { + fontSize := pdfexport.StandardCellFontSize(cellSize, 0.62) + lineH := fontSize * 0.85 + pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) + pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray) + + for y := range 9 { + for x := range 9 { + value := givens[y][x] + if value < 1 || value > 9 { + continue + } + cellX := startX + float64(x)*cellSize + cellY := startY + float64(y)*cellSize + pdf.SetXY(cellX, cellY+(cellSize-lineH)/2) + pdf.CellFormat(cellSize, lineH, strconv.Itoa(value), "", 0, "C", false, 0, "") + } + } +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/takuzu/PrintAdapter.go b/takuzu/PrintAdapter.go new file mode 100644 index 0000000..939b2ed --- /dev/null +++ b/takuzu/PrintAdapter.go @@ -0,0 +1,138 @@ +package takuzu + +import ( + "strings" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Takuzu" } +func (printAdapter) Aliases() []string { return []string{"takuzu"} } + +func (printAdapter) BuildPDFPayload(save []byte) (any, error) { + return pdfexport.ParseTakuzuPrintData(save) +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.TakuzuData: + renderTakuzuPage(pdf, data) + } + return nil +} + +func renderTakuzuPage(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData) { + if data == nil || data.Size <= 0 { + return + } + + size := data.Size + pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 2) + cellSize := pdfexport.FitCompactCellSize(size, size, area) + if cellSize <= 0 { + return + } + + blockW := float64(size) * cellSize + blockH := float64(size) * cellSize + startX, startY := pdfexport.CenteredOrigin(area, size, size, cellSize) + + pdf.SetDrawColor(60, 60, 60) + pdf.SetLineWidth(pdfexport.ThinGridLineMM) + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + cellX := startX + float64(x)*cellSize + cellY := startY + float64(y)*cellSize + pdf.Rect(cellX, cellY, cellSize, cellSize, "D") + } + } + + if data.GroupEveryTwo { + pdf.SetDrawColor(130, 130, 130) + pdf.SetLineWidth(pdfexport.MajorGridLineMM) + for i := 2; i < size; i += 2 { + x := startX + float64(i)*cellSize + y := startY + float64(i)*cellSize + pdf.Line(x, startY, x, startY+blockH) + pdf.Line(startX, y, startX+blockW, y) + } + } + + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + text := "" + if y < len(data.Givens) && x < len(data.Givens[y]) { + text = strings.TrimSpace(data.Givens[y][x]) + } + if text == "" { + continue + } + + cellX := startX + float64(x)*cellSize + cellY := startY + float64(y)*cellSize + drawTakuzuGiven(pdf, cellX, cellY, cellSize, size, text) + } + } + + pdf.SetDrawColor(35, 35, 35) + pdf.SetLineWidth(pdfexport.OuterBorderLineMM) + pdf.Rect(startX, startY, blockW, blockH, "D") + + ruleY := pdfexport.InstructionY(startY+blockH, pageH, 2) + pdfexport.SetInstructionStyle(pdf) + pdf.SetXY(area.X, ruleY) + pdf.CellFormat( + area.W, + pdfexport.InstructionLineHMM, + "No three equal adjacent in any row or column.", + "", + 0, + "C", + false, + 0, + "", + ) + pdf.SetXY(area.X, ruleY+pdfexport.InstructionLineHMM) + pdf.CellFormat( + area.W, + pdfexport.InstructionLineHMM, + "Each row/column has equal 0 and 1 counts, and rows/columns are unique.", + "", + 0, + "C", + false, + 0, + "", + ) +} + +func drawTakuzuGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, size int, text string) { + fontSize := takuzuGivenFontSize(cellSize, size) + + pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray) + pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) + lineH := fontSize * 0.9 + pdf.SetXY(x, y+(cellSize-lineH)/2) + pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") +} + +func takuzuGivenFontSize(cellSize float64, size int) float64 { + fontSize := pdfexport.StandardCellFontSize(cellSize, 0.68) + switch { + case size >= 14: + fontSize *= 0.94 + case size >= 12: + fontSize *= 0.97 + } + return pdfexport.ClampStandardCellFontSize(fontSize) +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/takuzu/print_adapter_test.go b/takuzu/print_adapter_test.go new file mode 100644 index 0000000..11952cb --- /dev/null +++ b/takuzu/print_adapter_test.go @@ -0,0 +1,44 @@ +package takuzu + +import "testing" + +func TestTakuzuGivenFontSize(t *testing.T) { + tests := []struct { + name string + cellSize float64 + size int + wantMin float64 + wantMax float64 + }{ + { + name: "small cell keeps readable minimum", + cellSize: 3.0, + size: 14, + wantMin: 5.2, + wantMax: 5.2, + }, + { + name: "12x12 remains comfortably readable", + cellSize: 10.0, + size: 12, + wantMin: 6.3, + wantMax: 6.7, + }, + { + name: "14x14 remains comfortably readable", + cellSize: 9.0, + size: 14, + wantMin: 5.7, + wantMax: 6.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := takuzuGivenFontSize(tt.cellSize, tt.size) + if got < tt.wantMin || got > tt.wantMax { + t.Fatalf("font size = %.3f, want %.3f..%.3f", got, tt.wantMin, tt.wantMax) + } + }) + } +} diff --git a/wordsearch/PrintAdapter.go b/wordsearch/PrintAdapter.go new file mode 100644 index 0000000..cee930e --- /dev/null +++ b/wordsearch/PrintAdapter.go @@ -0,0 +1,261 @@ +package wordsearch + +import ( + "strings" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Word Search" } +func (printAdapter) Aliases() []string { + return []string{"word search", "wordsearch"} +} + +func (printAdapter) BuildPDFPayload(save []byte) (any, error) { + return pdfexport.ParseWordSearchPrintData(save) +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.WordSearchData: + renderWordSearchPage(pdf, data) + } + return nil +} + +func renderWordSearchPage(pdf *fpdf.Fpdf, data *pdfexport.WordSearchData) { + if data == nil || data.Width <= 0 || data.Height <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + availW := body.W + availH := body.H + + columnCount := wordSearchColumnCount(data.Width, len(data.Words)) + wordFontSize := pdfexport.PuzzleWordBankFontSize + wordLineHeight := 4.2 + gridListGap := pdfexport.WordSearchGridGapMM + + estimatedWordLines := estimateWordBankLineCount(pdf, data.Words, columnCount, availW, wordFontSize) + wordBankHeight := 7.0 + float64(estimatedWordLines)*wordLineHeight + maxWordBankHeight := availH * 0.42 + if wordBankHeight > maxWordBankHeight { + wordBankHeight = maxWordBankHeight + } + if wordBankHeight < 16 { + wordBankHeight = 16 + } + + gridAreaH := availH - wordBankHeight - gridListGap + if gridAreaH < availH*0.5 { + gridAreaH = availH * 0.5 + } + + gridArea := pdfexport.Rect{X: body.X, Y: body.Y, W: availW, H: gridAreaH} + cellSize := pdfexport.FitWordSearchCellSize(data.Width, data.Height, gridArea) + if cellSize <= 0 { + return + } + + gridW := float64(data.Width) * cellSize + gridH := float64(data.Height) * cellSize + gridX := body.X + (gridArea.W-gridW)/2 + gridY := body.Y + (gridArea.H-gridH)/2 + + drawWordSearchGrid(pdf, data, gridX, gridY, cellSize) + drawWordBank( + pdf, + data.Words, + body.X, + gridY+gridH+gridListGap, + availW, + pageH-pdfexport.PuzzleBottomInsetMM-(gridY+gridH+gridListGap), + columnCount, + ) +} + +func drawWordSearchGrid(pdf *fpdf.Fpdf, data *pdfexport.WordSearchData, startX, startY, cellSize float64) { + pdf.SetDrawColor(45, 45, 45) + pdf.SetLineWidth(pdfexport.ThinGridLineMM) + for y := range data.Height { + for x := range data.Width { + cellX := startX + float64(x)*cellSize + cellY := startY + float64(y)*cellSize + pdf.Rect(cellX, cellY, cellSize, cellSize, "D") + + cellText := " " + if y < len(data.Grid) && x < len(data.Grid[y]) { + cellText = strings.TrimSpace(strings.ToUpper(data.Grid[y][x])) + } + if cellText == "" || cellText == "." { + continue + } + + fontSize := pdfexport.StandardCellFontSize(cellSize, 0.74) + lineH := fontSize * 0.86 + pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) + pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray) + pdf.SetXY(cellX, cellY+(cellSize-lineH)/2) + pdf.CellFormat(cellSize, lineH, cellText, "", 0, "C", false, 0, "") + } + } + + pdf.SetLineWidth(pdfexport.OuterBorderLineMM) + pdf.Rect(startX, startY, float64(data.Width)*cellSize, float64(data.Height)*cellSize, "D") +} + +func drawWordBank(pdf *fpdf.Fpdf, words []string, x, y, width, height float64, columns int) { + if height <= 0 { + return + } + + pdf.SetTextColor(40, 40, 40) + pdf.SetFont(pdfexport.SansFontFamily, "B", pdfexport.PuzzleWordBankHeadSize) + pdf.SetXY(x, y) + pdf.CellFormat(width, 4.8, "Word Bank", "", 0, "L", false, 0, "") + + pdf.SetFont(pdfexport.SansFontFamily, "", pdfexport.PuzzleWordBankFontSize) + pdf.SetTextColor(pdfexport.RuleTextGray, pdfexport.RuleTextGray, pdfexport.RuleTextGray) + pdf.SetXY(x, y+4.8) + pdf.CellFormat(width, 4.2, "Words may run in all 8 directions", "", 0, "L", false, 0, "") + + listY := y + 9.0 + if len(words) == 0 { + pdf.SetFont(pdfexport.SansFontFamily, "", pdfexport.PuzzleWordBankHeadSize) + pdf.SetTextColor(pdfexport.SecondaryTextGray, pdfexport.SecondaryTextGray, pdfexport.SecondaryTextGray) + pdf.SetXY(x, listY) + pdf.CellFormat(width, 4.6, "(word list unavailable)", "", 0, "L", false, 0, "") + return + } + + columnGap := 4.0 + if columns < 1 { + columns = 1 + } + colWidth := (width - float64(columns-1)*columnGap) / float64(columns) + if colWidth <= 0 { + colWidth = width + columns = 1 + } + + colLines := layoutWordBankColumns(pdf, words, columns, colWidth) + lineHeight := 4.1 + maxLines := int(height / lineHeight) + if maxLines <= 0 { + return + } + + pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray) + pdf.SetFont(pdfexport.SansFontFamily, "B", pdfexport.PuzzleWordBankHeadSize) + for c := range columns { + colX := x + float64(c)*(colWidth+columnGap) + curY := listY + lines := colLines[c] + for _, line := range lines { + if int((curY-listY)/lineHeight) >= maxLines { + break + } + pdf.SetXY(colX, curY) + pdf.CellFormat(colWidth, lineHeight, line, "", 0, "L", false, 0, "") + curY += lineHeight + } + } +} + +func wordSearchColumnCount(width, wordCount int) int { + if width >= 20 || wordCount > 18 { + return 3 + } + if wordCount <= 0 { + return 1 + } + return 2 +} + +func estimateWordBankLineCount(pdf *fpdf.Fpdf, words []string, columns int, availW, fontSize float64) int { + if len(words) == 0 { + return 1 + } + if columns < 1 { + columns = 1 + } + + colWidth := (availW - float64(columns-1)*4.0) / float64(columns) + if colWidth <= 0 { + colWidth = availW + } + + pdf.SetFont(pdfexport.SansFontFamily, "", fontSize) + lineCounts := make([]int, columns) + for _, word := range words { + text := strings.ToUpper(strings.TrimSpace(word)) + if text == "" { + continue + } + lines := pdf.SplitLines([]byte(text), colWidth) + if len(lines) == 0 { + lines = [][]byte{[]byte(text)} + } + idx := minLineCountColumn(lineCounts) + lineCounts[idx] += len(lines) + } + + maxLines := 0 + for _, lineCount := range lineCounts { + if lineCount > maxLines { + maxLines = lineCount + } + } + if maxLines == 0 { + return 1 + } + return maxLines +} + +func layoutWordBankColumns(pdf *fpdf.Fpdf, words []string, columns int, colWidth float64) [][]string { + colLines := make([][]string, columns) + if len(words) == 0 || columns <= 0 { + return colLines + } + + lineCounts := make([]int, columns) + for _, word := range words { + text := strings.ToUpper(strings.TrimSpace(word)) + if text == "" { + continue + } + wrapped := pdf.SplitLines([]byte(text), colWidth) + if len(wrapped) == 0 { + wrapped = [][]byte{[]byte(text)} + } + + idx := minLineCountColumn(lineCounts) + for _, line := range wrapped { + colLines[idx] = append(colLines[idx], string(line)) + } + lineCounts[idx] += len(wrapped) + } + + return colLines +} + +func minLineCountColumn(lineCounts []int) int { + idx := 0 + for i := 1; i < len(lineCounts); i++ { + if lineCounts[i] < lineCounts[idx] { + idx = i + } + } + return idx +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +}