From 0c5cbc33eac807a0baf0a01376882acad1026633 Mon Sep 17 00:00:00 2001 From: Dami Date: Sat, 21 Feb 2026 21:36:53 -0700 Subject: [PATCH 01/14] step 01 commit --- README.md | 16 + app/keys.go | 18 +- app/update.go | 5 + app/update_test.go | 101 +++++ app/yank.go | 42 ++ cmd/export_pdf.go | 178 ++++++++ cmd/new.go | 8 + cmd/new_export.go | 201 +++++++++ cmd/new_export_test.go | 175 ++++++++ cmd/root.go | 2 +- go.mod | 5 +- go.sum | 2 + markdownexport/detect.go | 41 ++ markdownexport/document.go | 71 ++++ markdownexport/render.go | 467 ++++++++++++++++++++ markdownexport/render_test.go | 188 ++++++++ markdownexport/support.go | 26 ++ nonogram-pack-01.md | 776 ++++++++++++++++++++++++++++++++++ pack-01/issue-01.pdf | Bin 0 -> 85063 bytes pack-01/nonogram.md | 332 +++++++++++++++ pack-01/shikaku.md | 368 ++++++++++++++++ pack-01/takuzu.md | 252 +++++++++++ pdfexport/order.go | 104 +++++ pdfexport/order_test.go | 36 ++ pdfexport/parse.go | 496 ++++++++++++++++++++++ pdfexport/parse_test.go | 135 ++++++ pdfexport/render.go | 477 +++++++++++++++++++++ pdfexport/types.go | 64 +++ 28 files changed, 4576 insertions(+), 10 deletions(-) create mode 100644 app/yank.go create mode 100644 cmd/export_pdf.go create mode 100644 cmd/new_export.go create mode 100644 cmd/new_export_test.go create mode 100644 markdownexport/detect.go create mode 100644 markdownexport/document.go create mode 100644 markdownexport/render.go create mode 100644 markdownexport/render_test.go create mode 100644 markdownexport/support.go create mode 100644 nonogram-pack-01.md create mode 100644 pack-01/issue-01.pdf create mode 100644 pack-01/nonogram.md create mode 100644 pack-01/shikaku.md create mode 100644 pack-01/takuzu.md create mode 100644 pdfexport/order.go create mode 100644 pdfexport/order_test.go create mode 100644 pdfexport/parse.go create mode 100644 pdfexport/parse_test.go create mode 100644 pdfexport/render.go create mode 100644 pdfexport/types.go diff --git a/README.md b/README.md index bc6d83e..9921069 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,21 @@ puzzletea new --set-seed myseed puzzletea new nonogram epic --with-seed myseed ``` +Export printable puzzle sets to markdown: + +```bash +# Stream markdown to stdout (redirect if desired) +puzzletea new nonogram mini --export 2 > nonogram-mini-set.md + +# Single mode export +puzzletea new nonogram mini -e 6 -o nonogram-mini-set.md + +# Mixed modes within a category (deterministic with --with-seed) +puzzletea new sudoku --export 10 -o sudoku-mixed.md --with-seed zine-issue-01 +``` + +`Lights Out` is currently excluded from markdown export because it does not translate cleanly to paper workflows. + Override the color theme: ```bash @@ -135,6 +150,7 @@ Several shorthand names are accepted for games: `hashi`/`bridges` for Hashiwokak | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | | `Ctrl+E` | Toggle debug overlay | +| `Ctrl+Y` | Yank puzzle markdown snippet | | `Ctrl+C` | Quit | ### Navigation diff --git a/app/keys.go b/app/keys.go index 8192e04..4820a4f 100644 --- a/app/keys.go +++ b/app/keys.go @@ -3,13 +3,14 @@ package app import "charm.land/bubbles/v2/key" type rootKeyMap struct { - Quit key.Binding - MainMenu key.Binding - Enter key.Binding - Escape key.Binding - Debug key.Binding - FullHelp key.Binding - ResetGame key.Binding + Quit key.Binding + MainMenu key.Binding + Enter key.Binding + Escape key.Binding + Debug key.Binding + FullHelp key.Binding + ResetGame key.Binding + YankPuzzle key.Binding } var rootKeys = rootKeyMap{ @@ -34,4 +35,7 @@ var rootKeys = rootKeyMap{ ResetGame: key.NewBinding( key.WithKeys("ctrl+r"), ), + YankPuzzle: key.NewBinding( + key.WithKeys("ctrl+y"), + ), } diff --git a/app/update.go b/app/update.go index 3ef54c6..4b7e11a 100644 --- a/app/update.go +++ b/app/update.go @@ -175,6 +175,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.state == gameView && m.game != nil { m.game = m.game.Reset() } + case key.Matches(msg, rootKeys.YankPuzzle): + if m.state == gameView && m.game != nil { + return m, m.yankPuzzleCmd() + } + return m, nil } } diff --git a/app/update_test.go b/app/update_test.go index cd66c37..cc6d881 100644 --- a/app/update_test.go +++ b/app/update_test.go @@ -1,7 +1,9 @@ package app import ( + "errors" "path/filepath" + "strings" "testing" "time" @@ -10,9 +12,11 @@ import ( tea "charm.land/bubbletea/v2" "github.com/FelineStateMachine/puzzletea/daily" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/lightsout" "github.com/FelineStateMachine/puzzletea/namegen" "github.com/FelineStateMachine/puzzletea/resolve" "github.com/FelineStateMachine/puzzletea/store" + "github.com/FelineStateMachine/puzzletea/sudoku" ) type escapeTrackingGame struct { @@ -238,3 +242,100 @@ func openAppTestStore(t *testing.T) *store.Store { t.Cleanup(func() { _ = s.Close() }) return s } + +func TestCtrlYYanksSupportedPuzzle(t *testing.T) { + previousCopy := copyToClipboard + t.Cleanup(func() { copyToClipboard = previousCopy }) + + var copied string + copyToClipboard = func(s string) error { + copied = s + return nil + } + + m := model{ + state: gameView, + game: sudoku.Model{}, + } + + _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) + if cmd != nil { + t.Fatal("expected nil command when native clipboard copy succeeds") + } + if copied == "" { + t.Fatal("expected markdown snippet to be copied") + } + if !strings.Contains(copied, "Given Grid") { + t.Fatalf("expected sudoku markdown snippet, got:\n%s", copied) + } +} + +func TestCtrlYYankUnsupportedGameIsNoOp(t *testing.T) { + previousCopy := copyToClipboard + t.Cleanup(func() { copyToClipboard = previousCopy }) + + calls := 0 + copyToClipboard = func(string) error { + calls++ + return nil + } + + m := model{ + state: gameView, + game: lightsout.Model{}, + } + + _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) + if cmd != nil { + t.Fatal("expected nil command for unsupported game yank") + } + if calls != 0 { + t.Fatalf("clipboard should not be called, got %d calls", calls) + } +} + +func TestCtrlYYankOutsideGameViewIsNoOp(t *testing.T) { + previousCopy := copyToClipboard + t.Cleanup(func() { copyToClipboard = previousCopy }) + + calls := 0 + copyToClipboard = func(string) error { + calls++ + return nil + } + + m := model{ + state: mainMenuView, + game: sudoku.Model{}, + } + + _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) + if cmd != nil { + t.Fatal("expected nil command outside game view") + } + if calls != 0 { + t.Fatalf("clipboard should not be called outside game view, got %d calls", calls) + } +} + +func TestCtrlYYankFallsBackToOSC52(t *testing.T) { + previousCopy := copyToClipboard + t.Cleanup(func() { copyToClipboard = previousCopy }) + + copyToClipboard = func(string) error { + return errors.New("clipboard unavailable") + } + + m := model{ + state: gameView, + game: sudoku.Model{}, + } + + _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) + if cmd == nil { + t.Fatal("expected fallback clipboard command") + } + if msg := cmd(); msg == nil { + t.Fatal("expected fallback clipboard message") + } +} diff --git a/app/yank.go b/app/yank.go new file mode 100644 index 0000000..94711d9 --- /dev/null +++ b/app/yank.go @@ -0,0 +1,42 @@ +package app + +import ( + "errors" + + "github.com/FelineStateMachine/puzzletea/markdownexport" + + tea "charm.land/bubbletea/v2" + "github.com/atotto/clipboard" +) + +var copyToClipboard = clipboard.WriteAll + +func (m model) yankPuzzleCmd() tea.Cmd { + if m.state != gameView || m.game == nil { + return nil + } + + gameType, err := markdownexport.DetectGameType(m.game) + if err != nil || !markdownexport.SupportsGameType(gameType) { + return nil + } + + save, err := m.game.GetSave() + if err != nil { + return nil + } + + snippet, err := markdownexport.RenderPuzzleSnippet(gameType, "", save) + if err != nil { + if errors.Is(err, markdownexport.ErrUnsupportedGame) { + return nil + } + return nil + } + + if err := copyToClipboard(snippet); err == nil { + return nil + } + + return tea.SetClipboard(snippet) +} diff --git a/cmd/export_pdf.go b/cmd/export_pdf.go new file mode 100644 index 0000000..553bd8d --- /dev/null +++ b/cmd/export_pdf.go @@ -0,0 +1,178 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "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 + flagPDFAdvert string + flagPDFShuffleSeed string +) + +var exportPDFCmd = &cobra.Command{ + Use: "export-pdf [more.md ...]", + Short: "Convert one or more PuzzleTea markdown exports into an A5 printable PDF", + Long: "Parse one or more markdown export files, order puzzles by progressive difficulty with seeded mixing, and render an A5 PDF with a title page and one puzzle per page.", + 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", "", "title shown on the generated title page") + 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") +} + +func runExportPDF(cmd *cobra.Command, args []string) error { + docs, err := pdfexport.ParseFiles(args) + if err != nil { + return err + } + + puzzles := flattenPuzzles(docs) + if len(puzzles) == 0 { + return fmt.Errorf("no puzzles found in input markdown files") + } + + 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") + } + + title := strings.TrimSpace(flagPDFTitle) + if title == "" { + title = defaultPDFTitle(docs) + } + + cfg := pdfexport.RenderConfig{ + Title: title, + AdvertText: flagPDFAdvert, + GeneratedAt: time.Now(), + ShuffleSeed: shuffleSeed, + } + 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 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), " ") +} diff --git a/cmd/new.go b/cmd/new.go index 644918b..e0b1ba1 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 markdown 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..e9a7ab9 --- /dev/null +++ b/cmd/new_export.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "math/rand/v2" + "os" + "path/filepath" + "strings" + "time" + + "github.com/FelineStateMachine/puzzletea/app" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/markdownexport" + "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 !markdownexport.SupportsGameType(cat.Name) { + return fmt.Errorf("game %q does not support markdown export", cat.Name) + } + + modeArg := "" + if len(args) > 1 { + modeArg = args[1] + } + + entries, modeSelection, err := collectExportModes(cat, modeArg) + if err != nil { + return err + } + + sections, err := buildExportSections(cat.Name, entries, flagExport, flagWithSeed) + if err != nil { + return err + } + + doc := markdownexport.BuildDocument(markdownexport.DocumentConfig{ + Version: Version, + Category: cat.Name, + ModeSelection: modeSelection, + Count: flagExport, + Seed: flagWithSeed, + GeneratedAt: exportNow(), + }, sections) + + if err := writeExportMarkdown(cmd, flagOutput, doc); 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), ".md") { + return fmt.Errorf("--output must use a .md extension") + } + if flagSetSeed != "" { + return fmt.Errorf("--set-seed cannot be combined with markdown 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 buildExportSections(gameType string, entries []exportModeEntry, count int, seed string) ([]markdownexport.PuzzleSection, error) { + var rng *rand.Rand + if seed != "" { + rng = resolve.RNGFromString(seed) + } + + sections := make([]markdownexport.PuzzleSection, 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) + } + + snippet, err := markdownexport.RenderPuzzleSnippet(gameType, entry.mode, save) + if err != nil { + if errors.Is(err, markdownexport.ErrUnsupportedGame) { + return nil, fmt.Errorf("game %q does not support markdown export", gameType) + } + return nil, fmt.Errorf("render puzzle %d: %w", i+1, err) + } + + sections = append(sections, markdownexport.PuzzleSection{ + Index: i + 1, + GameType: gameType, + Mode: entry.mode, + Body: snippet, + }) + } + + return sections, 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 writeExportMarkdown(cmd *cobra.Command, path, content string) error { + if strings.TrimSpace(path) == "" { + if _, err := io.WriteString(cmd.OutOrStdout(), content); err != nil { + return fmt.Errorf("write export markdown 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 markdown: %w", err) + } + return nil +} diff --git a/cmd/new_export_test.go b/cmd/new_export_test.go new file mode 100644 index 0000000..9faf5ac --- /dev/null +++ b/cmd/new_export_test.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" +) + +func TestRunNewExportRejectsUnsupportedGame(t *testing.T) { + withExportFlagReset(t) + flagOutput = filepath.Join(t.TempDir(), "lights.md") + + cmd, _ := newExportTestCmd(t, false) + err := runNewExport(cmd, []string{"lights-out"}) + if err == nil { + t.Fatal("expected unsupported game error") + } + if !strings.Contains(err.Error(), "does not support markdown export") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunNewExportValidation(t *testing.T) { + t.Run("writes 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) + } + if !strings.Contains(out.String(), "# PuzzleTea Export") { + t.Fatalf("expected markdown output on stdout, got:\n%s", out.String()) + } + }) + + t.Run("output extension must be markdown", 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(), ".md 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.md") + + 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 markdown 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.md") + fileB := filepath.Join(t.TempDir(), "b.md") + + 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 markdown output for identical seed and args") + } +} + +func TestRunNewExportOverwritesOutputFile(t *testing.T) { + withExportFlagReset(t) + + file := filepath.Join(t.TempDir(), "out.md") + 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), "# PuzzleTea Export") { + t.Fatal("expected markdown export header") + } +} + +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/go.mod b/go.mod index 854ce3c..9b52670 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,14 @@ 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 + github.com/atotto/clipboard v0.1.4 + github.com/go-pdf/fpdf v0.9.0 github.com/spf13/cobra v1.10.2 modernc.org/sqlite v1.44.3 ) require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect @@ -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..93fa117 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/markdownexport/detect.go b/markdownexport/detect.go new file mode 100644 index 0000000..6fa6822 --- /dev/null +++ b/markdownexport/detect.go @@ -0,0 +1,41 @@ +package markdownexport + +import ( + "fmt" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/hashiwokakero" + "github.com/FelineStateMachine/puzzletea/hitori" + "github.com/FelineStateMachine/puzzletea/lightsout" + "github.com/FelineStateMachine/puzzletea/nonogram" + "github.com/FelineStateMachine/puzzletea/nurikabe" + "github.com/FelineStateMachine/puzzletea/shikaku" + "github.com/FelineStateMachine/puzzletea/sudoku" + "github.com/FelineStateMachine/puzzletea/takuzu" + "github.com/FelineStateMachine/puzzletea/wordsearch" +) + +func DetectGameType(g game.Gamer) (string, error) { + switch g.(type) { + case hashiwokakero.Model, *hashiwokakero.Model: + return "Hashiwokakero", nil + case hitori.Model, *hitori.Model: + return "Hitori", nil + case lightsout.Model, *lightsout.Model: + return "Lights Out", nil + case nonogram.Model, *nonogram.Model: + return "Nonogram", nil + case nurikabe.Model, *nurikabe.Model: + return "Nurikabe", nil + case shikaku.Model, *shikaku.Model: + return "Shikaku", nil + case sudoku.Model, *sudoku.Model: + return "Sudoku", nil + case takuzu.Model, *takuzu.Model: + return "Takuzu", nil + case wordsearch.Model, *wordsearch.Model: + return "Word Search", nil + default: + return "", fmt.Errorf("cannot detect game type for %T", g) + } +} diff --git a/markdownexport/document.go b/markdownexport/document.go new file mode 100644 index 0000000..417bd08 --- /dev/null +++ b/markdownexport/document.go @@ -0,0 +1,71 @@ +package markdownexport + +import ( + "fmt" + "hash/fnv" + "math/rand/v2" + "strings" + "time" + + "github.com/FelineStateMachine/puzzletea/namegen" +) + +type DocumentConfig struct { + Version string + Category string + ModeSelection string + Count int + Seed string + GeneratedAt time.Time +} + +type PuzzleSection struct { + Index int + GameType string + Mode string + Body string +} + +func BuildDocument(cfg DocumentConfig, puzzles []PuzzleSection) string { + var b strings.Builder + + seed := cfg.Seed + if strings.TrimSpace(seed) == "" { + seed = "none" + } + nameRNG := exportNameRNG(cfg) + + fmt.Fprintf(&b, "# PuzzleTea Export\n\n") + fmt.Fprintf(&b, "- Generated: %s\n", cfg.GeneratedAt.Format(time.RFC3339)) + fmt.Fprintf(&b, "- Version: %s\n", cfg.Version) + fmt.Fprintf(&b, "- Category: %s\n", cfg.Category) + fmt.Fprintf(&b, "- Mode Selection: %s\n", cfg.ModeSelection) + fmt.Fprintf(&b, "- Count: %d\n", cfg.Count) + fmt.Fprintf(&b, "- Seed: %s\n\n", seed) + + for i, puzzle := range puzzles { + if i > 0 { + b.WriteString("\n---\n\n") + } + fmt.Fprintf(&b, "## %s - %d\n\n", namegen.GenerateSeeded(nameRNG), puzzle.Index) + b.WriteString(strings.TrimSpace(puzzle.Body)) + b.WriteString("\n") + } + + return b.String() +} + +func exportNameRNG(cfg DocumentConfig) *rand.Rand { + seed := cfg.Seed + if strings.TrimSpace(seed) == "" { + seed = cfg.GeneratedAt.Format(time.RFC3339Nano) + } + return rngFromString("export-names:" + seed) +} + +func rngFromString(seed string) *rand.Rand { + h := fnv.New64a() + h.Write([]byte(seed)) + s := h.Sum64() + return rand.New(rand.NewPCG(s, ^s)) +} diff --git a/markdownexport/render.go b/markdownexport/render.go new file mode 100644 index 0000000..02530ce --- /dev/null +++ b/markdownexport/render.go @@ -0,0 +1,467 @@ +package markdownexport + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/FelineStateMachine/puzzletea/hashiwokakero" + "github.com/FelineStateMachine/puzzletea/hitori" + "github.com/FelineStateMachine/puzzletea/nonogram" + "github.com/FelineStateMachine/puzzletea/nurikabe" + "github.com/FelineStateMachine/puzzletea/shikaku" + "github.com/FelineStateMachine/puzzletea/sudoku" + "github.com/FelineStateMachine/puzzletea/takuzu" + "github.com/FelineStateMachine/puzzletea/wordsearch" +) + +func RenderPuzzleSnippet(gameType, _ string, save []byte) (string, error) { + switch normalizeGameType(gameType) { + case "hashiwokakero": + return renderHashi(save) + case "hitori": + return renderHitori(save) + case "nonogram": + return renderNonogram(save) + case "nurikabe": + return renderNurikabe(save) + case "shikaku": + return renderShikaku(save) + case "sudoku": + return renderSudoku(save) + case "takuzu": + return renderTakuzu(save) + case "word search", "wordsearch": + return renderWordSearch(save) + case "lights out", "lightsout": + return "", ErrUnsupportedGame + default: + return "", ErrUnsupportedGame + } +} + +func renderHashi(data []byte) (string, error) { + var save hashiwokakero.Save + if err := json.Unmarshal(data, &save); err != nil { + return "", fmt.Errorf("decode hashiwokakero save: %w", err) + } + + cells := makeGrid(save.Width, save.Height, ".") + for _, island := range save.Islands { + if island.Y >= 0 && island.Y < len(cells) && island.X >= 0 && island.X < len(cells[island.Y]) { + cells[island.Y][island.X] = strconv.Itoa(island.Required) + } + } + + var b strings.Builder + b.WriteString("### Grid\n\n") + b.WriteString(renderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Rules: connect numbered islands with horizontal/vertical bridges. ") + b.WriteString("Use up to two bridges per connection and never cross bridges.") + return b.String(), nil +} + +func renderHitori(data []byte) (string, error) { + var save hitori.Save + if err := json.Unmarshal(data, &save); err != nil { + return "", fmt.Errorf("decode hitori save: %w", err) + } + + rows := splitLines(save.Numbers) + cells := make([][]string, 0, max(len(rows), save.Size)) + targetHeight := max(len(rows), save.Size) + for y := range targetHeight { + row := []rune{} + if y < len(rows) { + row = []rune(rows[y]) + } + cellsRow := make([]string, max(len(row), save.Size)) + if len(cellsRow) == 0 { + cellsRow = []string{"."} + } + for x := range len(cellsRow) { + if x < len(row) { + cellsRow[x] = string(row[x]) + } else { + cellsRow[x] = "." + } + } + cells = append(cells, cellsRow) + } + if len(cells) == 0 { + cells = [][]string{{"."}} + } + + var b strings.Builder + b.WriteString("### Grid\n\n") + b.WriteString(renderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Goal: shade cells so no row or column has duplicate unshaded values, ") + b.WriteString("shaded cells do not touch orthogonally, and all unshaded cells stay connected.") + return b.String(), nil +} + +func renderNonogram(data []byte) (string, error) { + var save nonogram.Save + if err := json.Unmarshal(data, &save); err != nil { + return "", fmt.Errorf("decode nonogram save: %w", err) + } + + width := save.Width + height := save.Height + if width <= 0 { + width = len(save.ColHints) + } + if height <= 0 { + height = len(save.RowHints) + } + if width <= 0 || height <= 0 { + return "### Puzzle Grid with Integrated Hints\n\n_(empty grid)_", nil + } + + rowHints := normalizeNonogramHints(save.RowHints, height) + colHints := normalizeNonogramHints(save.ColHints, width) + + rowHintCols := maxNonogramHintLen(rowHints) + colHintRows := maxNonogramHintLen(colHints) + if rowHintCols < 1 { + rowHintCols = 1 + } + if colHintRows < 1 { + colHintRows = 1 + } + + var b strings.Builder + b.WriteString("### Puzzle Grid with Integrated Hints\n\n") + b.WriteString(renderNonogramTable(rowHints, colHints, width, height, rowHintCols, colHintRows)) + b.WriteString("\n\n") + b.WriteString("Row hints are right-aligned beside each row. ") + b.WriteString("Column hints are stacked above each column and bottom-aligned to the grid.") + return b.String(), nil +} + +func renderNurikabe(data []byte) (string, error) { + var save nurikabe.Save + if err := json.Unmarshal(data, &save); err != nil { + return "", fmt.Errorf("decode nurikabe save: %w", err) + } + + clues := parseNurikabeClues(save.Clues, save.Width, save.Height) + cells := makeGrid(save.Width, save.Height, ".") + for y := range len(cells) { + for x := range len(cells[y]) { + if y < len(clues) && x < len(clues[y]) && clues[y][x] > 0 { + cells[y][x] = strconv.Itoa(clues[y][x]) + } + } + } + + var b strings.Builder + b.WriteString("### Clue Grid\n\n") + b.WriteString(renderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Goal: build one connected sea while each numbered island has the exact size of its clue.") + return b.String(), nil +} + +func renderShikaku(data []byte) (string, error) { + var save shikaku.Save + if err := json.Unmarshal(data, &save); err != nil { + return "", fmt.Errorf("decode shikaku save: %w", err) + } + + cells := makeGrid(save.Width, save.Height, ".") + for _, clue := range save.Clues { + if clue.Y >= 0 && clue.Y < len(cells) && clue.X >= 0 && clue.X < len(cells[clue.Y]) { + cells[clue.Y][clue.X] = strconv.Itoa(clue.Value) + } + } + + var b strings.Builder + b.WriteString("### Clue Grid\n\n") + b.WriteString(renderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Goal: partition the grid into rectangles so each rectangle contains one clue and its area matches that clue.") + return b.String(), nil +} + +func renderSudoku(data []byte) (string, error) { + var save sudoku.Save + if err := json.Unmarshal(data, &save); err != nil { + return "", fmt.Errorf("decode sudoku save: %w", err) + } + + cells := makeGrid(9, 9, ".") + for _, provided := range save.Provided { + if provided.Y >= 0 && provided.Y < len(cells) && provided.X >= 0 && provided.X < len(cells[provided.Y]) { + cells[provided.Y][provided.X] = strconv.Itoa(provided.V) + } + } + + var b strings.Builder + b.WriteString("### Given Grid\n\n") + b.WriteString(renderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Goal: fill each row, column, and 3x3 box with digits 1-9 exactly once.") + return b.String(), nil +} + +func renderTakuzu(data []byte) (string, error) { + var save takuzu.Save + if err := json.Unmarshal(data, &save); err != nil { + return "", fmt.Errorf("decode takuzu save: %w", err) + } + + cells := makeGrid(save.Size, save.Size, ".") + stateRows := splitLines(save.State) + providedRows := splitLines(save.Provided) + + for y := range len(cells) { + for x := range len(cells[y]) { + provided := y < len(providedRows) && x < len(providedRows[y]) && providedRows[y][x] == '#' + if !provided { + continue + } + + if y < len(stateRows) && x < len(stateRows[y]) { + switch stateRows[y][x] { + case '0', '1': + cells[y][x] = string(stateRows[y][x]) + default: + cells[y][x] = "." + } + } + } + } + + var b strings.Builder + b.WriteString("### Given Grid\n\n") + b.WriteString(renderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Goal: fill with 0/1 so no three equal adjacent cells appear, each row/column has equal 0 and 1 counts, and rows/columns are unique.") + return b.String(), nil +} + +func renderWordSearch(data []byte) (string, error) { + var save wordsearch.Save + if err := json.Unmarshal(data, &save); err != nil { + return "", fmt.Errorf("decode word search save: %w", err) + } + + rows := splitLines(save.Grid) + height := max(save.Height, len(rows)) + width := save.Width + if width <= 0 { + for _, row := range rows { + if len([]rune(row)) > width { + width = len([]rune(row)) + } + } + } + + cells := makeGrid(width, height, ".") + for y := range len(cells) { + if y >= len(rows) { + continue + } + row := []rune(rows[y]) + for x := range len(cells[y]) { + if x < len(row) { + cells[y][x] = string(row[x]) + } + } + } + + var b strings.Builder + b.WriteString("### Grid\n\n") + b.WriteString(renderGridTable(cells)) + b.WriteString("\n\n") + + b.WriteString("### Word List\n\n") + b.WriteString("| # | Word |\n") + b.WriteString("| --- | --- |\n") + for i, word := range save.Words { + fmt.Fprintf(&b, "| %d | %s |\n", i+1, escapeCell(word.Text)) + } + if len(save.Words) == 0 { + b.WriteString("| 1 | (none) |\n") + } + + b.WriteString("\nGoal: find all listed words in the grid.") + return b.String(), nil +} + +func makeGrid(width, height int, fill string) [][]string { + if width <= 0 || height <= 0 { + return [][]string{} + } + grid := make([][]string, height) + for y := range height { + grid[y] = make([]string, width) + for x := range width { + grid[y][x] = fill + } + } + return grid +} + +func renderGridTable(cells [][]string) string { + if len(cells) == 0 { + return "_(empty grid)_" + } + width := 0 + for _, row := range cells { + if len(row) > width { + width = len(row) + } + } + if width == 0 { + return "_(empty grid)_" + } + + var b strings.Builder + b.WriteString("| |") + for x := range width { + fmt.Fprintf(&b, " %d |", x+1) + } + b.WriteString("\n| --- |") + for range width { + b.WriteString(" --- |") + } + b.WriteString("\n") + + for y := range len(cells) { + fmt.Fprintf(&b, "| %d |", y+1) + for x := range width { + cell := "." + if x < len(cells[y]) && strings.TrimSpace(cells[y][x]) != "" { + cell = cells[y][x] + } + fmt.Fprintf(&b, " %s |", escapeCell(cell)) + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} + +func parseNurikabeClues(raw string, width, height int) [][]int { + clues := make([][]int, max(height, 0)) + for y := range len(clues) { + clues[y] = make([]int, max(width, 0)) + } + + rows := splitLines(raw) + for y := range min(len(rows), len(clues)) { + parts := strings.Split(rows[y], ",") + for x := range min(len(parts), len(clues[y])) { + val, err := strconv.Atoi(strings.TrimSpace(parts[x])) + if err != nil { + continue + } + clues[y][x] = val + } + } + + return clues +} + +func splitLines(s string) []string { + if strings.TrimSpace(s) == "" { + return []string{} + } + return strings.Split(s, "\n") +} + +func escapeCell(s string) string { + return strings.ReplaceAll(s, "|", "\\|") +} + +func normalizeNonogramHints(src nonogram.TomographyDefinition, size int) [][]int { + hints := make([][]int, max(size, 0)) + for i := range len(hints) { + if i >= len(src) { + hints[i] = []int{0} + continue + } + if len(src[i]) == 0 { + hints[i] = []int{0} + continue + } + hints[i] = append([]int(nil), src[i]...) + } + return hints +} + +func maxNonogramHintLen(hints [][]int) int { + maxLen := 0 + for _, hint := range hints { + if len(hint) > maxLen { + maxLen = len(hint) + } + } + return maxLen +} + +func renderNonogramTable( + rowHints, colHints [][]int, + width, height, rowHintCols, colHintRows int, +) string { + var b strings.Builder + + b.WriteString("|") + for i := range rowHintCols { + fmt.Fprintf(&b, " R%d |", i+1) + } + for x := range width { + fmt.Fprintf(&b, " C%d |", x+1) + } + b.WriteString("\n|") + for range rowHintCols + width { + b.WriteString(" --- |") + } + b.WriteString("\n") + + for hintRow := range colHintRows { + b.WriteString("|") + for range rowHintCols { + b.WriteString(" . |") + } + for x := range width { + b.WriteString(" ") + b.WriteString(renderColumnHintCell(colHints[x], colHintRows, hintRow)) + b.WriteString(" |") + } + b.WriteString("\n") + } + + for y := range height { + rowHint := rowHints[y] + hintStart := rowHintCols - len(rowHint) + + b.WriteString("|") + for hintCol := range rowHintCols { + if hintCol < hintStart { + b.WriteString(" . |") + continue + } + fmt.Fprintf(&b, " %d |", rowHint[hintCol-hintStart]) + } + for range width { + b.WriteString(" . |") + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} + +func renderColumnHintCell(hint []int, depth, row int) string { + start := depth - len(hint) + if row < start { + return "." + } + return strconv.Itoa(hint[row-start]) +} diff --git a/markdownexport/render_test.go b/markdownexport/render_test.go new file mode 100644 index 0000000..d403585 --- /dev/null +++ b/markdownexport/render_test.go @@ -0,0 +1,188 @@ +package markdownexport + +import ( + "errors" + "regexp" + "strings" + "testing" + "time" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/sudoku" + "github.com/FelineStateMachine/puzzletea/wordsearch" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +func TestSupportsGameType(t *testing.T) { + if !SupportsGameType("Sudoku") { + t.Fatal("expected sudoku to be supported") + } + if SupportsGameType("Lights Out") { + t.Fatal("expected lights out to be unsupported") + } +} + +func TestRenderPuzzleSnippetUnsupported(t *testing.T) { + _, err := RenderPuzzleSnippet("Lights Out", "", []byte(`{}`)) + if !errors.Is(err, ErrUnsupportedGame) { + t.Fatalf("expected ErrUnsupportedGame, got %v", err) + } +} + +func TestRenderPuzzleSnippetSudokuUsesProvidedOnly(t *testing.T) { + data := []byte(`{ + "grid":"500000000\n600000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000", + "provided":[{"x":0,"y":0,"v":5}] + }`) + + snippet, err := RenderPuzzleSnippet("Sudoku", "", data) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(snippet, "| 1 | 5 | . | . | . | . | . | . | . | . |") { + t.Fatalf("expected given row in snippet, got:\n%s", snippet) + } + if !strings.Contains(snippet, "| 2 | . | . | . | . | . | . | . | . | . |") { + t.Fatalf("expected user-entered row to be blank in snippet, got:\n%s", snippet) + } +} + +func TestRenderPuzzleSnippetTakuzuUsesProvidedOnly(t *testing.T) { + data := []byte(`{ + "size":2, + "state":"01\n10", + "provided":"#.\n.#", + "mode_title":"Test" + }`) + + snippet, err := RenderPuzzleSnippet("Takuzu", "", data) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(snippet, "| 1 | 0 | . |") { + t.Fatalf("expected first row to keep only provided value, got:\n%s", snippet) + } + if !strings.Contains(snippet, "| 2 | . | 0 |") { + t.Fatalf("expected second row to keep only provided value, got:\n%s", snippet) + } +} + +func TestRenderPuzzleSnippetNonogramIntegratedHintsLayout(t *testing.T) { + data := []byte(`{ + "width":3, + "height":2, + "row-hints":[[3],[1,1]], + "col-hints":[[1],[2],[1]] + }`) + + snippet, err := RenderPuzzleSnippet("Nonogram", "", data) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(snippet, "### Puzzle Grid with Integrated Hints") { + t.Fatalf("expected integrated nonogram heading, got:\n%s", snippet) + } + if strings.Contains(snippet, "### Row Hints") || strings.Contains(snippet, "### Column Hints") { + t.Fatalf("expected legacy split hint sections to be removed, got:\n%s", snippet) + } + if !strings.Contains(snippet, "| . | . | 1 | 2 | 1 |") { + t.Fatalf("expected column hints row aligned above puzzle grid, got:\n%s", snippet) + } + if !strings.Contains(snippet, "| . | 3 | . | . | . |") { + t.Fatalf("expected single row hint to be right-aligned near grid, got:\n%s", snippet) + } + if !strings.Contains(snippet, "| 1 | 1 | . | . | . |") { + t.Fatalf("expected multi-value row hint to render beside puzzle row, got:\n%s", snippet) + } +} + +func TestRenderPuzzleSnippetNonogramColumnHintsBottomAligned(t *testing.T) { + data := []byte(`{ + "width":2, + "height":1, + "row-hints":[[1]], + "col-hints":[[2,1],[3]] + }`) + + snippet, err := RenderPuzzleSnippet("Nonogram", "", data) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(snippet, "| . | 2 | . |") { + t.Fatalf("expected top column hint row to pad shorter hints, got:\n%s", snippet) + } + if !strings.Contains(snippet, "| . | 1 | 3 |") { + t.Fatalf("expected lower column hint row to sit closest to grid, got:\n%s", snippet) + } +} + +func TestDetectGameType(t *testing.T) { + gameType, err := DetectGameType(sudoku.Model{}) + if err != nil { + t.Fatal(err) + } + if gameType != "Sudoku" { + t.Fatalf("gameType = %q, want %q", gameType, "Sudoku") + } + + gameType, err = DetectGameType(&wordsearch.Model{}) + if err != nil { + t.Fatal(err) + } + if gameType != "Word Search" { + t.Fatalf("gameType = %q, want %q", gameType, "Word Search") + } + + _, err = DetectGameType(testUnknownGamer{}) + if err == nil { + t.Fatal("expected unknown gamer detection error") + } +} + +func TestBuildDocument(t *testing.T) { + doc := BuildDocument(DocumentConfig{ + Version: "v-test", + Category: "Sudoku", + ModeSelection: "mixed modes", + Count: 2, + Seed: "seed-1", + GeneratedAt: time.Date(2026, 2, 22, 10, 0, 0, 0, time.UTC), + }, []PuzzleSection{ + {Index: 1, GameType: "Sudoku", Mode: "Easy", Body: "body-one"}, + {Index: 2, GameType: "Sudoku", Mode: "Hard", Body: "body-two"}, + }) + + if !strings.Contains(doc, "# PuzzleTea Export") { + t.Fatal("expected export title in markdown document") + } + if !strings.Contains(doc, "Version: v-test") { + t.Fatal("expected version metadata in markdown document") + } + if matched := regexp.MustCompile(`## [a-z]+-[a-z]+ - 1`).MatchString(doc); !matched { + t.Fatal("expected first puzzle heading in adjective-noun pattern") + } + if matched := regexp.MustCompile(`\n---\n\n## [a-z]+-[a-z]+ - 2`).MatchString(doc); !matched { + t.Fatal("expected puzzle separator and second heading in adjective-noun pattern") + } + if strings.Contains(doc, "## Puzzle 1 - Sudoku (Easy)") { + t.Fatal("expected legacy puzzle heading format to be removed") + } +} + +type testUnknownGamer struct{} + +func (testUnknownGamer) GetDebugInfo() string { return "" } +func (testUnknownGamer) GetFullHelp() [][]key.Binding { return nil } +func (testUnknownGamer) GetSave() ([]byte, error) { return nil, nil } +func (testUnknownGamer) IsSolved() bool { return false } +func (testUnknownGamer) Reset() game.Gamer { return testUnknownGamer{} } +func (testUnknownGamer) SetTitle(string) game.Gamer { return testUnknownGamer{} } +func (testUnknownGamer) Init() tea.Cmd { return nil } +func (testUnknownGamer) View() string { return "" } +func (testUnknownGamer) Update(tea.Msg) (game.Gamer, tea.Cmd) { return testUnknownGamer{}, nil } diff --git a/markdownexport/support.go b/markdownexport/support.go new file mode 100644 index 0000000..355ed01 --- /dev/null +++ b/markdownexport/support.go @@ -0,0 +1,26 @@ +package markdownexport + +import ( + "errors" + "strings" +) + +var ErrUnsupportedGame = errors.New("game does not support markdown export") + +func SupportsGameType(gameType string) bool { + switch normalizeGameType(gameType) { + case "hashiwokakero", "hitori", "nonogram", "nurikabe", "shikaku", "sudoku", "takuzu", "word search", "wordsearch": + return true + case "lights out", "lights-out", "lightsout", "lights": + return false + default: + return false + } +} + +func normalizeGameType(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/nonogram-pack-01.md b/nonogram-pack-01.md new file mode 100644 index 0000000..1989a3b --- /dev/null +++ b/nonogram-pack-01.md @@ -0,0 +1,776 @@ +# PuzzleTea Export + +- Generated: 2026-02-21T20:42:05-07:00 +- Version: v1.6.0-1-gd260f2e-dirty +- Category: Nonogram +- Mode Selection: Standard +- Count: 31 +- Seed: meow + +## ember-newt - 1 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | 6 | 4 | 2 | 1 | . | . | . | 1 | 2 | . | +| . | . | . | . | 1 | 1 | 4 | 5 | 2 | 5 | 5 | 2 | 5 | 2 | +| . | . | . | . | 1 | 1 | 1 | 2 | 4 | 2 | 3 | 3 | 1 | 7 | +| 1 | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 6 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 7 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 6 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 3 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## ember-moss - 2 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | 1 | . | . | . | . | 2 | . | . | 2 | +| . | . | . | . | . | 4 | . | . | . | . | 1 | . | . | 2 | +| . | . | . | . | 1 | 1 | 3 | 6 | 3 | 3 | 1 | 2 | 1 | 1 | +| . | . | . | . | 5 | 1 | 2 | 3 | 3 | 6 | 2 | 5 | 8 | 1 | +| . | 1 | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 6 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | +| 2 | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 3 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## stone-viper - 3 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | R5 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | 2 | 2 | . | . | 4 | . | 1 | 1 | 2 | 2 | +| . | . | . | . | . | 3 | 1 | 3 | 1 | 1 | 2 | 3 | 1 | 1 | 1 | +| . | . | . | . | . | 2 | 2 | 5 | 3 | 2 | 3 | 3 | 1 | 4 | 2 | +| . | . | . | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 6 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## lonely-vale - 4 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | 1 | . | . | . | . | . | . | +| . | . | . | . | . | . | 4 | . | . | 3 | 2 | . | . | +| . | . | . | 6 | 3 | 2 | 1 | . | 5 | 3 | 3 | 5 | 6 | +| . | . | . | 3 | 6 | 4 | 1 | 9 | 3 | 1 | 1 | 1 | 2 | +| . | 7 | 1 | . | . | . | . | . | . | . | . | . | . | +| 3 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 7 | . | . | . | . | . | . | . | . | . | . | +| 1 | 4 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 5 | . | . | . | . | . | . | . | . | . | . | +| 2 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | +| 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## arctic-basalt - 5 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | 1 | . | . | . | . | . | . | . | . | +| . | . | . | . | . | 1 | . | 1 | . | . | . | . | . | . | +| . | . | . | . | 6 | 1 | 5 | 1 | 8 | 2 | 4 | 2 | 3 | 4 | +| . | . | . | . | 1 | 4 | 3 | 2 | 1 | 3 | 4 | 5 | 2 | 2 | +| 3 | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| 1 | 3 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| 3 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 6 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## calm-cloud - 6 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | 5 | 3 | 5 | . | . | 2 | 3 | 4 | . | 3 | +| . | . | . | 1 | 1 | 1 | 5 | 1 | 2 | 3 | 3 | 4 | 2 | +| . | . | . | 2 | 2 | 1 | 3 | 8 | 4 | 1 | 1 | 5 | 1 | +| . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 4 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 7 | . | . | . | . | . | . | . | . | . | . | +| 1 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 6 | . | . | . | . | . | . | . | . | . | . | +| . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 6 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## scarlet-sage - 7 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | . | 1 | . | . | . | 1 | +| . | . | . | . | . | . | 2 | . | 5 | 1 | . | 1 | 1 | 1 | +| . | . | . | . | 8 | 4 | 1 | 4 | 1 | 1 | . | 2 | 2 | 2 | +| . | . | . | . | 1 | 2 | 1 | 3 | 2 | 3 | 8 | 3 | 1 | 1 | +| . | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 8 | . | . | . | . | . | . | . | . | . | . | +| 2 | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | +| 2 | 1 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 6 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 4 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## wandering-viper - 8 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | . | . | 2 | . | 1 | . | +| . | . | . | . | . | 5 | . | 2 | 1 | . | 2 | . | 2 | 3 | +| . | . | . | . | 4 | 1 | 5 | 4 | 1 | 2 | 1 | 1 | 1 | 2 | +| . | . | . | . | 4 | 1 | 3 | 2 | 4 | 6 | 2 | 6 | 2 | 1 | +| . | . | 1 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 4 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | +| 2 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 7 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 8 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 3 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## twilight-owl - 9 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | 3 | . | . | . | 3 | 2 | . | 4 | . | . | +| . | . | . | . | 1 | 6 | 7 | . | 1 | 1 | . | 2 | 1 | 2 | +| . | . | . | . | 1 | 1 | 1 | 6 | 2 | 1 | 5 | 2 | 6 | 2 | +| . | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| 3 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 4 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| 1 | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## silver-bison - 10 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | . | . | . | . | 1 | +| . | . | . | . | 1 | 2 | . | . | . | 2 | . | . | 2 | +| . | . | . | 5 | 3 | 1 | . | . | . | 1 | 2 | . | 1 | +| . | . | . | 2 | 1 | 1 | 3 | 2 | 8 | 1 | 2 | 3 | 1 | +| . | . | . | 1 | 1 | 1 | 6 | 6 | 1 | 2 | 3 | 6 | 1 | +| . | 2 | 7 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 7 | . | . | . | . | . | . | . | . | . | . | +| 4 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| 2 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 2 | . | . | . | . | . | . | . | . | . | . | +| 1 | 5 | 1 | . | . | . | . | . | . | . | . | . | . | +| 2 | 3 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 4 | 5 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## pearl-river - 11 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | 1 | . | 1 | 1 | . | . | 3 | . | . | . | +| . | . | . | . | 4 | 6 | 3 | 6 | 1 | 4 | 3 | 5 | 5 | 1 | +| . | . | . | . | 3 | 2 | 3 | 1 | 2 | 1 | 2 | 1 | 2 | 4 | +| . | 1 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 4 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## eager-cloud - 12 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | 1 | . | . | . | . | . | +| . | . | . | . | 1 | 1 | . | . | 2 | 2 | . | . | . | . | +| . | . | . | . | 6 | 1 | 5 | 3 | 2 | 1 | . | 2 | . | 2 | +| . | . | . | . | 1 | 2 | 3 | 4 | 1 | 2 | 9 | 4 | 6 | 3 | +| . | . | 7 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| 1 | 3 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## sandy-quartz - 13 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | 1 | 1 | . | . | . | . | . | . | . | +| . | . | . | . | 1 | 1 | 3 | . | . | 3 | 1 | 1 | . | 1 | +| . | . | . | . | 1 | 1 | 2 | 2 | 2 | 1 | 4 | 6 | 4 | 2 | +| . | . | . | . | 6 | 1 | 1 | 4 | 3 | 1 | 2 | 1 | 3 | 2 | +| 1 | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 3 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## cosmic-tide - 14 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | . | . | . | . | . | 1 | +| . | . | . | . | . | . | 1 | 1 | . | . | 1 | . | 1 | 1 | +| . | . | . | . | 6 | . | 1 | 3 | . | 2 | 3 | 4 | 4 | 2 | +| . | . | . | . | 3 | 9 | 3 | 4 | 8 | 1 | 2 | 4 | 3 | 1 | +| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | +| 2 | 2 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| 1 | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## copper-river - 15 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | R5 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | 1 | . | . | . | . | . | 3 | +| . | . | . | . | . | 4 | 2 | 1 | 2 | 2 | . | 1 | 3 | 1 | 1 | +| . | . | . | . | . | 1 | 3 | 3 | 1 | 1 | 4 | 3 | 1 | 4 | 1 | +| . | . | . | . | . | 1 | 1 | 4 | 1 | 2 | 3 | 3 | 1 | 2 | 1 | +| . | . | 5 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | +| 1 | 2 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 2 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 4 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 3 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## deep-badger - 16 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | 1 | . | . | . | . | . | . | . | . | +| . | . | . | 1 | 1 | . | . | . | . | . | . | . | . | +| . | . | . | 1 | 2 | 1 | . | . | 4 | . | 2 | . | . | +| . | . | . | 1 | 1 | 3 | 7 | 3 | 1 | 2 | 5 | 6 | 3 | +| . | . | . | 2 | 1 | 1 | 1 | 5 | 2 | 4 | 1 | 1 | 2 | +| 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 8 | . | . | . | . | . | . | . | . | . | . | +| 2 | 4 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 9 | . | . | . | . | . | . | . | . | . | . | +| 1 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 5 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## mighty-trout - 17 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | . | . | 1 | . | . | 1 | +| . | . | . | . | 1 | 5 | . | 2 | . | . | 1 | 2 | . | 1 | +| . | . | . | . | 2 | 1 | 4 | 1 | 2 | 2 | 1 | 5 | 2 | 1 | +| . | . | . | . | 2 | 1 | 3 | 1 | 4 | 4 | 2 | 1 | 3 | 1 | +| . | . | 5 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| 3 | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 7 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## blazing-trout - 18 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | . | . | 2 | . | . | . | +| . | . | . | . | 1 | 1 | 4 | . | . | . | 1 | . | 1 | . | +| . | . | . | . | 2 | 3 | 1 | 1 | 4 | 3 | 2 | 7 | 2 | 6 | +| . | . | . | . | 5 | 3 | 2 | 7 | 2 | 6 | 1 | 2 | 4 | 3 | +| 4 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 4 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 7 | . | . | . | . | . | . | . | . | . | . | +| 1 | 4 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | +| 2 | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 6 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## gilded-ivy - 19 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | 3 | . | . | . | . | . | . | . | . | +| . | . | . | . | . | 2 | 4 | 2 | . | . | . | . | . | 1 | +| . | . | . | . | 2 | 1 | 1 | 4 | 4 | 6 | 4 | 1 | 6 | 4 | +| . | . | . | . | 3 | 1 | 1 | 1 | 2 | 2 | 1 | 7 | 3 | 2 | +| . | 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 7 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 6 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 4 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## woven-lagoon - 20 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | 3 | . | . | . | 1 | . | . | 1 | . | . | +| . | . | . | . | 1 | . | 1 | . | 1 | . | . | 2 | . | 2 | +| . | . | . | . | 1 | . | 3 | 2 | 1 | 6 | 2 | 1 | 3 | 2 | +| . | . | . | . | 1 | 3 | 3 | 3 | 2 | 2 | 1 | 1 | 3 | 3 | +| 1 | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| 1 | 4 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 2 | . | . | . | . | . | . | . | . | . | . | +| 1 | 3 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## fading-aurora - 21 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | 1 | 1 | 1 | 5 | . | 3 | . | 1 | 2 | . | +| . | . | . | 4 | 4 | 5 | 1 | 5 | 2 | 8 | 2 | 1 | 4 | +| . | . | . | 1 | 2 | 2 | 1 | 1 | 1 | 1 | 3 | 1 | 1 | +| 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 7 | . | . | . | . | . | . | . | . | . | . | +| 1 | 5 | 2 | . | . | . | . | . | . | . | . | . | . | +| 5 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| 5 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 5 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 4 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## crimson-owl - 22 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | 1 | 1 | 2 | 2 | . | 2 | . | 3 | +| . | . | . | . | 2 | 2 | 1 | 3 | 4 | 4 | 6 | 5 | 5 | 3 | +| . | . | . | . | 3 | 1 | 5 | 3 | 1 | 1 | 1 | 1 | 4 | 1 | +| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 8 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 6 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 4 | . | . | . | . | . | . | . | . | . | . | +| 1 | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 3 | 3 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## timber-falcon - 23 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | 2 | . | . | . | . | 2 | . | . | +| . | . | . | . | . | . | 3 | . | . | . | 2 | 2 | 2 | . | +| . | . | . | . | . | 5 | 1 | 6 | 6 | . | 1 | 1 | 1 | 1 | +| . | . | . | . | 9 | 1 | 1 | 2 | 2 | 6 | 4 | 1 | 2 | 5 | +| . | . | 2 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 8 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 6 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 8 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 5 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## blazing-acorn - 24 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | 1 | . | . | 1 | . | . | . | . | . | . | +| . | . | . | . | 1 | 1 | 3 | 2 | 3 | . | . | 1 | . | . | +| . | . | . | . | 1 | 2 | 3 | 1 | 1 | 2 | 5 | 2 | 6 | 2 | +| . | . | . | . | 2 | 2 | 1 | 2 | 2 | 3 | 2 | 4 | 2 | 4 | +| . | 1 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | +| 2 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 3 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 7 | . | . | . | . | . | . | . | . | . | . | +| . | . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## onyx-pond - 25 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | R5 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | 2 | . | . | . | . | . | . | +| . | . | . | . | . | 2 | 1 | . | 1 | . | 2 | . | 3 | 2 | . | +| . | . | . | . | . | 3 | 2 | 5 | 1 | 1 | 1 | 7 | 1 | 1 | . | +| . | . | . | . | . | 1 | 2 | 4 | 1 | 8 | 3 | 2 | 3 | 2 | 9 | +| . | . | . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 2 | 5 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 5 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## desert-hollow - 26 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | . | . | . | 3 | . | . | +| . | . | . | . | . | . | 1 | . | . | . | . | 1 | . | 2 | +| . | . | . | . | 7 | 2 | 5 | 3 | 1 | 4 | 7 | 1 | 7 | 3 | +| . | . | . | . | 1 | 5 | 1 | 3 | 5 | 4 | 2 | 1 | 2 | 2 | +| . | . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 5 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | +| 3 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 5 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## emerald-newt - 27 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | 1 | . | . | . | . | . | . | +| . | . | . | . | 3 | . | . | 1 | 3 | . | . | . | . | . | +| . | . | . | . | 2 | 6 | 4 | 3 | 1 | 6 | 6 | 7 | 2 | 2 | +| . | . | . | . | 1 | 3 | 3 | 1 | 2 | 2 | 2 | 1 | 3 | 6 | +| . | . | 1 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 8 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 5 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | +| 2 | 1 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## velvet-viper - 28 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | 1 | 2 | 1 | . | . | . | . | +| . | . | . | . | 1 | 2 | . | 4 | 1 | 3 | 1 | 2 | 4 | 1 | +| . | . | . | . | 3 | 1 | 2 | 1 | 1 | 1 | 4 | 4 | 2 | 5 | +| . | . | . | . | 3 | 2 | 6 | 1 | 3 | 1 | 1 | 1 | 1 | 1 | +| . | . | 1 | 8 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| 1 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 3 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 4 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 5 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## crystal-larch - 29 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | 3 | . | . | 1 | . | 1 | 1 | +| . | . | . | . | . | . | . | 1 | 2 | . | 1 | 1 | 1 | 1 | +| . | . | . | . | 1 | . | . | 1 | 3 | . | 2 | 2 | 3 | 3 | +| . | . | . | . | 6 | 7 | 6 | 1 | 3 | 8 | 1 | 2 | 1 | 1 | +| . | 1 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 1 | . | . | . | . | . | . | . | . | . | . | +| 3 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 7 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 3 | 5 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 3 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | 1 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## aqua-robin - 30 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | . | . | . | . | . | 1 | . | . | . | +| . | . | . | . | . | . | . | . | 5 | . | 1 | . | . | . | +| . | . | . | . | 5 | 2 | 3 | 3 | 1 | 6 | 2 | 5 | 6 | 2 | +| . | . | . | . | 4 | 7 | 5 | 2 | 1 | 1 | 3 | 3 | 3 | 3 | +| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | +| . | . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | +| . | . | 1 | 7 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | +| . | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | +| 3 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | +| . | . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | +| . | 3 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. + +--- + +## starry-trout - 31 + +### Puzzle Grid with Integrated Hints + +| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| . | . | . | . | . | 1 | . | . | . | . | . | 2 | . | +| . | . | . | . | 2 | 1 | 4 | 2 | 1 | 1 | . | 1 | . | +| . | . | . | 2 | 2 | 2 | 2 | 2 | 1 | 1 | 4 | 2 | 7 | +| . | . | . | 4 | 4 | 2 | 2 | 3 | 1 | 5 | 3 | 1 | 2 | +| . | 3 | 5 | . | . | . | . | . | . | . | . | . | . | +| 1 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | +| 1 | 6 | 1 | . | . | . | . | . | . | . | . | . | . | +| 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | +| 2 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | +| 1 | 3 | 4 | . | . | . | . | . | . | . | . | . | . | +| 2 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | +| 3 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| 5 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | +| 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | + +Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. diff --git a/pack-01/issue-01.pdf b/pack-01/issue-01.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c5dbdd295a89c3c6f2334fb0b88ba39073b87186 GIT binary patch literal 85063 zcmbq*2|Sct`#)o(v1f@SOJz&hvTs8wWgC%_tVxLMvJXZPS$ZVI*iuyXlAR$jAzOs( z8VtrZjcttKe-ECgXL*0W|NDF2&*z$RpY1-&b*}5a?m6G<+(L%uFP@Q>I7@$4KuW;% z)?Ip4RmrP}6=Vx_ANQo&vIz zw7NRIoy%=XE0pAZYZo1zz3e;$BriJKc-ft|vvs`aD`NSu^pBuU2kDYK2 zJ$|qIsl<;!LZ^b(Mm*~xJ6rRFlAz}K3MKgUmS^ax)vxC{zn}JY`#SA6?v-^b=T+PH zx0jTL?4Ik`WhDq8tZvOc6?r0%Wb;s*#r9#M)a?s%<0rPCuan3$0nEuJlDyJO>m<5n z)r8bY6uK89f8;v{();#{FUen3W3IfK`l#4`F+xL7Jnf_ef!B!s_=Zr9x*rHK#dxty z@2n=hqws~dQuRA$lgmv;9yW!{M;J)$(8vUxG)}iU=_D=_CToKDdML0_t~?84s`1-9 zu9p%CD>{cyNxrNVxX!9S`h!KVLs#s?rMOqj_m=1#REXahcp6#W?64dwN-5*v3%WOV z=Uey!YT2e|iDVqpW{ z?{n3^UE{|@-uomT+koQC9q?_+k2JTw$<$VU@Ay(Ue`?(#{iTQ3hw;{LT<)sNsT@?Y z=Q+a>^JS!J@PYo$`ftkoeST7dF^e6MblE5_JlASQTJ@K>%~yh&N01!TZtm|{k93-p zH`}6eCI z`F42gEZ)K;EOvxi$jrBxoj3xQOLUf)uJ!Yjoo9b&eQV{Db1WC3D1%=eS`(}~ax~ah zA~(0Q2&I<`D~jcBAIc_96`v+01+Q)fnq<`Gr?5YBe$hF(@NNdik~G^q9J~=!l}w6g zTWJbBTh3y|-`NvG%nzN}zE$^pC$OTLwAF-Ad{MH>O|e|@ zVY(k~57FoFt&0^uUR!?Wb3I#$2(DY#n{D0RQcF5cYhTsyTS3KSSZa~?vROKXlGfy@uYhtv$LnC{xImoc z1_QJXQ7i^ePAOFzu#)&@S+l_R9trddo(s%SbJprD@)&EBKQ9`}_*+q=&I2uQ3FDzp zr?nnbG9H>FIkQCQrN>C7J8xv!dg*D4eAatY$i5=odz?EhQ{qB#C7l^qD)i~`kid+* zmlulD47dAY3^-52?XWDPp=1%Hbh;TU_GygAY#*jpiCUWhFc9zNaKNLRTT;Y(ogAm5~~-26SU%Hpxg^0Ntg z9k<1;&lg)4c#$5jj+oLgj}~`_3lPnv4h1f68WrcQUiZJ;z|5S9C}PV$Wl4Wn*swvG zt3mKOCETMw$32swN#`04jhZZf`s9A7h9L(LXJJJHTtb~Po(j3@mXHZyuB4Ck zW#{$a+$X%S;^e7aI__Ia$mSPS{8&xD$Uaqba1{D8Z``*3dP8}iJ!hbEh_Ekg`CY>m z#O@70^xZz2?9->z@h5q^^mtsNz34{f_`$s3gG(6HCfWsVr!v;Cg0NrRV@j7uG3RNW zuB|Y0vQ`a+IeWCrqFt_d;5%iW6|8?EadVvFbgD=o8rC}-F0m-(O8Ev=(v6v0i8#zJ zNwSxkp`b5p(lSe^y%5(Ro16~p57BxczMkyIIw0RJGjz-^~XJgK>u#`PdkJn zmiU|OUAIR~&`0afp{tTaVpj)&Xp47s3z;6f1FF0b4(Ww{X@Y+KMhW-mPjS=?w()cq z^uhWeJ%+yP9zU<;j~#!#%OBFFB>jE2UH-hVwgLDv_(E9me_~MK7xei*F(_2$27MW+ z)2!H*Ms`P@-J#(HxE&BN>qD;FnuYr$I7;Ccg=NUdTEDyNmo*KBMM5e=|W*N3}T~uKP!sH8YI$7K|0%NKp*c0NJd7-u2o#s~-N% z76}-j(z#wQbdNS`&j-ZUzb4f!3K+)qm5Y;W0m_x@IaoU6|m`C>np`lqKVG57}l zcR5DPw!RpHuzvIANrSTJ*$M7mp=`^1IT9VgA4TdhE%B3bZO^N{vUKt`dp4KJd+2k1 zI5b!lRm;m4Q+*aqm|C8gn((a)3@izDwOnfFuf9&&A>=0Q6yn}1^m}{83#QJz_jSs# z3s%W!Rv~OGF5z$Ub>`xq67Mc{W=Nv(9f7L-mwx)nShd5J`1nSTL4!AN{DfKGx@ezf zol?oQB`0-KX`_0wq>9z~G>CMYbqvD~I>NhctV{~Y;wRe~v{w49uh;XG-SG$3FUMoI z{12hDvuAlOe(;u*yN$nlZrLatcW180#lfhgm3@Y_iK`>+OneX1Vwgik14aPfyj|UJ zxze4wrv1l(?d{RLmq)MHqff3ibB6GZ!cLggns$fT%rq3AV`$5bc@+D|dvZuPj@Hs%?d}jY?q32D^Rk_pz)?!b@c#iT-7**2^kf_V!fqG7R?x?f* zhDZ<5jtzOPgG@8J9N0@V@4))*&8a^UwA@>>6sHW4_#8!`Aps7n`Ek!C6_M9bl5Ah( zzHuxe1%z63*v#V&!4Jl>9XlR{W4#hB_@jpT(A1D<<|_BZ`)7iMELWDmH-tFjGx@Ii zi-=x!wiI55>Bk+PLDnmY3retm37fh5tqc5`r^D%+VO@-Np>Jg4Ps!wUR=YkWP4V|~ zPlL!ci)mDL@*>Ga$6ND#KndUPBFyfz7-&1)pLxY?*KAp^dcIz%HM$ml6vAnDIR1fN zYrZF?7AWBc7W^vlyBXrzogns7nN;>KCgX;8(%2segq?j1c9dB0m%*h8aJna+)DZL; zrQ!G)$az>KZ@>B@d-vFn_C>DVdP=SF z9#@Y{iSjhxWHO8ue8qd>ydS5z$qOvvY|X3j!pT|PQLi??x*FD+rA?FNrcHu<*16?6 zYJrX84YxkIow9|bj3;QhHK$U-RzbxnRwQ6NfY9Th0nx-w;db{W+T4mwZM^(;BWgZZ zCGzRc<2)-ejyIF2AaW39FTJ77UJvca3r=XHgvjeVibdO3m?@#VB^gt*nS`9I`7tJc z;Gq%!3#Oit<(AA!O`Ka^TJJqT`x96oD)6e;Y${;BM> zJ}c%PLKF?Y5(|03%l^qXRiIT`Gr<-iTEspXG3zHIfKUtw@r)FpKfCHjPSYxLItWV(wW;fjBM~5#vg)2(n+^tg;%oo^#_4M$G2&dQ{ zwQ9Krl_D!zD+?>9Fdt^B1};c?P<;=W6-hWnVI>e^P24*2C@cm-sgU*d>wD$P28~lh)oa72(JcvfN0SkWI`DAchR+AkmzT)QR&j@Aue9myDflXFTRC`Bybe{*n?4uQS zc>w5g9$!k1Rn0p|MGjZbAROdcK`I<8Z87Q!D^Baz-YF<3Vnr@=${+0lTj^6Z+yKnq zp9Mn+Y+nPRFoG9xw(!Mhpkp~3K6^ns?A{rdkNjWM;HaUh-H~nvzE?F5x*}s^s zwD#EHX3mG}1P=3N29vj@Ti?DuVi(W=fm67+^KK#-+s-vhCs4sRLM+D8+I)R^F4tH8 z{Nag9w|P3I*1kB~GLpgXvEHb48PgjSw6Rt~BJ&e^RNMV(5~fljyerH%C&=p<^CH_I z|5jKVsd5rW@?L0`T^YedELNipSEAsT3O(zeGjXm4(%9&Xd? zSlyDO?iqrA+NQ2$QBTCW)@*KJZ-VFgMiyxiKe1$C;AdGCH&PyF@kXv7NFi44*;h5o zsxk6v8_Y^w9U8RQI<$zkg8Tc!30t#2@tf@!mJy%0kukoype-x-$|@AwS9!m@l8}&C zc@&4dyI58dO!TO%TTl<)#IAk~+V-g29wragl76-kNVuWJT*)&=n}M_Nh3)0p#t7I_ zFcHtcGY5z7Oc6KB+8n=(scv`uoJu-j9x9}9%^3cWTT7LBxxj9RcO;Cv|vNc=Wnz1uAMveMy0h}0} zASAQ~Mo2oPEKXLZHSDa)T`kv|6gtTAKC$DFx`-x9 z4K~!X6`$#kka;!H;puCHs2ej_8<7@G&$R0C$8s!2q|Dvl`b7IR&e*Ih!vL?8yWJtZ zn3R&EVt*Oc@n%>__il@0jpyMEPcUvx^`4kzdgJ!g!M?hj)edhX95P&`|MD@yw6bHQ zGE*v8E(0q&ruI>x@tdQuiSbbA1V^(Qy{+OyY2-(41DmtRNp1rVMiHfO<1M6TZnW(w zTZI)}9&Bpp)}}(lTky1Di~H*zB4n^)Z!jVa>gM1K@_a3_jO*m(K~hEV3D;*eK<0dL z$J6UOPrUbXLoE2uuG{}yxurQo(*ViY?t8t(J>PL&Uf&Jjr{l+o4zb8#8;}6H%KI>_ zUO-UK;y*Ez*Z>(w)b5PG*ES9&`{T-hrmha5PlYbnwtPp&zmMa@a%zvcUVlG~rUw#t z_{G1dUrP?kFWxPW%J76I!7r1Cne6mljjqjZ-Vz+zI2C#?unDH~xq1D;c1oi>+zsJ) z8B+zlxJZTLI2Gzm-vm>>=oOW1dpqs8JdlJH59OvdBuXe#r+s9fH)BdWzQNrkrW%?d z(kF&s2VxD2+{=S9iqvU8q^Q%516QD0$9d%oZU{SlTeY=^9})+^eP&zS6=w@RfJFuh ziSM8X;KF835CaV-CDjAN7Y-Uo^$w{)VZ|y3hI0)e`CMY~j?vei26lr`U3h^hf!?6l z;y0oq^@v2#5Ly2F0!Pk}m|nSYvP-Vih=C36etgdY2Ne7LgG-WbO5e?LLeT9I3B`u{ zXp|QkQfBetemBG+4$ip6Q@X|-$mkH4ZoPim?Yfmv@!IQ+%UH+)UNq|i8WRWyBSY3^ zAj778e75YSlv{+&c5hbIt5-SS0CXP#{C-`V#o9m8^})i}yI^GQif08G2ShS6_=FtD zrG>U;kLPpQ%HgNnaerKTU~;bHt3RLmYgezECJC+f6l7G*&g6DO!fQspj|z!es*1ku zK4hjja$!KofuBCopo@M^A{3X7;7PNdH7-1^8DK20teIC|Z9ATOoKsPensY<3p^Q0c z8C(EbV%zZ3pp8{OPypf=ao|q_Za%dv(XgoMpkSEIUP@o1TP&Vg0MZt5;Pg$h_Th8J zxiQ1`s@!nkx1m=*aCO`Q6nyw~eK(}cscxQq!!mQPwh!ahjlJlGBG7p^q*?k*kGdxQ zaVRtIu`KWL5zXcPPhR6Q;Gqi{>yWLhFKHx$c(O0HX3m#7W@>}0kM9ZP zgqG8EYMJ?Whla7229#EVx981hnGG~(*KEi8lTF6Q!B?$e;~8!x^t?!Gu^|=dYOoy_ z&<$@W^E_Qckn1r;K#+kNA_#AwE_tDGq#6vDHl+iapaYsHSIwuP6Un3rK#aWC*v(Ye z`~6y6&{+A{p!oEVHgO$tA4VgjxF%ooG=^Nx)d8NwEroH;@lYoGyH z16OZrLzvYMET}I_gl0tc0i*5;C3DZSmBJof6d91%tN|3@Ojl^GcXi||^;`kzW2R~; z%=yboXT12n4)}PVdLw}n%SxCZAhsd<9HC<@5@*!!!&`)yh>9V#1_uoc!%KUEK+wj8 zXODEO*IR|g4sX8g)W8k=#4i}rrx?S&VG2}r>TbZhcj`DLiM}pOItGrvA$0_a_ZK>S zewM_1!`!>BGdkMci&Q(o-+A~+O;EF?C*xbbxUpt;5=ZGu>PIa1-Q*rwFYn|YXmj!d;D&3B%cETNNE-8z3nf zJ`l^X6NV7IKTq;)1!Rxie`EBIzpMpNcxn_}6(nK**(j3@V8`&5FkT7X~x@C{&Z zXYe50Tn^ltCRj+eyEYKoUHPo-lNKJ^0S~H;cJ|YfsBt~b1T+=Oada!kkB~au?wX>2 zx*R(u>k99wA?g#R71~q(lxV^4SNT;x9#if&p#C^}^n5%w>Wwx=;UR2wNH%w{E^Ux> z0{%EHZY;2FjH=$Xgwpx=hcN{ExB_e(7E06?lA`o<673p~Q2-kLF--|4H}pNWgq7e= zdG`yWQh!z1EzD7br?#%$(4JLz*eQLr`oi}(Z$PjXB&9cKqX5w&&<|m&vnUJB^SaYV z&3a~l$UTTf-OU6B(4|eCA9`R{qcT8eOLeS&DGji^P3?bHn*T)t+!@tQ580pDQzu}; z-EkkWGr`Zxq4{4~{76R+r*8XLDV)=aX0qPKgX^fiKpP;-aUyQCsliu4gt*BKZI7)J zO54Bj?ufcj!b`EiC(+P&41WuLuQ0oU_(zq!!YE9J@=M3w?JL|nqS3(|>e^B(dGI>2 zP_gd%CbQLi;O4W=^~Xn1O&toTW3*BzT9JC!^zqeW>d)%`cV6x>TFm7ayZ6^(qd@Xa ze}+c_Wl1wmNLgkkR*`4X`qmPxU>+6z(9OW$Py?;#-G{~y4Is@5q5f3CAc{1z0xxU1 z_4F_Pa}7?x%HmvA;<-cLQlzO9FAi^0dNu|hVcnM|6m87dEo|zTYCVpt9$;;%h;I;$ z!G9`EzJ!}MBCh5IQlEAwq<(65Wy_c?f2ZeockK2=k`JIfThrwufvAeqF$5M<-bl4l zJ_Bxdtp*qRZl>ym{7E$cJpn`+(4Jqa@hbrc?mty&D8A?2U6fe#7_gq!3$$*}acdEE z##1@En26DY0*X7sbW09Oj;sP0SA&$jIOvCy+OD0jq0IyAcT82J(pcR2fT9sJOuWx`L2ws=ll+_F|A~ zD3rDgbQN@yI{rQAq&@AW4*DlZ&diDes+Jpn$r4Z!*YLO&@}ulmt>3fqL*ND%g1aAl zI(SuW6G@BEXiEuSyTJa3W}zA*C>GL?()<3JZy)~%2LAno`Xl=PnRr`pDJz-AEO&Idg+#tdtV$)y|>M5P{kJ38n75A^p#G-2&^MThwI@hrooUAHi z*FxxgYe9%zR`7#xfVKSV)D4HPg-quED{i>|m1h)g;Nx``>INMZ8J$vc7Cr5A?K+06 z^ma-D(1qhLpTNaAGb2Bu@u{I5!!nky!BaaJ{

`a>;WAZ_}P9)s#Qb6JykrEtQ@r zyw5+kRHP{m68n1~|Iwbxu0I8^JF*XNOHgYG4WWm=b*PCoy5zII? zI`%4=#Je4sbW2Apu#SY<(dy?9M0QLCKIg-|!TMRilGZGgB<0puWs*G2BncR;Haq6Z z=9h=IIgRv!AQ_Dc>w^6}xng?8F-j36&yA6>^x(klEf0VC9`v%CQii4a7Qtf^y|jd4 z85@m2wN;^r&x%l%WoxI})|4dF^gf_d3iGyCiGf7!^tvsq2Q#b_+XkCRTAn3hlOo#C z%O}{Hay>VMw(xBjOj1Nf@Wy&@BaEd}GAT~5Nvw8Sw}`8vYfv0rpKP8C z{FA|!5)(ZZskS$j(r+Tg(Ylefo=D>;r@96LPfuW2Kc8+@L#|`ZrOh86MI~nxHc~L= ziYS+ovB12WdUC^73d8{$V*`&oLRIsd2>Ei;!BtCaf2zq>9#V|Bp$^KARyWnQ$~~ZM z_*R{JaGTMsTK6y~osyA?Mmb6MI`;^tG}pR1%--ZP^l)o9viVA2e-7Y!`zE z`CGgBn?FDkvVT!@Hq`sb-T0E?v&XnE22-eS+c0)p3jSsB^DA_M*3lPfV*@viwz4^B ziD^^l2y}+?*7khuxin}UL}#(&sZ&0)n*kNwyd{ldh>#(jAv2v+A#*10FS6!k78T2R zrV?VKxJ%wtQ*KlkIyo@&B`5RABlW@$l6S`Vc*s|Ac?#;M}(!~W>nn`1PHf1nMOO=$H2)VB#0CY$^N<0 z7fL-H=?QucWGpW}qeOrk*}c;hMV|P*yKHC$3s#1ig3?5y`7vM2rtx(bx-dYb-n<-4!09#aXUh9%rnA?wTNmC04Mis7Iv{t=Zi(AGwf3)N zP*n02sc&g0Y!D7r#rXqK8--9+eV~Pw1~${9XsBMh8npp)V*UN&2FwHwYzK^)5i?mY zWx&jcYYFv0@^VAhCZ+gl`*AH%Vk`#uv;BLE1BQ#D2pEgOV94HGI(do4nTb)rNEhH3q@>s5fRt08V4ZgmF~ zmF9)Io`$)C)Ras?o-fjQk?iA>)(~frxt0c{y%GMA2~2m?bH7QTH7y(_G_?*}zbySe z;BLtWL$v*y9NbJ<7*nip9d4a1*RHy5L;x8S*l%S#Pl@xXnG+9uc09sgG&oG1_l9rG z6r^T;Mg6`70}^wFX_KHRFIA|O<`He)I(5dQL%MLL(>7VE@NKTSy7EUQzT%lq3_ij` zG~E4QW`4B~k z$1b_g+TXx!tUv?6nH$NV))2ny9%B94R73FNRM^!ERH>`?R3oX7{an<}edXE$2kVun zz{QuXuTz0CAp%q#7pRbrsT8@W4VkIkBtYZl;QGpgA)be*rdX-4GMP$T`?(rQ;I5nI z_7YU#ES&YCBoEGzdnkP8T{la95VupU1wUvxh|C&N;3~q(8RAOC86s4yk_obgT%}6T z+NbC|D$$5v#C@I8yqgg&y&}M&nMyLz^PWL7GB`<|;o0Bdp zZT>+m6>Iw|u)hu~?_WyujSrON6og5{!JspO)C25K0n8;ikrxm(>qnY&VBW6q?d#P|m_R`7&-aQ`BAZsBKS zE1GjujQl4A4ry4nk;d50QPtrdQ_16oQlGGfENvbSY2RF;9Y0D18aO$i`at9V#Hb^Mfqw?x`Xw8K?m#mf<>sXRHs5lz{jalsQxB8 z$Nx9Us#lGh)upAZbaGBb4SACvJYKC1w#K1?z!ZThUR?kqv?*;<=@wsM1=qX$pVqk# zz48iV%}u_&!41vK_<+7qH)&hVPBZ8ew9?a8E_wbgG>8UR;?N^Hq!zYl{!?OZvGA`M z+E6iV=#hT7QS2|0DcoQA{q^T1pT=>=mS!+!XE5>)cGko~-20opZi z(+XHi4O(l+Jo9TRQ_!De{qKTq@=N6a-HU!q)mOEntL#S?(|rPm>NOpOM_UXoRaYg9 zT`O0Jv)dpQN_;E-)~@PMxAaqVdvQ}{yC_L{2xUQQd`vqRBe~o`gQL> zw`sTZPakLA@+lnSDxA===ug)5t$YCU*We9kAwOUY+OD7+&)j*P#N zE6B|$enx%u)9fbkhnK>4-iY7;?g*6Hq^x9Pc?3$8d1plu(^Hj?*+P~lxew-Iu`)rM zxSGDCN_Dju*if!qoFw2)ocz@^0^Qjd7sq1Tij&#GH6}N^^TPc%f_Jpa>EP)ZZG^V1 zO7#N2@9Viqvqe*Wd43~Si9b~rCEKttV~MsWPCV!dJYgAwX$)qv<>|k-;keO;d7`U- zlW)2*2N{9JPVjGIR_bZ4na-v&6QZPSp1D%FqyQs0XUPeQyKrzR{^pf@Y z)Y-Vy2n{A~5!-UdwzqLRjHp2Wpa7y&v?Cu1dlbb#smwIS*P1w|sz->Qlb=ko@nGF`NomfN39?fFRO&gnsJc3IVI>!K z`yQ;RC`&1he17^qm>{Kp(+szuH@%d592Rhjl=eW^ktJS4q)4|FcdTx~vCd;i;gqThjomThN*{1pN}`JP#)}6- z>C^u3n1mnzXI0Ag(<7wiRV1dQ*nMNN#HHlCEy_}kYd^nuG#>h@**&m_V>wSM-3nfvv4ZF?hGM zS()t85AF%WXZ5^qH!eWdmGs-BC$`9^Q(l=M5U+&LU*l~Zn~MCxUs*t=HW>4uT5B3^ zptbt%-gg586dS5u;#vB$e5i+H0rXQA&d3aeC6GDOuiJE1L~rZr(~6qaRzLANE4J1{ zGK!ktUE2xsrw7!T4Wz!6Np!Fm<4r(A8}aY)6max=kLp}ZkVpeMmhnhm@V$E$Gy_UmM?)B50Q;xA!Owe zOAcg+Nx0Yr3)$i}-Or1#rIZ?ChjMLmA->noP@qtD-VV^x zTUwD!wRGW3H2}y|^9PMR>f-}2FFv(~37wZ}&BDb1IJ%`=ybF{1ri6560=O|BjD1-Q z7}X%g5dd6utQW4}op_=@Xv1cTR`Y>&95P+`r=`2B_V_2b~$3_iAv?;qei$jtQ~Es#&iYR_I~ z#bXN6IzwK0QV-QH{k$ z(Bn@+_BtehDR&d%`#kjADwsDezXJeCa!;f5a_ns;ML?{vXj??%h_6$<_mJ_Z9Zh0| zf^}rTAD83<`F^hT@m512<->5V3OQ?NnJfUI$^tN?j}nujGBwix`v#{04S++^tbyT4 zJt5ibT_M0aoL=#9phP>_xe_ z+s(Ahq}CS6qz(Y4stj5lH!l!R zF+UCJsN>#E^+hy$c_MBiUwmI(1-9yd1-&$1(;jA>nPZ>nl{m=pnbgb=mRG21?1Vw6~F z$6mb~Ag6jjxq$IQ8GbLzpmhRv;rC|Z0K^8>tirWG!D@E_-UGagqcCcHcZ@Lh(t8!* z!1ObOGpW((z}2PON;V`tHEQO8t*)4rO=637xS8qpnNeWj9Eg5PhfD$qD3 zeT;N{)_9p!)%Wlju%FINHQ{ArYe_HQL6amHU;eQeXK&4Esb06kT0C7tozLewb@gZ2 z#YJ8l@%umVy%H@XI!uc3jY&+Zd7nz0{YV~qNG7pnM=@iX+^E-nPC-2Bm}`Ub7|t1-{;cx&MU7 z?!&Er2kWl!YQ^0oeH>d4ue;dbtz*YKrO?u#W6oGvY3amq2yhHm*J$QFJ zdw-)l_0LyV@$u6k1tH_$d@9&@Qf2|wv--boXN$V*ZtMSZhx=bZ0GYm4)OH!heW*Nf zyxh?jN2{EjQu=n@3sz-?VSTjtrvol8hBK#+F$Ei;Ns9;oh7$K0WF#e)|z7~RU?fFqYK-_4@l8U-9Kx|rp#jRL(CmE_t4JQB? z7a>*oz+YflV*p?N6F9qv7XB+3hy(4~*0+s7lN1Cu1Hf!nwxT>UGG!>qF2*SZQIH9~ z@HvcTl?V^GMZsq`oz=r*)8ev%>J;4e0QtjvfIRSA0Dt)}Ao&&552&9fnF^#pCL2bf z22LcS9$~kgNVk0u6BPhp^ArFoXx9@$xa&RDuG_519{fVv|9wCn0M~}M zqtiKFf9&UukGknZ`jLL;k^&f*BCJVmSLSVJ-V^t{K8Q=FT;>DMmeMVA+J!S4gk-6O zS|RQ@JgW!%3plU;F81Ose?hAMaO|k~i?0H()KRuG?{aGIYKaHF$!be(y??fJEb~vg z_D8?!sT9D+HGGYV4*FBx9nif6NZ{q?StbB14tSOPC;h6vx*YuK1qIdOL5T&%jNykW zUf!!Bj<~vR9aiB-5x2-BpVt$6LN=b!_$da zLw`b?oVY#+&#kR(9G{{Fo;pi#l`lTE;}j8JV6A5fA)ZmxTpto_2td67|A%6|1aBKk zIZXBzW%EH@9wrOpU%_v#ue}nqhcl#8TpiZYi{;}KJsL+expq^)>E$@$jH?Va;7>u) zCoC$oSC7dqQSf%hQF4yodg}CuYl_dG5`aqWRrysPz?E?cKTnNm?!xg_CyKWZSXm~n z73k_)k1_+^Gh5rB4XH75H^Z%EK$tss(ll%{P>^1l4Tu!r?VypFUe6K=xhf-g(o&oN(%XNxTwE2hfFqHx zAOkmrFikg2$En&rmAx7>W+LR(e7&ytO;;e$_m=9jykHs zuSEjzT0q3>jttV&6jd}pq)J76UH25|m(u*hCu+~V%IwJZ=~% z?bJOR-vxsYSnnE-`tO1Jn5Yk}@mE{Guyjnn2i?LB(JL}o3#lnmivup~J0?`m0o$SD z@F#l_HADf6_hH~an~;BDRYW`JMQu}7)(dvu8t|zf`{%i~5_Qc;3_O*?4tEK6~w>0*4P{ z8S3HA`oxLYsimC2V{_@o+uJ_QgF8=#)+^zd9n6^H4(VD1|D4g+YZ2v16PU(5$NFzs z=*54rW%OU3N|yfHvE<$Kzp-VcAotfZVM#EjqnBBmmIz{V6#;JdOmCBp7)LXa|)01@wIgEX#^cqJGRo@V2K<5U?cCKI-ude#vLKw zU_yL@>s-*OSP&p%|e~9?Zxyr15HZ6>0F7-#{sB~<@#`$ z=1g&^K0%cqI6bZ^MQ zfE-4nYi2SU1IDuxU+7;(XtR8|61w7chgL8<%b3N0<5G|PH35C7znfl` zeABO_x4fVVL$2+vYNlzNnq7&v^zH#&_6t=#aJ(a}w*4*rc zdrQ~LNAs(U-j<`+PGyMNx+@OiPx38sE*7&l*_ebX<`aGtcRX%XmSoPWQRZcA_HD3< zflX%4vn5GI)P|uJ;ww2Xh1+u`oYglmJlm`K_Q+dvnX8qzU*l7<^f9yr=0pQmIJ@0# z#ybqn6?hNnvaOTZ9_7fJp{0}gpzpJ10|d?}Xb%%-!_Ap@!1(Qqq6fxlL_zIjYUGyK@`MwVk`jbtVSSC zknsqX6mOkxv74-qx<9o3U@+1CHWqw!MBiz3C+*sj&&6|Bt$d2s=Eiouv4{yDixq|q zas{iJ1^cqcry>G;yI6=%qSKjQy%rdKOTJt?x>TCX@one6S!v(g!eT^l*J@a1~zY#eZu@*A#p({6o^pS(CldT8M>SLGHJ|CC-L zvgfYldM?4e$~bOmJ2`d^ABa@XbDi7Xn(|0q>_vOFpN!L9)hA;|W>*%Mry7@n-*!e2 z?5d2-5bzRhb#iG_M_!T2^-rc#TJibMR)4(nzp>-_yq7GY3`xNtr;}HDnoUb44)|Po zQQeSz=$<0Va*7}CT=%(QrmA1~NaO{-rI(YqJE^%NGD?rcRYSjBFsf-t|XILcK&ds3c|v4s517eimcdSR>r)YVw&cQKDk++T`Oja$Ae%wq;P)@B`eQ%&zT zQ+s?RuoIRbs{G~xh;-6{{fSU9>l2TImz2P_Yal@Qk{4uTNxIC{ozC!C3VeG&L!(tt zV2bq#UZ2)W=+TFlz)q6rb!sli@LB2`AO`9qV|U{6*W(vtZ1}(CY>b0T9(`*AKNf6a zWil{CKB&>_TF;D`9RPQn6mx%`8Sujp9LDaelHqhs|DH)BTkl)<@4C^YcSQ?Lv`XHX zH2M|rw3Z)X$2?WcHvbymIF|P^@%s#TwfIANCSTb_5z!A{`qrp|_31>4uRUgIQ!~AK zp`fiBVwe=fau_lfY3&8-wS#rF(R(W?Q_sB6rmQ{$8Ga*0-qvaDhYs0?x_%duGwOytW3&t%TdX z{s>&bJ5SB1iHBdL=z+tV^dBaP)Ftn)c~!1Cq7VmSpzMoUCGiqlvnv)PTY7;Js4h{J^mm$qI92GpoJ=wOZ>3x~N{e`8O1n#r{ z7S<25)Vr34vqdy-Z+j!I^(!O;C*-9xAA=tsX?_zeltz)f80C3h!9bLY7^o0Z^y&>BgUO@si!u^sq~lAGohalQSgJmZ$J>xcf2^AVlIpKbdy%+Hck`jCx*u2M2 z^G_|ud2DDaZQ0fkfiRnQ`-&ym(+~cpE&YZelM^-%gvNYFGp3H$?~P zwtsYrgZYnULPptFjyWLS|L-R^TP_?AwtxXAH&Z#L6+Sv4SS(TWb*4Md$HQnemM5}lj6giMlIhrnuE#Zv32P9FM0^+db-O5&mLA=mI`fWbL~yhM zB?L~$w&n#}>UOMvHSzNO<>>;^`h@bYcN#XQ9*&>Vj0V>hgTAN;u(jYL7nOq1!Tk z`1U9~mT=MeilhZ2+tVt&Ir#eZ-H+aQPP{<<3&Zwbp4^oA+sVz{^uJ};q~-r=*xFr= zwg6s+03=EI=)g`~;zcACHJbpdm4>jGcKB^U4_O)`P@YV#s*XDT8Ac-tE5>frb!^tONqhZO(?bWe&V?*~p zuUF3m%1b=@a3{cZ)Ky_e`kM2b>1@ApIf>hm-7h&lDZL*_g^~$G0s);=ErOYk`A#NH z;PIrPh@GI|_vG87uI(uKr83mdyz((Cg#>GZ{xdi3N{ewo`gkQtaai(1v#Q$GPO$>4 zlhnvXXdG4#4*Gt~=Qe#X>{Cw&{ZhB{3q1&VB zq2}w5mQUPG*D=`@cShHOYk0pb2>V_B4HN&$H#Y6V*I~}Zr-}g&(ZY2W6GE5rultI} zGhjzm-Vo{IZp=UXhHr{Oy;H5b5E;6&HU4%|9OrooFX_PX_Q)}Vjx`C|u}>=OVb89@ z>^zF@9qiVv^`JY~@wIR`^ch>?@i!I*j}ZRiWq5>4G(sky_7(3w*#)LedCEl<2oIjl zB723IP$z0|X7bWY)pfXz8z&+YQJTI5uXW^=G(Q+&WE!5FmrG9-7&s|!Zjc#v%LBw1 z)qP^epUcaz7_2PHZIvscEPC-{lZEoa?W|Xr#GdkELR$0>by3mg%<&o)`VP|WrM3Tt zI%AY!?FKxbyDdUfS}C;LB=Cmtr6SHo(Udd+-$=jb)(O0gF82}Y^M`#VoCn$sx*|r7 zH_T9nk4%EO?>`1(B&>hWG^Jsz{thvE;*bP6?9|n1@t%Ffv%g8MUh&ML@5fwBf=#q> zxHr-$)?G)bSqvi{)7JY^p52HOXMlYxo$qB0Gw6Z=4Q9VoF2)LXg{t*gOXqL)=YEGs z6-x@w$q#1A-OhzY7 zF?1vX|BOC*b3j-LNw1povM)lYYtt}xQ>t(^i#I@M>NN{M7&HFXD->!D2nuxi@%79j8*PB6*;5*82Gilt67dDDe+2NF{>Mw;;`jDAXU==4x#=Y3Co2@ z84TX7AEvrnf62|IeiAHYeNs^Y@D!yHji8RsqfTg~YPF%7k_vH`3-RFAP-3*^J#bR| zl^YFrI5lI@T@BG&gqDz|ktInl5jsPfiwIAeX^BuCH)+k_we#$+sZVF|)K~BTw`KAk zFl46TzD{WtXtUWSq!V4NY@=#h|Ho<(DEH@J$5 zqS~3Lnhrxc6rQ(0s%|B*Ye)m?lg%3?T^MzRxk)tQR)Usvvx#(T7Wb54G`=ude0AeR zs@O$$g^*hu2|v9VzageY-=OlL;xqGL(V0udzB}I-bl5X9k?RPfScJzYL#v;pD7H2; zA)Ar~q;-_&Q(l;MGPFw3w=OHwetHyp?OZUaQjvOPiTWwcYw8mXmDHX|8cf`PHc-)+ zf+&3bpLpbIL?oSxvY{fC9!|h}Gj=1YP8Xtz%zCr=d+bH(AMrsBM`dO@GnG<$j|SvT zd!LTW+(BkEK<#-Kvf}Lye2Zl{_`*svhVJ2f zTvPDTbI*`p+8Tyk;JdCj*paG{f7&^j|I(XY=5OBgyXk+&&dDmC{fnJT>2w*E=W3#S zp#Xkk5?-?flMKtDr7zW(@C$9Z^i;X{?CTy`rY8}Svg=>3rYIdSdX#q>{+r*$P5dJ^ zyY@~Mr_qJvTQV8zMFx|J8y$rTk9mofOmcK zu<2F!dPK=>Pie}xb!_-sMA)-M?!3?VvIAE`K~X`fs>y-2i`&XjIGi}KPTbyZ+D`MU zRX{CRE}*cv{YB{2@2PDSN>^HhH}ytS>;|kldID|v7Pr$y2yW@qEYpuG)q(?6I~1nL zH;#8V-mXjLUo^KHG|CSCq9g=nMk*Tw1unV{11A>+AnV z+IN68v9xVN2ttr5P3ay4kuF6#0Z~z@3WCxCA}S)iw?MF91Jr{A=^mu0RO!-DK|z`* z9YSxRhY~^p{{*m}^ZwuaedoHCWp~ot0$a4YgH)@)dbXIgb=1aF!l~Ej^k}Js^Zm%2i zZmJ4q^FJwi@y_tY`C+ZU9&UcUvna0aL*%*B;76Pd?mr72e$1j2PQJom>aLVu31g^g zISsR_;?*2t5ruzPRH0p0bmX=LM|QNspU;NPn>Df|C`y{ne6Tw;EO@R$=7zD$ z&7!B)qQ&l)uy!HKPm1W&y#mg8k4?3+k;6 zdL?xtcYxOUQ1L5Y->-RP%CjLa25jp%t4)Wqmp<>}2iU{P1PyK;p{u`rkXpNp%+W^P z){eWF%DCPP(JFf}&?Ut*cYnZ=%nXMY#TP7Gfb#QfJ;fZ#3V90^RUG?+%;5(1+uKiR zAGb(Z4j!9jra?!d>E_W3Ufdj;>+41)o3*>HZY)DFgwQ>5n*yer%>TqVcEf^*v+H6b zuIx98`oR1uXktQ-#CdJp-{vI#{?bC!nMn!JBw|zM9f_9t%6>60Jd9XFj`Y%$bJts3Xb(9_7*Y za~o4@FBI-WP|ppo!fHkFo!|-g)&I8EzTx=C%98s2PdKl z82^_4`CQ&T?Tj^;k((-d!IT#9dlrA{@@6%?$cLBW&8J z(|lJnwyvvpb^}I6cUqKnng7Er=3k;aWqw05!i(~mx!QoA64dnYqU<> zC&mX$l$^<-`rYaeu}YU;e_ScgZ4l!;pfagEH6$ZFYIk~|h3hI!@m21ljC>o;w0CK< zV&NNFu?QXuXTtQZNyvKSo9o%S{I|5~`!u<3*2{vwHb(Tmy*7;#EUOyD=Ua_Y_eSIiS}e~)Hv8${HZD%i_89nBgk#Gnj%u3+ zOTGN;)UYG-OD6fimQ!740>GD~#(#BwI+Dr?tpm z3-k4;>#`a9;yY1$mrPs@&0H?hND3@C(3J9ZB*b@WUW&}eXgbQsDl4^I(u1EhvOKD7 zeKy*v)P7}V?^o~R?uJ)TsT~O~{OnOL(HD{?ZzbKO?3#q0BNan$=*l9cG{0)@PoKJ# zl+_IN3=~eEdXl8Dw}vl7`;P;RUBS65e`1(rdM+4O_@sFmnpL>ZXN|tR5%Z`v{qplE zA;EJ}`|SN1PY%hQ9<#G)Zc3WcuFqkX61?c%%8uw z?Oc>4$Ra9nFvcX`p0Uh%?JjTR124w@A`5pz+0RQck6xl57?)pqKKVerEkxDyh4=?C zkq(}=e4SoBF}DlP#v^Lzbluv*&t)lif7)dG$~|rwOZCj#r54qqa{fy3btE-;6zRR6 z^ZJKT^e>83Bh{|!RaeV{ zQzNe}B{|e*EhJsH=If_@@9lYEkHKGu2P_$OAzsXV6c`@f|Kusx=SnsDNdqRKW*x%H zd}!U0!KRFAW`pvlX1h{`Hgz?4L|YoX$)UV)dD7meTRqdRIIw$FZrolsLTWOtE_6TI z^VB9mdcl+@i8;a`A1sUs(`w~M*g=GOT^~KN6 z;-sI~p_keKL^^Fh6E+HYD#%AS#mP6WO|`?~v|oV!<~%mY!ERCU+=|`>H))BdqHYPF7|Pee4}Th=Sjz@${^ngQndOep|WUvX{`Y_ zBQ@zfIW84MB20pcaOeb)Af$ef&L$Dt(2$+DLFm~eA_xs>QV3^w>i*`p<`sjEC~Rbf z+5__}_n4>{ELL<{hw$vn3LF8d3AMjYc_0C;D#?|t#dp89C$iz?LdB@eHH-S_&>2R4 zwCWd?vRPlu+^+40bc4)@%_$=l>Xi;#1SY(E2Kl7Qt8!!-2|&)& z!-k9MdnI^KWus49@A{MH#vx>* znDmXc3)ADl`*ek*Z=4FRW~;MaK9}&>n$A}G*5T-yy}CB^sX6;}-RM&R$~WuFVKjgV zc1sq_Ov#qdAlxqjPA7ZQS}h3N|5HshEGH+sdOsyEsA{)P_$~IzF_D;CBzC;#JMoM? z(YJ)^Y98C#yqEI3g^TH!^oioJ+MmFrpA%KaSO-N$bv`+uSbb#NPDxlXgRkiTP*-8f2?jPann0WZ`m5ob?*S_9x7*V9_t2BVJK1l}^q;@Zx2<2ci zhb)|X;@h&ai|E|T&qZT1Yb>1E$NXO9SUQ96Llm=XEJ%Iv#mk{5EA4&agH8ZVfS^4h z2F$A@C#zw_&;3KRUJTTVDpI073|!7rku%#5F^&5C@yRN~$0d2sd3!@*jM3NAH?ki& z)ST9G36U9hRLN>36rM2A31NNU-(S&_g-kVDI0MVTiy&Li{DsG#S(NR%XW#<4*K7}Q z0t@C115~RAbyM)rgGqQF2;3k8|4umFamla3G{X$Qo6QOY%q#QDXG~t1RKwCu0RGvS zD=zq&XH0NtZZ#}Qt%KylSIm@AMt!jJ+InNtB$pvmsC@Ie4y{`+Lg|c{*i-pLb=8bJ zr=AUUbe2U*pw+&n{z-TfN}RZ$La*P_Gw2K9yGL_yVrKqfmGi?4UVu{u$pII(tDyjP ztfG2ge#~}88qhY40a*DxY%kr_>rlE08y2GUc}fQTmfaJ+5ajy4xFpUNaM9^ZG|oZ9 zlI|gr#pxz|k0yXGH9@@uYD+K#Xw4;v-^qdHX1c53a=phpmA;Y746YSU3QovEu>&5R z@?gF_;{0T*l?D?mpvdf7{ok^P)wj3HB?wPY=g($GV7|;z3C&U)4_Rq^`jt+{Yk5#!Z^ zCvM*3=6wdYRTHn-FcD8LZiS^6Blni|pK(oQ&2MdRWnesbG^iG|d9Np=tWxfbD*!X7JlcGHFX_o$oC$DY+D*KFu&0RG7T0UG&}sFMpsbk-iS^*`(2yybOv z1;D|zDyJD_YwZGgRh%#|@MvQpV!Ks?HRZQLL21+PAV7 zWRY>#*6IEI1LLD>LJ5SD-z`^l{y(-pP0n*GGX$<=m@pxGK z<3jQX{)`(@L@XnARd3*eMu;YFVMb!ImNdFJ!XlB z@g^UDzW=Q#7Ib>Sla+;xqDPT5bE+tg9=DvfcYh#;5y~D7j53)w;dnW zPMllj02vB!zhI_iePL<%%bVLC@{<$!W_!mBYp>yUvi|v$@wxHTu`9`-qhavEReZ%} z`YzCupv4WEtp=~)CL?lj3(0^KY7lh)SX~}PJO4QdE_q~W%Oe@+Pq`C2H@doZ$^1i) z0-tsTMSpw);M44N5x6fKiZ`Ys=#r%Xw*DaD8vkdXO?B%j%jtlnQCzvzv+T~biH7mg ziq>sknnyYi&cbxR`RGg;-{Y|foekAxeb?U|?4SHOMUfEGok;tcaCUFtp$C?16}3*C zDS@-~|5<8DiT zDEXb~CGQ{cl%EW@I-o7}IGJo5`lSdkiiBI$rjRDZy29B`NYN#8-je#N)7720am|@w zfk`q64bJWT_k0I?D2vyH6xk_1f_IHwR7}J#a8a%pY@o+b8~v7; zVA7xF)i{=%=1qZt&W?!S<}ty6&NW9DwX90cV^AIye#)Y~B>Xzj%J9Z`Y%o|S68x1e zI#@`%>an2pN0yus+VgJEjA9triz`8*cqnzkcZ~1#el7xxCOnkc*`tOW#1c_u=Z7R_k`<^v%I6;Dzg8=^yEF-n!70Rh_0;bX=d5QYpgq z?^$kMl|zNUmLg|OZ}{2=4V;=q-pcMu%1>T_lQN6=yU$D$3#B?dl=*|+H#uh}B{XY4 z%*EMjS8Vk3c-st<2dFmFK(j$(gedCwOL;H$x2ye+_nP+$wxv2>5;Gp-&%z61J2y=C zma7|98kaeYktZDzZ0BBxrhA+7Xvpk*M>hgaEZW) z)7rUfjcfNe&tc5(^PeL(T9>!>&2>Fkccws6k8KEOR4o`G&=_%z`hRl+{qg-o!}%K` z)A9@wm7ALEzZ;UECm1DN_9o{y@>GW=;&8Am zC4=6R&6yr2s`XN5%iZf0GYNwXS?g$<9>;;hr8R-Oo#)5Nm609(Ve~Nq{iibl<6kQvz%Fuku^~ued=jaY9cy`_G}Do z3=_1&Q-JM^Dvzh3j9C|oWyxz&gOUC;XU23D^#Lp8oH<6f#LQ^{nV5W!`KBZf5RY_! z{%7--yS4jwpQIbk% z7AO_mXBYAknMyBPZCzXRA$riC|4PWq8@h&HWi>4mVN(Q<+N~+Iw+0pxgR}}ca&eJP zvk^ovL0t1CD3}1d=C}XqkRmxCSt1*E*D!(gV(a_6Eklt_?iU?tN*W@RqsMPfC0%-c zSajbDzowI4-A}|=$y~gD;MJ95a+0WF9(;6bJ)_$RZQhXwax_AMdQ#tuHGQ~jQGF%W zml~s0F&16xIBH{2gL|xA$+jZ!G0fbeB4IXq)9(rQxMKo2Bv}nYl6#elvyf17NRmyR z_RoJ9PX7`kCHosjYU}vF7*2B13evy4I|l6bJlY^6Nn$Hm5@&0=^8q1A-LQ+kGq@Uw z;`1^ZyDHKhN!<;!LGIjhU*FZY%ziz2_L?-__<_+HuJ6wa=elr95lpxxbZA_=BM3eMAL4L#r5YJqaVuEiZ_OGvzYCN;s>;?tXhNIixq$wLX_5~3ZiHlAI;v^iAkk*Vy zKpF7pB-BrgkB#H~T-c=(>ZPE4oFY-@tuaoPssZE}GbzBKa zGs&vL9}=vU%*A`leZ}mv74Q`)Wtf4wr-j@-#h>IG6;^CMnwvRIzdhUJ(XUulf7Ynn z_2v^#ow_isY4tjduKH2MRokSBS=8(17rmVj@6$QX_UhUERHNzkrMXNEC20Y+{O#b1 z5k<0QPU2@pR&eQ2F-?C))U02BYu41zHRa#hoNTt5->YaarJOcgdnBvpvugObP2Xxh zM)CBeNw-wbgf|J<=VNlj_0C5Li|epPrNClS9yNS^H<#z!_*tmPvbXjHzvbo00qE=e zgY(G^;=FOzmPS@-`>Qvu9g3xjtQw?iftsHfy)Z#F*-nQA^?Rwe>g*df>osO2G^ba2LAz(6ko80tr+*gkwc zbmx`Ph(sfL_W;%udGqTb+E+%*CTRj0`kI}})NY=1L#u}~J@2huML*3nDjv@;f*v$X zn}@zFyUwAWHUXVtOQ?!)#0PeL%*E59_HB}Jx#bLLhZ|*b4X=J`HLao0u({X_ma2QBm zrz(gVfL@aT)cqz75@1PsfltWjvPEwD9&$;*n4YtfqVLi0J{rO*zU+qi%!+rlE*Ak* z7%3zbeqd-6Q><1`D+@c}#(%yj89L?Ve#(4s{rJtVGqkUISWJopGn}+L-|)J5stm0@ z)t>TNw&>QT&+K6+KjFO$Vt!5v2NbQ;$i(=Xb6y}sNR`n8B9-C!2@f?Ab)g3p-Bf5o z9UodAGvU7>xI|Dq=E873@PS?uLGi#m>eBLIMa_@)N)+Qt10Kh1Ul>1pck1!_acNo{-nI7ZlO=bQZ@ zSl85mi@Q(snoAG$hp?=2JSZ-*es`_hL_h zK@ta`I>4XZ9p1mIr@tauRydk;HmPG^f|evawYQpeiRp4OYdZD1)4bLh2}MOz0bo@y z0xTEGnulgo{Ix=!`7SY`lR+^wI8CbwUSLp}+_;sC03 zU#`@I(_Yhrx`ZZ23ygQR8NgrrHrYR|qd-3H@;vGiQuaJK92PL$^Mn?JLm@E1kUF05AS-tLEKEHfK+&+-o?t}c* zhYs`ta|)u-4M|IWmc!0MUjyDdubwfqJo_~5jpU5OhMs0tuouctw;C(u-yP)|xLQij zyS`BrH)d;r!Bn2mm?m&hR*?GOT$H(32PmF^986(hF0BZoU>Z=VIsu4a!oZUD(y9{_ zLOCqK7J?MMn~(OmhfxHSpP+E%ru-led5G=iE;&Kr7Dh3`wRP+OWiDa~F54vD2H6!> zf)v}R%D14JX}*8_4(DDtj2*Bp`t{cLdu~l5m2tu3g{Ge{R;b@tJ8!%>DhA| zk2P#N$n4OVEk^l06JMckVhsRo@dqKpkv$@AFt!rt>0anP3&?Rv)E{V#M>^jvk;P== z&z5&WEpG}jihpUqFm(@K7TY;jT3x&Cq4l997u(QMQDLgOPuVMQUw4PM=Et2Z=OA(x zd3klui^~%OP#G}iYE{we!9KNou)8`UBj>h{tAt*!mp;tm_6bQXLG-gn4c{#>#bo2p zmUng&k9+0@T3c=k8P+Uv+f0V$Ol#oP+p4D@qP-MK!$Jppa%MUt>e%81I#wKN_gBv! zXKkZ$gtk$+O;VOD1zY%ONUAX_s0pC?9_lcCNTC_|{M{npL^l3xahMTdxltr@x=;KlZ{#BMhGM{cJ|)!t~_=s;_PQdCeK0Pi0xle8ch=pGT_hZ;}4Q8IH7yMF5Wvfbd4 zf_tNdcFZGtCLXnUS-(HN`RAt(5a-{ISh1)GJuB6pK&tD6wE5V(kNKuo|qqA<;uG4azA?&@Ic_$Y6PzU}Yg@5ETliMUs$(2HcqvbE z#`jY?f6Zqm;8pX1#Md1wGtIB3XAZq=lzK};vB%;%1+0mxY|=Cr8#|Cior7eL(+DYz z(IDO16Fu-y$K`{N;>aEzH&}QvfL#IjR*05l^?vjZi!0E|{Kaxd$3@49>A7|_OVj|Z zj?2vy{+gHYn{&x^s?9T~yp;Bp-i-VFq+JP<`xQf$23X1Hz4)K} z8&$kyxZ+{bPGvKR%2@*Ae2 z^U}4ou$(0=ygK1eGiVhrfB2u-=(1~C=@E5pxrmPcF_X|-!uF(JP+R2W~ z;cOY1b>K_az>EMIOCWr_;@AwiYHI4d#9CUhBn$OfA2f3b$x7T`-Ba2VS+(N`+g|Wa z@9!@1PhUW*4118@f8F7Z2ad+IgK21;_IXgb*xVKWn2)QFN#t&;N`yKiwiHethjS#y z^B5_Ff6#6#Fep^26oLun9uOE1^x!=91tX36br6Cf@bggI9;xI(=mz_Jq=GqmHHB1i zflb5)7MVjlah|YWT!pzJBHnXV)!E#?^IRKlo=3J#%LO`FElA(|bPDYqUA3eE)B8T% zp^AU;qVE%->o2n;*}rs{dU_LX-uDv_S7VOiqt#)$nL<;u`uXkXJ{RB2nPyc5HTn*9 z(wKntbodqDv!$T3C+lBDz`4ml4*_J>0k>l4M`ONH1ER(#t(Me7F03W__O7m zU8tRNkS+I&#(6IR^4T=6W!_KrOLZ&;GB!?dEZ(B=)lao@dB&}qk2fAU7)!6$luIPtb+Xwf`#=1iS4$%~(Q8Ne)Fr+f~ooS~a) zE~dAt+(ZVP$2doT``R30wJxhXC@i>hSsoG)7__;4f44A42nu)wa#MCdI%#S3lUN2| zM7EsW)~Bt(ZCX_F`%U~#53NSl*8Tn;ZtdV#z|XmN9H5a(62x`acY8$23F=hGAdNyZ zK(1|r1ck|Yk>}XeJX1)RB?7X%Y&k%_zYDiGP?E(64j39p_olS)9s_(2Ki|dwHmh#m z-{0wdzr}Cg--Q4JK$}ABaKqWpCAx_RRm8_LNzX8q`g3+>YR(%t)pCi`t|?tF`n3jk%UkXX3w;$f;JIk;h^76)i@j+w zf|dTF*fiX{6q7H$LF*JwCcq!xHayR?8JXB_*NAiPa`Gi}Mk)sqrL%_}`4STr+g4m9 z)>d3Gp@*NSsv@~Ii1>#_7tn)1ov&Vr2@WSVZnlt6iDTo0fLM5#fy0Fr(&UlA^yZ%P z?WDpl<7JpA1OBwL=w}ZOncv*xa;wK4A*vE5T}A9p8}W|$>ZZ^~G{41=deECha?k<& ztc=^r$)FigL0THbwl#zzoPsv-=)vZQx0OW&oAY^?&CSol%7rOKj|&~6>*T}MDi$k| zz7^%%R_dG0Grm4+ivjDN_&}e{(V}dA=egU6&0!n@K%mV1eEE?48wG_UAZdMzDR@thDU$1S(=M6w-f7qh~rpCpH1l}TZEA>&kftD zY@Az*a9&ni-ZSSOb1+P&|MSL)!m|!B_1qEsu{<%Ig&3jlr_6n^2=NwIPe)Ehk1bD z6E>H1Q954jhFO$cd9IG+X+x~5k|%5mdm+QqV4a)u<8fGRiRFufS90}jxi|wkq)xk7 zNRca(maHgAr`p-9E_P!tt5vWElaZV;GZfsbT6&vBHyY9*$qMl{%H+-6B zJg!h-F|5JE$6~B>jQ)Xx{-|Pe_LVB{!Qw3YKpZV*ysDVzs58~TB}S1?JciLqNz(2b zHe=6to0)eA*|93#Jvx)Ir1+6 zdd3T5us3;C;zqFRQHPxJbQc^}9yPwsyJDRuu1J~pi50K@dZ$zXf0y`f{VH)BwC}KW z6!pRLQ{^iD5X{FT66YYXQIBOM*efp|j3`O9+}k>qd)gXz-fb**h*Rx`eOV=B-hE)b zMHr~&`JQr^iJvX4B8MZCZ}Hz)`#MPuQ}FajCGt~QmACtOAiB)hQVT?M9nM6z|0SJy z+{OXu{hF~=UJf;PIOHxmTwVZp3c<5~aJyT~K+e4Sv4cq@9sVbH=7Sxh7xxmVE?W z8y|b&iii@K($TB9pw8kmSXG%kC*`muftOIViY}-dfacc$(zqbjE#&8B|dnfkJFi1G;R2-x}&UyMT@xU_*RkTjl?HizTos z{^}MnARep_uNqcQEB~-d+|{9KPto^(hZ)Y}08?RQK4nBt)RdcZ71E1LduT>Ay9dvh z1}FiqG8F3!n3A~}K^2xEvwQQs0|6vySFg}SILO&4dN^r|P|zDjG$WHPZhc4=4Tu<% z6QBTIRdYSg)FY%lxD*N}^DpjLx3~{o&{f%FBE~K2 z?6K_>Ah`73X-_&n9yWoy}SDKm|_@L^rNPnC#S$9#5M~*pbKb)&?z>VR8eV zw$^06%O9WSEejkzpsd*!K3CuYJB@tu*SUd#8$c$IEY}B;P~(9%=!`Ik%!tkdJeJG+ zVMR$ouQRlAZ+cY&G5~5ANPqOAXpWy8`*}doIH8{Pb8(+adQW&bFCZLB4V>&) zHT8|ecP{Hl+SdnCRMt-0fw+oHiX%`y+<49ThbXFC_aSlbL}@?DBzJW9P}O7i8z!z| z5s&#}Fdph+R)CW6kB^5W9vdAx0M8;VhXp*aUH0JyQ%?PXag+NaGYx;n3}0RIp??V^ z9L9$onm#*|HHZMG5}M#>)~4P^8bd1}?^^;`MBnlIP_KTJYh>u@!TX4KGH*T}#Z; zFlBF0YT^KNVqA`L)%P6a7tTcyr(c*Cw`m#OmXeDnf1_o*mz?+}WjU;w`OpI-+X*-w zVwlrPz5TJ4EZb;@!^;wbv=7Y!GA76Qsoq>5fJ=F8##vlg3}`^(rY z)14lj0PK%Tp~%IUs1;t9AlF+v&&acxT1%{P6eJ%=B1J0yld50XB=5x{evOW(c@KC{oG0N6!^@7JmkXZ`X0lgxb`)qcQ2w`u97+%W2Nqe z_4mCBVX&%PE^1qj1y5ZHsDyY5C|5iMDnaRBLZ5R1b9`LbsOychZ0~$eEW1B7F#`n$*F|{kZzam$G)>{^!n+seO z1D=Z^dI1m&M;t|^FNEK9OyU2o`sR43PCMs0h7GWVA_S%BhE(|~TLX#gG@N_E4}ZY; zmI)|IJG7H3HkiOM2I|LAwXI79!9auoFZD0Fkbh&6+ZK%!b^1lw5$H+|(Ry@;o94pl zmbj?Hjb8eu8>w$RsD7p)#a!2sR=D4XWUtrYQK2p$uM{i4Q2~x5y)7C_^-`ew+Sw%~}V61YRjnu3YZp2a1k?AlwAk*LNQb5)6QL;|hgA2by-TIA5?{Dgu|5>!G{qf5U`Yk3!!dX<*0-pP6sD`EW zPGjqd3V4WpPnS$>r0&q5>Tq@VQ@NuAS~{0m$RT3}J+AoiF zkErTcHDCdxv&)C`v**Yvuvox}x_sDUnsE6l(|};jmJTeSx2866JqP(ebD_$l$|v>; z7N0=P#GVwAI#h(f&Oh!{ALlDm7X)7b8^OZ7PTWUXQNeu&yrkL68*iq)W0}Y@fL+It zr!{B6Dk?l>4hwT!Fgdov+(~N)yAIu+n*@}$=CPq8tl;X*aW!e?J?`SV4Bf^_)iGoD2E&Dza6xSZrNUTi-+ ztSuo`HK3efj((4828jv$+0Hf1@UqRx!8C(~VId;d#{5QK>1sqGknuy$%M*InwMi>3 zl)Ee{dXYgrBR4|#_VLN8oOb$f1Ld{jT>K*lxeB z?OtqE$nJWT7vg)zmx5ZxJ)0<-V*u;7Q0SJQnA9Xa7Oxu>+~ny7-C7i zBS6i;@`3%w_OAKDvo|A(pVryk5=CyF4%ZoE{v+etc76X%3-Wf1827$_xn3xh5X*RN z|CbA>qaWD6NbSmDp32bJKSJllt!s1)K`kWVs&A84)lqdZD=$Q**0W!%GZPh(pBM7X z3B9{izVncwvA2nIilT{O(&d&5sF>8f+%D`UP?J;RMa9x76#7x+& zKzC^Tx^%&(H;k@)2(z&P1|9VKc=r6L5~Bh0UbD+rs59;OM029LOhG;R)lN?BDS_m? zSvDVIvxe!6q=HZJA$?rwc3vheW@EXKf=^9!W(}$)NuXs-ho>skvnL#$2C{##xEQ}e zr}`-4(4P>8`ghGAb>&CX%fkiy*ENrm(cw30u5Z9U7nXjkUUs!3Ae9G#a# z%XX$Vs7ysHr!XizsgE#|nUvwmweqx>TebTuxyfsrJ`CM6*nT>>^Y{z%$39!Pnxl5z z7o_GAPnFLSW8%0ef1bJBTJ!u3Y8!6xhYmRc$XV;dDKiO;E0HzCl$0*()(fazyG;kU zKkT~S1S%8)1ypKL?ZnV6fhRY|Bidr!>dQd> zh_lF%^ByKi)ivzXO(Eav;m_Ls^nESxIdX-r=^8tgpysH)wc)j%)R#Mvsd3}|BM1YB z?Ope=>X4jWyKA__X%pMl(7vTXh|t%87Wwi>gq5CTAa-2NDY0Y@>Qk57m7W)Jwop65 zi!LCdXX!ZI^&?07)nPVS+#DWdo`EAXd~kP9T~#X*eTk&)qgR~FMbA%U@f^>J)XKWg zJ#w0MXwjK>*s!be<|v|OQEJeLWL`V7#{-w`Y_(S89Eh8HM1ga@LcN$RHgErts6+Ru z^1~=%WKqf$C*`)rSwg&Tv2k2-^AB{;-PoW<4Le4+HFPmC79D+XMapIkJ+HJYzHOz~ zO0QvM@XKYxIcK^%70X`-$~RsGPncsMHPrZeYv2ilVnT%rdo$i9Q9C)yxlQ}>^J>oV=00j&HAzZ(Z45MTwWi> zO6$6t_FZG;o#u~BYu;HsrgDYE#fZ|_Ae9b<%{Re)!I+A{4s#R{OXnr4i<1(481z(O zQ^uLxOJHx;Pi!aymlG$28zXlk*pD?J7BgL=AGaMQ~~V3QA2dkVGF|3XeXm*9K<|9Wxupo2o?g^{26bBQ|I4V5?T zq-w@Hd#2BI%*1NNR(hk0UYlN_evKCEl)u__pDnulVT08NJE@_|QX{W9-MV&{Oy9Ct zIV$PTqTs(Dy<-Fx*REG%ia6QQJ9bjaW-EULga^s>NAYxE)Q!Y8S2CS9JSo)?pi{&i z23evEXvYv|t^d(cfD7sL{_rAj3hzcSs_#(zVOrvwpIf&)vb0>}dHHy4n$DI-X7BDS z(Iyq;LDo9h#W$7`2vUcn$$dxuXybPW+;+|1x@M@c(l_3A@Sq_x=xz17q?rwOtFal0 z<2?aWq)h5#&hC51*b2i}hL>PmAt(oz`x40`O+vt;swrV+PjXSn;^M<;eN-Z3H?0{Js49j$wtgE@ zV0bYX1#4ve*aWhI_tu(A`5fOl6XkAhLxkl;FqVSn3E7apoC9~$a?UFYD2o|t6o+(B zYSM}CjM@LDYLSKo9hc@@AKoJ8^pUdbi1tp&Jpn-lrn*3VE@4O|Bl`|^eH|-V-#VAQ zFWoutc<-6OMvXwM`MVMdumTkY8huv+!Gs*4dIY(zIC77?4RN@BVE<)%Co%L;?8kOT zY{>1+@;%FUbhOH58$tp4$ok4d&WNI(#Wv^HA1VW}u|W~g=xyJvcG!Fj*_rEpb5t-9 z4s8wMZxf+Vf^F7d-uwKG^Y81DI<2rPTrEt4nE_dIyOH#sjXjvemQCkPjiAKmqbQ_) z{Ar78_&54OlJcsEw>_mnG~{3LlUjhR9;jNod$`%zIFeK3AKSA3k~l*4H{yt`J`Q_l%DGLdEJb&iU1I1Za~MgjbYr~SlfeXPE~_Ch*OH4N~3oou|B@oINI zs#q@kc!Cn9L{=*V!xd6@yOqYr(a8+IW;%88&WOK0Rmn8Ls&_0oLfL0VdFr#U3`1-j z;5NKkz z#Bnm8)pcP~kjS~jMm@*=^x4c{8hoXBeO;@F%A28Y0q43Ju)MUq)=;{_;$)6*>CNA~ zGP#yG_cdL*T;Xsk)Jjma8sCjsCAlz^hDvEMl?KAgsuYTQ_?cn{xvVw<@LvLzhq6MQ z=NsO7t>-AI2X4?w#gc4_cO5mD>((xI9MsbKaJXvywjpoX5pl+NDu$bnHF&Oth`FZ2 z7^!;Yr}qd`GTaf6JFA}OXIZxr7PVK~KWqDfPX z#?r+pR0+Jr!7mG`?%sUlNIJSt=tSc<@9Q|TBtF?2(Gt?jpCLMxpXd}P71=~XA`jLR z)P}qBJbAw)G%|8T>NJ~p8TuVjiIz=QGw(u0oGg2u6J79m;;m6g>W#-qFVIj&Qe;wQ;vTu>7;EMS3%Vhkr_=L$g+AGw zzS^2=)X?E&xEFjecayyr_8FguvfB65&*sw9tw$-QCt_-CS20(n?DpN&N~Av_rXup- zG7avo@xBX;qJlcv4|L0=WtM9`d=?Y*I_Y-%jAKa(;*QNJz>>g|Motn9gha!rG4i>*UgVwU4c}()TFBW95=}Pbr*R4+`6Qi5* zz;1-TrSNXtma5NrDBt!EL+@X%HprqHf5JGR$SM8)K^yKeF^LNO_kgnHzSd=72cC^J z8=j5Dhi$JJ5quT?JR2MQb!m=$wWCUNMXu^&wT#Z<{shurFELJsWE*iDD zsqz&q4~HrlU?1vm0bR@CdD8IPN+w&bqClW)IqUkNEv8;oT<4S$(-u|%+aM(c4@45= zbTN!%-o4REXAE&|Wo;A2^&lj536BlE~ROW(W!aaJG6x%G$mbiPQ+Uc#wzm;{gjhP+sw?u6^&_w!);vp7` zo$b~6nf9u;efzPOI&Mjq$Je)6efGO@JIz*St%)A3nmXFF&oK3IxXKlcL*_Aaag=uZ z+K>kk7slVb866Vqi7Hf){nQ~f-@SHl3bCwj4)KSh$# z6x_k4{LVBvt_m*3)OPg9jIRo^U9K~Y=0(_e0XfYD_Cj8Sn^yp!f_aO}PqXJ9bIn$O zS9Cv`WD^C8Gu}&`E*3u|^~Zj&MuIYP)FZrjakqYsfxS41hsT+NEOj|>{&U$1><#IG ziUC*~;k$Omg^8?E32+P0zVOpKsfHe3>qZ@+9I~qAtFJ)Ha=M8%2FOQ(lur=net>+y z%29qRp#2eKFC||hubJ@pqEm%~_QJOGEtTeR@3urQ`|KgV{Et7_*>^RwC(Ov~V4#Aw z&71fd50zV!UUY3UY=uq-mHPA-988q@vUuD{1FyVP5TorgA*^4TsoWEjuoAtG==58< zZ53!L>NCm0l{2|IITi7PWQaokN~h4oSg?UGSiJ(0^Rlhow$~xuw&xmn!YnhWI;(%zD^78tvB55uUvCSE%T(vij^%u zxiZrAbY1(l)S!OLteThmz=_x39XbFG;D9b8;C}?DfdGl~;AMIXmkT;ch3oDA^>b4$$Yn`LT?RgzDoW$KvXa$P&rho(;zYT$+(EDwcch_ z;@xfa%S0+T(~d-CQtXWtN5JUX|v%wv0|~K6T9MLIIS+fcrKu0`$px!HMnOY}NuYl+9$JPe3=c{bfRCRk!gY0K>#hZWx>>&l{*Z zjog6eX`yY+NwTD7hewN}euG@3T^8nJHv1QY!=hf(`1Ppp0)EUn?a&BbAU4@(%MVVY z^fayh!|{ByIx2C{i}$ZRo##SEJmK(NjrN_3%e|CQab7QrJ+(i+8ugBE_Cp7#tx^l6 z<(+I&lmCeA#>|e`Dwt#!m7jIVA>fHvi|(*iR&Zx79Atm~p~VCY+83RU00m z(Oe6}$%X|D`7uGOx4~B;FF~mcwb>CTn|t=JUTgJY+jvqm$nAoz&R)QpOqG9>37`DX zm|ao90)YC^amoNs!Q##ZJr}+t?3LxG3hT`A6xH#<16)>(C>Y?`Ij9u!Cr|aM@Buaj z^8SFf%VR;sk1TN`wA)6b-XBG_jqhUzQf*IN*xYk&NLcZ1_TlpmciJjaeEsF4cW3OA z^C7k~moAX#@?an-+~Pz8SS3|;$P5IJj%B&(5hC0oshKt}Z1|Ldj6?VZWbg|P;l}}Z zG5;O6uv7lAV2fJXIrtH?i_n)=VVEt9Nirtz6(&2butYs=d>IlLu>Qnb%%z(06Ic+T zK!G!uW{VC|)x4rX1iGR@2kPLDhxtD)M2z6IU2){S8&Obc;}Os1$?zTH`^Fzdwv1aOg*6S#Sua$}=U z<2q&Ta2_=tuMUp4QyUvykc)Li5XS_z0!rq=BiAV(=TVC| zeM|k44Gxlu9mxfL$rfqi_=P-~b~CTmDbFd}rD%d)fz|viQH%!;khe?=6!}$QDV!oAL>sj{N2Q z0=xNgcYtx1!cko{y(dMe!^rh7-X%M?Saye7Md8m1#yWr<^-dSf@N>S#y(}BKy~KIs zWpdy0R~_bRtR2wM?O5&NU9vNH0RwzdzslzR9m_<})cpqtmo#?LC(HMQ(eTS?fbSYl z=X?y%QI(oX9_%Zw-TVgEu;n{(SL}(<;r#A;zAqUOoH_^^y4^;|raF3xvo+pV!VdQp zXwW!f(mc@NQ>{vI(!2M7D$CCuFwpk1{LBxApS9DM_;rF5JqEzj;Fd9N9!7IKUT;mk zoz(5~Cc1LGy?w7~&*{}n#E;5XdjVxlzpYkAXPO+NktVQ$JnQ&V6!wtxpJ=#W7LmW} z#(rKNJd>{F3+G;z47{IgI{Et5vb$uo-7e*|&t>KqSJL-vqcQwo0RaM!JGN|SpM$tDwem>XdQLMB^@6`4M|N=Znc=8)0Lz%b zPiAaG`yi|2evJ{*bu6uPO5Ks5fXngWC}zZ_psP1Jo??2w zlj9p>A|6gQI^JS;|6t3X1$D&yT|08G7RG;5R0RU3C&EVSC68`jWMr6-UYoUo^iYjE zyt-OhTKzdWuU6VOiAF~u=+JwuJw({ju=M?@hpsg!A^af;gY`&kwe@NZoaG^@32S2lv5WN9ZoP%zCd zkoodNSlC17oS66v)r}ll)eUrQpRm=&!dQ6tuo4cvRPb&vS3Z2FS%edfMoai^c71pj z^IBekMrS9;hTT|^U0rY6VVXn9RY_5i8e9BsxJsTHD?&Z21L$=*J$(=j-#t8GHx-{} z*cgUS%-qFwt(M0)$Qk#{c*q#{gmX?g7?kC$2B>f5*iHx8pkTAO&A!mPb1@~io_B_u zZEOx=3i~$QZX_q|PpOWyl8y_$)LwZf)S2b4--V%jaNQ?K-}&ZFCM6iCipN zOjh*4Y|hl?_-+mEI#+pbuB?4Gl3r~F48zG~oPr}uh2?h_D-|@oOrF~s_f*ag%d9mF zU@z}(ZFg4q#;Rw9yT916yjyOIl@J{Xf(@~9zUH*3pU54!7|>t8JRKxARxh?z>yBJT z4{;72t+d8oSzNN2-U!&*MaiF7H{>*tew3KlEYpqtW2gtlTIMk#n4Or|HJHk=(TQ?9 zxagjZg;|iLk4+cJos!Ygts0iC)V;aA)<&E(Rbhf+AG18V3u&6Gu%dt}B zcVi8NRXROr*5BT0qZjHLh>=q-j-FVYI@#PEBR6N#^L=ss;AqOO8H!rwZIqIAc;x!fcZ+DV@$K^YxHhU*y*mP@=CANHq_?z+#cn&JkUW zJvW4XUo`uSvwbci!BlC!RP8BO>5F#u5@`DE4|5;1;QFM= z_oEoQ-=trpvqI=dLcM_G^MDL(dQK;HqyZLIxHsof=kQ^v zkI?Za>Frug(D~Bo5x#qEq(&DnYG3Bh??PAqQSAJkWua79%ly&YAA!Z%=8tTG2R|t< zoOv;pCiy}%=P9ex=+Vo#BM18UWqUfG`4qMv?VDthQ_kn)D;H3yNFO~;dEnl9vvx|q za1WQOpLx|hBo2E_C7--dZqeF5IP6p9Ad+eT@^w`b!z&D@^|c62855TC53IUC#}5w# zxu?oX6sWb}XE1m9JC>NO=rUg7V?Veaml=QP=@TFz=PwyZA9nVra6Y3xs-H`R5b)Z0 zFKTV4u0m?<2p2nzT{VJ>N(Got&QtcJ#jJ}wI3&qq!7q?z#9%2^D^ur?$ywYOmclK& zZpNab>gY;B)06?7rTFxL(OOkW)0t&9I^k%|lpo4Km7RR-vkye-ZU|*fDN!=>_5V}5gHa~m?XGOC^<79^m)yE_& z@6kx`s>5^hbefv1QclP+)E!(pAq#qNL=sAxENkRM5s(eMY6_{55v0X(W#G=lHKZ%yYRjuf`emlu>`Ke*n_z z0ZJw7?;$rT3IX>5XOx;eQe zDaU}TyLVR6h*S$i9bdRWcl0Yixbz<8`gH*bi7aLnHyZDfFEp zyG__x$&39Tl((&BIm?D+Q{He|p~PXfeh%yl2ubKT2CYZm1WoTZHU&GLkE^s(A-Izy zB@1?Q683aHBL3VQvk}RL$?->ekVU0S2%M_WE_cz)1VYkvcWh#9HnDhzwgeMo zNQjTwjcE35{H`>A3Z4%A;oAt^qrWo$w{eCvm+aLU+Q0eDb>wG4Vt};&1uN z&L1?>!d{N1PP*kA@5$Cav8C?w@zK!sefld(Jtl(3iYg7xb%!HSg2$x&iXH)yJTch9 z2gZBnK);JwE+8K{7Lj$HOD`CBUbB`MKpgDo8^8UGI_z&KNb;X!KR(KfeZADo^5Cxh zCx+8wR!2f0a@nqoa#kYCHf&I-qDlzcnjN@C6L{!$5xkWK0S|>N&u}waJCZ=97$mH? zYOaznpLT>EE`bCa!p{rUFu2tmi!qJV$Zh1+pJdoe(HJ7zOZ+v(0qV+PO?|$G;khYP zBlp2~B|xO>mb_!OIIorJ;kAZhiPvE?xAi+&&8JM+$KNtU#9;cA>qa$MY(fgvlDt3U z0@Tp2kym+=fy)s}R|2^(ME1y#+f0zmBb3V0_<&07Pqx33b_Y1lWl>ef7Y>Wn@r*El+-6P4g=lQXk@MHIr475*1Y%g0 zBqotPF{Wv_P*bNljod3$k-_g@PLkW#sKND#_xtC$r!IT_)9Ad)kCN9J{n|DEk4|d{ z|H#Qb(f);duTj9Hu_TFmwwpc-%kIFidFhav+xlz?&yH>jOZ)AhB>c%+CLt?es^FN$ zrGeSvmpOoE@wW+rkP`lnQ^(5awe3ihbf7*iO}*LoK@NQad5c4yiIrr{XLp{ODSUZh zD-Kj|5Vi^L^sdmm>q*eQI(6*703po;}*08 z18$qo3MtXMDRIXnM>Oj+e&?TD*-P2Grv{0?rYz59Ecb0cwma-sybWEJq4mqX+Pv6oS5bfj^Aoo%a_tZj*y!NB4{z_qkftX@TO#g#wfP*Z$plpEdg zKh3vt9N6-e^9KpbE%n=A_K9xV=DG7>BC%lkHz8Nb5)J_KT1^_PPnEp=op69UR^OL1 z(uuUVhDW7KRpJ;we?*NAokl`!acNcM;6)_Wntx~SZWsV^}7=svWP@YN8VmvMxr+qU6 zCOk}gH*ved*~FxDgLan<4=-%SAgBLsI+OU9G%ON-pVk*6#I5U#F7gc|5{Ser2Nz%0~oAffhak*tXc z@4K8Y*=h8yWJEsVq-IDOL7W zIpu6D77bhW`}&M6EWgb=ck`pzx7l=kf$grBRYOOS2i&~Sz2446ZVg*ccV>l9ilZj2 z%!^$pX%sC=scYe@kBicfp~>83Uc zolo!7_G0C54G@27k>EFXF7GyncUn~Pm?(XQ#pX3jPxIZPe*XwIGT%y4OfG3E`Tcfw zht-E?KkL!)=)$H#ib~+IpT_6q_^0UjTRL1ODS)@)YNM5x=|lPT zL)j5}jQ6-{wNoCJ$0-8=Qi6EtXp#P2)bna84{MTBK+jaitotlSf-+vbGz0um1}{o# zUr2Ga@lBLnaRhzOS+&@Y5%i;(_Npm|jY2FvL_WyV&Bs_g3un;hRs~m6{ET(dKr+IL zUitlo)}ui>mG@6nh1&0nFe{vW#gNNEY3EeQORvZ5b1NxWLD6{4ZhrfnkWPnQuLYC0 zH4$SuN*M^InZeT_e0Zr(@HQK4_db7y`D;|InkO_pI&iBVa&_aJb%UG2xQAy1b<&x4 zcl4+Z?~+qo-fd%$KcOG1jIA}q1Q_DCd!Jv!rz}0bkYiQd*=k;ys+y0_9M@MeW&#wR zCpQPSv*F=1rtl5O%pm+BRMS4X1EN0kQ}>kYmoOw5v%;xsH&@n#?Db(- z<3-|9m6c1?h_yAIJ2pOvDS5p)j0J^?bC6IPQ^tZgWq!aZ3WXb@{OO9o4?m@2B3Gje z{Fj}(aCzO&!VqO3;aOGDih1m7IJe^ZWn#;0&CZ%=%}&7|h@!^&Mr8RuipOvFK7WRj zF%wsxOLHVi6(2f{ujKgcn28lw&s*OB(pcXtXr0pkUAgh&D?C!GK#vvjly>v`@E0y6 z10Szod3PVdzP_aQ?N@WbVGwv3Qs@7xsgJp5Hn&Gzwtpbal+d-pftn!yM$QSyEJu1# zqy#BbK6jmtD%DtofInVPmcVaFkd^%<945o4zVW$Gv}tg#sIBl@#`lLv+Qm*|=BLSK zwno`750z`XzFVZ*g_W8iI8{;6hf~J&b%)~0l83)9CKF!w$Zon3|HGQ~FCAMY{^Hn5 z*#9rqEC~t8-`A`}Ej#uG*T4-7(?`w2@13vvT3OaX>9~kVmT5bVeQzwxJ{OkaMZCKi zd`BTD!=)cpT|Aw?GA`;eGBX2flT)Z2i@;6TUQil+CNDfQH@vYiV}i4B*N<_D(Q_ zMpstpyw>*M=#mdFiFV$N#m!VHV%KLf+G#m;PuO+ln83U?F~fk$d}pajCwX@Zhg_}o z*+wbu=qPPt2ft37Nk@LY)in5g%As{4VqkI?v%0y;=Yv^_$!J(z?waUA&4jP6cg3`$ zl15&Q?jmc2X{)@OI61pGXTWP9-`%m{7?a)IoS6-`WJRTli>gUMG48d^q8u5kYwz(2 zj}pQjL*Sw%UZb{s6_s8Wx#>2|r7z1P4J3^V%TFJ%lZ;WB@M%N8a$qeiA8ovGzg4$n z89k9u#70}8>GggmG}+|7aRZyTk>gFfC7br$= z7t!pfd0C;RbZTwt=E{8Xn<<}8+!dBxfe_zA^-5F|oDT_58vrp~e{Eax-tq{NFUu7d zCfqPWoxu=0cD%vVkhCuTWF0B=nIc^$5#?OL8 z>V?3Pijmg0-a-lmc`a_8gtdBH>*b{_lL?%k0Fc}yNCHs7xudwmnQPtTNo4_&{i&z(^3Y%mp9hWVj@go4lnTx zVndNDX7(4VkG=t9V(L2fGPK1!On^?zJUzbmm*b(kDB~D25?|MM0B<{uUA3n^2raf_PDt74QYESSf{*RH z&rxp%U;F&A#}AOIlY4toXA?K@r4T3c-y5{o_zLwTRXh$*zDEGH)HI;zA_fFkDt6UD zke%iic%&yG3?txfJJ^;y;Dt23OX7Lb2vvA(mdX+PdBAkV$*{*~rBXKa*_;66l}6Nx z5{_vPAMvAwV&w5WWhsO|5ty-lo~Udp2o0$KL}W?LLTtHp6~r1tfBs0AwtA61#|)5{ zKU-I+@A1Q8#`ht+OI9HOP>Tni4t?B%)9To}AKsP&dWZmoi?FBO&QMNc?C?{-lY#-< zs#P`M$_FJQpmtA>;IK-nHgfZdH=sM2^2PL@4o^F zHlgqb70G-20Q($in9t#Ce6fDwf`0&Zf5ZUjE`f>;)N{=kO9$v)1y(SbGhabXyT9=` zT}ph)TPrxBr(DxAA!B_e@5V6&4kkHQ+|`xbqsSe`b3x&WRoWi!k!X)dIyv84=*on4 z9737Rdl+)-H$3?13Bl90e8r738E7=Viuize^YweF-LGEO1NuW>?_uCF$ z-c<}tDqJ@r#b(JF`vwHnQC`DJz;ri{R817UDG+9~glz;L05GfuJH^p8_~;!Zz-0l@ z<36+(Ll--GjRgG<2;Md?)t`_hM?7fjA8}bSMJR(v0yB0m%AWuKG6h z6d?X60ElC@D7%q6698h122eYMeLz0;>iYAS0BNU#2&#s>4f|jDfavcDE;MJqB4BGx zPIicW6b`sgvKIcROW6#`a~Ws_Quyy(#ezSGiB|@4U4ZNxe_9=IN?s8F0aM;1R>BiY z@m&U60x8ZO_takE--xN+%8(o_TwhDSnnJ~M8PC(!yUA2VqdL-@Z-OP`g9Rs6y<5QU z)?&oUz1I)cdD(XxSCUXX^4#9fjOa0kLtaxRZ@>`4G?K0TND{J@pr2tYj3a%BEpR^U=Amy>?}ke zo{j!Nf=Tz-h@Tp^i*u{89KnK?>?+^ULN7|UmW@iFe%S#yw7o2U=D!#=S{TKQ62-8!xf8tD|%Ao$N z0pv~~panvU7PN0n0JI@8J{6BAp6jTAimY0aP`_gUP{W3fy#r3N7&!b!67&;Z_%{UN z4R%$X*VrrBy@8c#H51Wu5wkLdRejxu=6OG2lg6LJk@+NTQ~4x!IF(LSBg_2tiSy7m zi4{kgTBsf+wo}Q(w}W51Qvgc*&JO{}_UnRV6FsZG;WwF0)N~V|gaH>2KoXx)cKki+ zLK{^?0#i!yyFYX7N6DN@dGEogbAt#}MjVZQD!3V4FXtY*4Dh^)f$Lk9sUptv0N9Jh zF8`>OoqHgSfafI^QKjIo?#O_O6kIa>t&;y%?EehBG)kABu{$0O@QBHHy15BAjMQn5 zcw8F_pb!DoSA6*Xw059pkJ=6%aC>N7c}N#b4R^%3salQD{J8^q-hP&! z`9a<1=nBm>DRfvJ8X6@()!bRxJAI36BR;zZ^eqeU%?nRvvX@F`qLcgU@8}Ibilwrl zLJ~M*E{VK<5#24~lm5T=qRT`7)YrIVPboXx+V7EjeT@)xqgrh8dKC@dvLqXcYP;Ev zE@sr0lKS1akvKs3dJ8x0_d8rO`F=>~0)UT?QZEo=pnKgd3CsZhf|JX#b;AB?=S&BF zju&Q{E=Jyk9WLHIdzvIP5s;8onp6PtH9+gPl><2<_baubFID#VTR?;M%TjWE=r^Xr zsQ<|E$E2Pz5nFWrz%_u>7a^?Q<3**D165x?kE#*+TYS4)l`b8aWPV-0jd!$o|7SkV zEY+k{=F$rxqun|LKcHK0JQhr*$C;VIIu<_KHU%7kw(zq$S?AATn~e?K4F^YS@i z=SVQ2uHA~>NorW$B71*;%J;2vZwSgwpuK(q*|%Kbn-r?v4n!c6+Uw20eqTRwXW`~k zCwo633tIcWgT*(=M5Hys;&~fdqbAIo9P-Df@Y~7an~p=LWo@6c?K5R0M?&vCPTj9 z_qlI3XTEbiB_d|v%EkE^M9C$wHar>*WHg0&Uw(_6?l?ReT;pf)EjZ&l+SqtS7WMl0 zeRkY}Ej!LRrmLKK6os=_!gN)I(9V4bL+g$tHCQGmC4E`loGb4^mMF@H&<;+-H6R;& zIPZU%u;0DCIe{IHT`8sB^^{I`U=JEy#4Y+xe?OqqqVs*m19zFTr+j{=7QJfaF>v2_ z1NmA$bO*gM=J4e%tD{!h`Aq=AoG7KKqVX-}bbQfi807K(1ab^#xf!&(i5X4y-C0?( zZ2UgF^nI{*R7nxHveTebrRclWu%krdTh58gZ^y-(U~v=Er9FzC&6b0KCGX#a2|wM% zqPx&#V_glqKCmN-J}^(*8H|hj?$%&T1JbRwcXxXe8Ki`F#}2GIxQkh?4GIq*1;^f{ zG2vA5^?*r-SaO(5gyu99Yu9d+eRzF_wyXSADNRYbl zhpG(877it#``QD_tu04_EC&X1U8$b;7K!C(e96*|C{hrV4pU~eI%3B@78N5gEc2mOgJBz=+$ zx2$vs`*yVp5YK11Xmy1;Ww)6P*iL3OcVkk~@2m{$Dsjwmn!F273j;p&sob7V5dq`~ zast+_5$tjo!j2wqI0zWYZ6-Z{Judx>G}Ff{r7B~#DL_KJ*}z!77JkVTcV!6gopl8h zXJ(C)^?Eo3h|Q?}Joq}6R&kic0!%>~@_xy|zaw;Ek<+LO4x;9o+wmx5P0?_T9` zj6SRtO?j6EY&=X=Q}QPkbicW47MDwfb)GJ#`3l{XN1&oOc?KJ1S^5-jU|yPazmIX0 zSA%i%*VAg}M#t#%z(U?uX*fSYK%A+-Q+G@pvUdsn$KYV=3t&emt44~sP-lMx#Q7d~ z-hB>_ zI!2;$C{w;lDMO^|Q`j8TWwU#7S#(3}U#G|BE#(-PhI0IVD$d-`OCOm4l1gXry z%h9C_dXQ8d-Vec5^Iz`8fiiWtemeum@drIXSh*-zf!yV^rAfG={V6=tdxxUU-AF$a z8w7B}CsiESqlVXE>;V4PEgA;R%SKD>wLLlkz*!p0vT30(mr(;9kW}IbuNk{PKgxYX z!{AA!U4{tu+U@N(pH_7r&SX&CQhJsEd%Jlan{WLlTXi^Ny>-4v35y|EV@6r!kJ}Fp zCe{GuI=r!F>+TP`PNfsxL;$QY5eRz@`~y6&4_!bcFi3&?|tg2>6i&KEz!!rfEHxE2)~d^1dI~J3#9KgAd-^#L18iLq#N8 z0WU6)&A*CZIRK|i@=ITf%YiH}GZ5 z0N)yLIlxl3-s5M+s1I_%IPmpL@OMZ3xD;t%Qwj$Lyk`3g_U_E!KluORi$RbVIM{o3 zFwkn#1AmSn;PB1v1&oMzi`SkvcQR;SihUc{zJK@6G+>SP=*2iFx8>1dc_f?| zLtUf;t%0sY7kX-fzS_=x(%qxqPmdO&>d=i@VYo+)MpPc?gw>^~o$@ek|f_(yi|g#9D&R9GqyESro9yusps z5?H3_?BT6K~0wl#J%F&faf>aTMuTuK@3RmwU#MMq5uQ^fj@VivH!^OGe5AK z4LcgF)~@hY)<;x~KX0AVAb5xKzqWbIJM2vg7sj)|>AVu~p$nPqlyMNe{0Y`bvatPrz9E2bd_0MC zn9UC_fBS#s>a4SXn!51AGZbhJb_LB6Y`@?eiicmp@eOOd zmEV0-D9SUchOHrz`?glI^0$DSyCU`r(yOW#4DMFo+MLX3jy)+ujQzss{<#45`hRDd ze=fOjo$zqF^qNG_?BrmuGWN#|;^q$=sR?FYq$=Twc$RqN#@=Y!yk$yq9Y}coeC~CG z{GD`sFY#|C*pp$(=x9D{vBG2e%m- z(~#)~#!YTzB{AadujO;<==YqhWiScUb|gFY-pE>-M{oF)ky4!7FFQcpVlT^|`GL;H zT=a^kBP~!`=unP%t4t%n~A(DZq~gsbw}$4{+#zQ=}VHZ z&vq_4&6uYq%eUseF;i&lcs;NKw%S<`x7gJ{mj*52u;8meAjK9)S2yMfKo{83<(j-jG?e4_*y`Fxu_l z_=?AiSy22C_fR7H7YYyb)5Wihl-{2sOy8IM7roxDedqAoeIME?p)TZ3v$O{}#q`Q55pJt_=nYBiZ=T-{P8q-We_smQPyF1P7&W2Y=#5`Q7=BkcbdrXf*TS?S+QL-9Ix>?QP{ zQRoxyng`7{i!RRI!vlkYl+k4M;)3eNmJgo^y1R|hR+;L3i#WV37$WH*7Q+8Hrw>zc z$4N#Fi!|+S2>9l3#pIw}_$=qHSVqBdMq#NOzG(ZBVtW|pQy_|w*m5HIspMSAq3<6h zXWcVKj?$_P7G(NzhDgv zCHR9eTi#Nz*ubl7#mpl0yV#dnS4BN--kmY-9u3P#Hir3LSy%Hp0~qRi+U+js)6+Mo~cqQgPQM9xxblBZ-zG>Gzfz6paub^nuqcfXT>f`C5 zm?J%lp1P=H+^x6PzEf7VgKn-xj&^4NQ;}y{NZ8bD+dT_Qxau{NgF;rE*B=G1-z-V7 zxneh+jJ!U~@v=+Q)FXZpB_!D+8a8P5-iiHP>Q&|(o6FoX6|df1^KywloH#VU?9@53 zHc>XW*~4NqZ%x``aMT7>ElnYG;|zlF2%0iG{LG6Hm3s%zetoa$Mi<=n+KnzgVA7?r z>*!>P*5c9AttuB?9JFCnZGS9WMR6G&m9cO!PM2HK4tnht5g(9y56yp5)SS1pc$#

gMIPPbUC&YilCdo1%V)M>4A#x(?DCD%Eu!2kgGYp(-8#LEaF*(_ z;2se4mOCKGjQubzrtr+$pm25Jbb;yQ$c)iKT(`u|h-@H5vdiO>eTIy=i%J7$5mO=c z$dSi?SlV3J<=#ne#67-s3q7hI)f32mVZ#4R5!ox+w|;}4Y{`ZWN035oUWGddq*fp= zpY{4cdT_yrf_OgG)9v9z;bKlbt4Y*J@y>pL#Rc7a^)id9rwUD1M+s{CC{FI~~{AV#e2p0_{CjM-r#REAu@(Fg2| zjsA6z?l--vK9|V~k{3_j(>*Qf&wfc$Inj({wtFs5+iv4Bb6v+Mb6rZ?BW|A->YZrk z(>vlVbUXSwqI@XV#BWV@bVX_Mfh?izA>1AA<@drbNdq6N>2Cnxd`74L8Q`TL!coeCaPBK$x6In!coG*g(F*MUjlPi(C zurMjG?P1@uYf8j#6N?iA#p`tIeZM^}0|jxsl(I~AC(|kU!5N;wRnY^Fin@&ak4_Ff zjwA);BN6Q`(K(yAbG(~X^wj~P3!LHsBTNhPIBKdpjs9GL&SzV`ziJg@%bT3D$Ysh~ zccA4aZ{R0MX@Qs~Qu=MjB5Q53vvRmwLR2U;iF)3Isw6@|ELBP9!i`1{*TT|GA^XZH z!zbvRsB`rpv&6Q8xn? zi#wURoB`XhTQQ0~vnYWKMe?JRpEKd22e(#>o%+tan}j%W9pdqcP~FD-G0MGmXIfc6 z?cl>Znc|->aO!d=bGR`TUOy)5&o1&=aqccEtdo|t-1zLv_M3?ZN;`O8xi0vfP@4dG z4?jt5JH&$x`ck%-{+hXH`v_?iXy?n+E{mTpFxRJOmtDygCtlhp3CB0+%7M{EYAUm* zN0(r3C|0V^js7Bmt13?C_;nI%)ns+80}kFb7S>)fNE27fC|?_2oIPk3LM>8%;c!T# zqB+Tyd(o*BtrQ5EBNq$)!b>xXP!WTmwN?CAMGw9nE;`>ab@?TwY=i*(j5>WM#r5j= z^m8*uyXPkoxDrcoj=!;@ zYVPRyHt0RS6ZL0o2J*B+RD3l3G2B{ux6K}%2s{`onyp*ba7HxH-!=#K!4H{<;dg$b z2XDkYX6DPhUTPTrNjk*D#4FAWv2A{_H_4M!uSJHx19CxIInjw^R+G>JKm##90d+^V z%<3rZA4=Q=om3VGJX3V()d4enGglQY-Viv%LvdELlS1~7Cv4}3#rxVK-f69g)lQmQ z!==ImU_L155!1%>&9bv?Y$I)>Opd86taRX*Q_xFAqb`l0hg{I+!nejox2(05W)!IZ zbudO@^e{@95*WR$Dgp!vscdc46-foNs}InwhB|)bx?C@>4DK!b99q zG`xPCiuZad-iCcvtyby%BBJ=%<=Wb}nZ^4@wnytbkfGnLr#;ZN8oRih{_oLR@_k}2 zF4vABKHq!q=VnHhC2r#rF7C^CH8dwZEb1VQI3r+CIzhL&fSggY@?jha#-ZLd-JNdWepcitM6gzP9qIYcrWyQO7&`msN8c zB2w)gnBCXssi!g?;-KH)X_akXCj8S}&+IEAqJZhxE1d&y8JTe%=(&Fy(&M&Xi059e(o-^ROGdJ{{m_aActu`M;Y zV%Qy)ODTcel~>P-X7fAeP_xBXzxX61h!iM4emRJOe<@5?pKrQM)2#^s5zWuz@}IA* zfj+rwqC(O?W5(#tD2u!cw|(gfy_8}Ecb{W0VT_?HgWQ>6Xt@=mT-m8@V>BLY!Pw^3 zG!4mflxT+dJbd*IxTn~-zzzI_O_tgHSi%vi_F_}U?VBEn(fSa#@v5(%O4 zIVs!I2@Lm!z~3Ca;HzS_1H}Q17bbwlshG`B9<;!rowuX@XoB*Q4Qmq1{alq@1r8Ma z5%mk;+yljCjQ2oxtdJ3#8@R}!q!g5z&3;DMx5#>$yRehK*z$61UcTm!Q5*h7TdDMe z{w;`QjXTLP6K)6A;zV6ZvLzEH#8iqd=Ufu-mtQs;X^R$#kuzM~8VHQg&{G*nk`RrN zJGHt~dVt%GUoj%Mrp?LKg8Fa`ow2$-eap4?3(om=B23oSL`>QI(?x|RK{LTsgzz8k z_xpjLVeS!#)5j0A7LZ#FDK{eDp9@>O7FqE!t|p+tj$F9j`T5P6wIRr1jt_Q~vcleiXgw}3iC z-b8SX4+i!$l{}m#g*;h{i#!c-Uu5zQ{xkj~@H0GqCpXWqtzyeKKfvY$I=G=9p?6f? z6=S}12U8i)jmRUeL!5^zlHM1&l=9oD_pW6BGDfHiWu0U;DqXGzJVj3GkZ&BcJ}U3- zExI|hd1nbhbl+ZQ>5FNsGQKwPRp}_YUz>YX87m#Y%^Z+s5in|^QXYml7Mp|KFq?X^ z*wA{C6SX5fwF6-b0zbnJ^exY}tU_|^bYFwMVc~OL#A{n@ z=5*gnr2(f71kp)-om?4>dv1{;{#1eK7svt(A*zP>?cQfEtUB~zZ8dvG;z8HOJ=DdF zZx6QSp1C>m@O^oMQP{fHCyU+1kUlqiX|W}{#bw8+r(x%lJAYC^+c|&h6n-sw;Jm8- z_q9m!UwUs!{>6Kfu>W7IMKThw_EIO)P)pr)rJxsZSIhqMT;kK035g4%YA50KKD^)>O8 z=zlR^MR_!iUuET?y;fg5=-EJ@z(v0*SBxrC@{70{NNe94fTn-J1yMn!a3acJ=pFQE zJDuI_A)IS*zrxY5+AWDn*l zQBUx1Qqcw>CtS-Pw#PHlE>dh#DakLoh7TgclXu}dgS8IjWS^8#p09cSkml{B*z9~3 zBI-B!C|ZR*ok+SXd4iHQk@4&aS4_f~uhE3NadSz1282Sr%OG)sh2^;UAvfdfvZd0- zY0NC-#IXihVrlrXSiN>)T{TB1k`g)*ijXX%-)mFPNYYWtv)kkx_KqyZlUx#T!;Yo_ z4$7uewBrwzZ}GYxD(O6V;3M2j6MF7g;A$yT0jnT<;N%UA)`M%a65TgSE@i&ELw_yu z-KN-s>yv7I^(IXH$1>w?F5jihGVi6lE^bcU%eN9wG*EZFwb5)U-#p(VBktYV4vEee zqm%}bZyOJ*d~119bwZu_)0s%N+k?;cV}#FTqCe0VvfqdlgkKaFdf>5US${Di-LgkY z@>|RC%g~lhu@l{vQask@Z?tLxG1w4c)yytyHJMW@1%mOAlm4tGftNG-ub=&P`p~Nn z>*-A+shVBc6(!eNPs887U^3x5e?4EV?}@%-KIL2U8{Vv+-rnS~4!taS>xF5Yxj6iJ z+Q|>+Q{o)XE@xS(y{?S^*xU8C|0E&Z{^=%thFQo({GTsN zT3t_|*AAk7YTDWI%>uEKkBmvC_^(^YpYvjt4W&yswg#l58!~QEv`;KF+7W&c8HW z-~N1Vfg+=s!(46MzP6>2BaBDhc^85o;H5&I)&DTS|D~&&Ff2>buV0GE>eZGaa% zZ?+x0L>;){NhS33ynExAVFk5z8!=UZG8o}^n%+DuwtdE8=`Zgs+M_vsIQ{G4b#SX` z1oN3}i9i^s+V_f(!;w=T9{S?agf^AmBV{7A3Q?hnqs3eSPv55rwUKs-=s@MERLcCa z3b`fb$94lF9w7?1<|u6J42wrr18!Yl?#t+=S+8HcBJ-*yKB*JI?9^OOdf;m45)1}uia5?6TvBGyNBZ8N3F=WUv-vzk5oY-|@Nr0#a<)2Kp-(mP=u%7AQ7Ejuq8nod>*ji*G7;q zoPu8BPnJ=cnJiL_(4+VW^j(^gv=y)NKT(x3z1ny{+RjGK>dm|q$jfl%DnsGBe-(LGn=7K3D?HzFS*W9QA`NR3h2p5+LZ7HSV-7fzOg-x@ zmbr4jgN=I34_sSsN@y&m2Qg@Tjpjb|txBj~z)4~9>&Ddg%a26I(tfto?-Li##1xZ< zo@ZtXxc_OUPS-;+bJ&FDZ8=M~yJX|xCU%j`<%uK5zwj6uf0mPTxcS)te*f~sx0Ej$ zwD>$SJQ^G(8L@Z0ja0VUMZZ7Aant7BtxlBcW{RC$fn3AcpTFBO!F z_LWd1cR6rqI(4bC+FXxK=A%`2SY%;iEpunY|5w_3z*E_VkK;mCvdiX}p^W2j&O!Dj zIT>YdvSlT*_ufVJCL=NuGRh`<6Vj2rGP3zUhxhyaz192w{NB&+rH*r5&voznzV2(> z_w@*sXL2v4KTr<*;o3vb?wKQ_B`Lar@9{-5GDKu^W_@{Tvxgzy`dcSKHZDubeJ+~W z_O7dWhp4t&=}ui{B+fe&ufcDd4sR5LewN#K_Gmt zUg-MF0xzozuj#!-<1((8J!|~+KBxNGQ<*>|x+RSIskrN}bn_^LwH1#^Jt{;`woeuz zhqr5iOGjG=KP~Qlsh{x5SrtD0@O(dNn~U4;Xw8B^@EILes7M8juk~bkj!$pkbbS*U zvO#|zN$QcA9JJVud?uYvKuS29zI4sQ<+;c#UHYcTskm}`Q2UQieAyi{Zl}Q1lGf`n zBAf0e)a?GBz;V?nO!XzAbh5P*xh<^*u3THQ4J{V;O&eNNJ+k>@3r&%1&$|6mRjbG+ zd7m8N>7^fx3ii6zfCTSPje+V}%WOQmnkrvmH}div4}-{_zVZcI4;F>6k-EkmNb$oKK6FEQ7yPr_*{6(;yDvKLnuc~yTE&7PP!ny%e#dK>{H zvtAp)Z;_4`bE8>F#aR{6y;XcnMiLxWrsqODd~EVnHEE;s5rl}mwaIf;ecmI90B1-b zXZlrW7&OF-a?kk&=QmhFu+jr}e&WGn9gwDUk+-F)R0dSKHVJ3hgEv~eGRbF>jJO~q zdL{^AtMCYSBrjOZCUkEZZ7mh~{*Z&$2OE}LK>MaeimhQ69$VF(sYe*4ks$3OGP`)R zoMnDkt$lUfUMhn&c=x`4IUAKBHDnSQEG9&>?1A(Bsg?M+Wr1)c&PvvkR%YMH*p5GYdKwQ3Vw>}-=n5C8-q1u-L)Cbz?`(!Tm$Q$#2 zVUElL)We1><>XJTpYp8T&Fw#ymnLTk`;w5`kerXKk1~2XhK!T7J|Y#bf5TX;7?$0) zZ$Sh!8@Dio$mbn=P>6U`z4E?B-<@bNZ5m?BFs6ECEC(~q9abb*CAI6!=c$ChF?o2#v`an$Ajb?cgDYvP5 zrDj@7Ekl>dnV7La*j91={a#j?t0#f*-m~ZT^`E;ZAC%r~W%OK)wq&Hx*P>bR;L1qp zCih-P4-EnNRKkaITi@zw18ClO>9w&Fnj%Gn-5h)7w0^MGNW0uKdt zO|pI#%HQ+*Y$G;jLbAYLmI z`_ux%{iZxc^C9j?ez2GaFv6dW>Zf);$L4c$YY2%3rRh{Y)8&9lpcyeSTg5Te_g(E* ze=GNOyG_%^AE&Z21{jX}oYy`rWuVtTTLf2^slGjl1XXPVc&Vew@ZE-8y|*KNHEVe|AEh)_%%R>fJw}dyYxi zO2-f+U|>9F)owJP#gm3$43;LH^E`9x*cyKuLGeA54j*s%=v=Wm^Ttw?G#eSsPzsi; z>NlQIataoL1}j%bK&sjh^9R@5uH7>ae>1yqz2z!%!wD7%4hHuIh&Rb;Lfp#7KJh@| znp*uku_3!SLn|K~6&0;=B- zD|d}$9>3w!9I_i$OXw$-!~5Rs2`gQx&Jr-+xi_TUhstwgwQ^uLDWk=Ejf?qNwtu+f z&1i!zK{b+C(q`8l%}H@nCx<8R@;-;FVZVqed?ZsV0vX(U6;lY4(c|&I7Ov%+!z-II>P4g69Gqd z7=dLsKXyb+9!#cCkMgyfY0h#4u{N`23h{%9j+gRIqu;vB4Y6Mgbbg$;Qyk9sD`mKK z7ue{XW)!hd!MWo-)9US98s?chpM|35ySUzPNw(kaKXfQ^n{#KJ1LUbMj-Xuv0!q3% zuM44y{5tt1?5vfi&0S6c5 zEPvAz5k!%-olVF3J(gM)8R;T2**VoLP8(=EY$=#0m6n=$8e;oXs8tZ_$Ua|#U7FtI z{J`&}{@ufY@Ho*4$zg`v=J}1!a1Giavd;yBWcOwBl|VL{?33wBW{t#|3)XIrb?`rPwxmJgro^XQ&Jr>BMQozHq@B@3U%B0U`!mYp@IIXMln zyPL&kWZs1?6o`sO4)LdMo+ru(0^cAWxl`>4`A8?FbixWIk*M7l4UM9y8&TYZ>E}6% zNaT={QfPhcdsS;EuF{=d)5pSA%DpT__`HYFY==bN@Hx*q=Ro>G@e&-J0sQj90=YZh!a>f^+g=t zT0$G+5iN6f^8!DQr9rMX_Sv^4iax^~DvD%yL!o#9f%^#|oaaG>m%5(@Eq+MVbP`6< z8Z|*+7>3pG-MFu1OG6Q(N3P6JBuE{5=E-;i)?5jO?qWXvJzCeY+ZYwf*L93YJK0+~ zKD-L%je&)f#-_OP##o&sHQqBJYy7O3b6#|$bMnHq#;okK;(W$ENY1{!MxLg#24NL% zPqZJeRrE|}=I12!AmE!HFKz}Gu${TCmPs1$TYr&);#==%Jy72JBE{orM$`2PKDrZf zeNSXIJ!M?U^97F~jUW+v=1hBLN`6GMpp3u$k{FJ^8-0+&WQibITPB9#39X0W7om6B z^kk!ft{&;>Br7v);n@eD;IYTUkD#k<`pxDu#hHGC(jA6|G|K_@1&a{`tFBlQ^;*Re7APh&)>pC*?-XuFiZI#6PI}G)zK+mq z-}VMzOa#cs=$T&@kK@2I2Ua-U#+Oa&G;Cs&^k(JAGY?YSA?LOM!(1& zfIyHpagQ7KXvXYoj8Z8}AN9Yorx1k@m>L4Y(aP=PXUN8hmkvPXft-)w@o`2YnNGw` zAYK2x4^bDHs_;<`3oz(n(&8XDmv-7p{k|Kn3cQ(izz~G!={w*clUuxY5K}Fs;R>Xg z2e}~`T)~pU+>!`xw5-`pjA)=?SJ{G@u&>(ZkfUX$(F+E(e+Y>O+xFzGK1-Rk zu&USb?hS_74fxxeh!IISq@#DL+8CGhMxzTjAV5L2Vv)P(0>e9R(Dy`cMC<0h7E49H z>M&mhphXBwA6d|hZWdG$J7j3)ACYq%st>T&4_NR~p{f`D@=x+w(3;18~Z#d)^U*?Cz zKH%-bL_vU%B*2QT{P^+tF$EtdA|vRN`xR+sjNFG29MC~B*8_F50?^tVK}c#b+ZvTy}9W%hl7Oq4zwfA!g>$g79YG->37 zZV3Tz7v3VC*)zDC8S=GW+8D4ND>WYPE%)8}s+~xHTwd*Q@;X@mVRMcAj1y<$hjzcZ)7u%XQ17a{Vk`-sOl{^#+<#k-PWmL z62Wo)aeL{O>-iHZJD>icDFxYt4M9Tv+7zB=W$Yl9GOX;Nj_f<%4V#Z4@6Ty+wKr zD=4l~%0y&DhCcMV!SJ0bh?nCLuBjTIFGu-9{h+eYFF4vK>M{I)eHH~;j^a#lgqeL_ zDrMO&Leu&dYBtaFqj-XGQ>sDGYV-%9Al$7{Xai!3!8tz|X=>Zg!7-`50F=oW-Dv?4G zCk}a-&O6ipg>_lTdfa8YwC$4C@F!!<)_Za-^N7`YDvty?3zPUiHCnyn^vRj|--`an37R&|Y8{A!)%bK#OC8;K_qu_i-0M0fZ&=oL7`l^_vi z3K=6~l>SL(B|+;zS}O4uVnwBr8bigz@5I@YUabw-rJ1p|?Tosg?b^+PvL>LI-2B=^ zmC__VK<@WSN-N}I0kg5R$qO+))z#a?7bOqgW8pD(5K3lGXOu0wx5&hB=;0-j%03oC z<`C?Nd1&L?IzRvEQ0~pr?J+Q<+>hE!=IIlvxi`lbo*~P%=dAy*u>Ygs82X#x_yYbH z3mXi9{i6PSNK&@@0wo2kU8tjwbBYd9D!brkL+DrC*gohRRK)t_OxUiD%c;&claoAt zYHpmK;Z=}^j(iIdja+!ez%H=U-1~}9$gTpC?oztH;5Az#d4Hm{Gpd5Ri$}>2-VpI> zK`zKoD7<}b+Wk%|vtsYs=I7+o-ifbqhn|bYj=dkIPLb#4r_FWn0EuQ)>*eAw=z;CkIIEvD3Vo0PZjctTr@5Juq|cgTts=n zDQ!V{p{Pb!bcTMBdZ&zWV#uT+>AbPA?zxxy$-!2uyI|VpA~n+5i94WIcUwqm$5nRV z$UHD}Jn_0~a<;4bc};rHIv0yJ&CBx{m7&`BJ$7#P$eAB1_0Avh=hrnLYuuHlmCMx6 zJia}rx9;T>dyn?a;bqq-O+5D##F>~nV8+(OZ8H+K(Z!x$(xeS1N5`{WmM_!XwP$*uqh&Ni z5K7WwF!WPEt`GOaPi5Ogk*SzBe4j-GC_=AK;P+}KjK0dIjS<>r{h>RT*-m??<-XAY zSb$KX*@@0Uj=mm7yk`hbPBMe8+x6iZQn6HzP(c4DO4sZ5J|H6w4vU@%;mI|H4G@XI zh$A{{?TtwvP;rjf1l->P%pZ-TfbC~K$c6TLdQyrmChq&T%$bm~4O}V-58pLLxhAep zZh&p&xY&DfdE`c!GQ*WRoD~lo*GYbVt*dLQatK-C){ldu*}-g_*#6&XvV>Hh4zT{< zE@_C&Vr^ImvI+eb2AH!JuWYkfqEW{Umk2QvkupetiH9eo5T)5TD=OcXHgnNpW^Vc2 zWlO?eVOk&|{Mgom9YDQCF-kI)O_%@=?Yj{?FLD?op6Q-M|Jq&(bOUAHy2}<{_Crkg zt_1q)A2zs$=3_*b1g+A+Vmg57Lty^;w7SF(c1EIfBafPS+*TVeGu&6j-9=%$2YR}g z$VcSjudDz8X1mYh!`v92A-OpE8~cKNhWTr;fe76;E?-O?Kr)lkDCMS-=wYbCA$byE zTu*nJIrI4H-LRw^=8>y+@ZM@Xf{Il7f7C9lkSom?mj(oxchi>NR*xp~*c!&kR-7T2 zdm@CVkC0ypBmm4wL>H)sO^;s|-6l$AM29};co|rtG(Va^ z)%{A^?!klUW_I{r9002qXji6OB}CL(7*e{B!gz~&$1_$Nu) z6EhN#i>Cs|=Z=fTWMbZkIqK-nD^}XCKA7Ke8>)XVH;bu8{_$W$8nF>aFLIL~=*yq% z%m(D*^`wTvH-W+Q0|v7Ru=~+Vq)A`TT!{9-!yT7KY)W>tX9Xf0J46C7*M{tty5Dx( zDo2i1bP%qp?|)(#948! zySgWGd;Y<2F@oMF5r$PG}Qd>HQ>FXU|GM-N!Md(x!oKtab zo^*c5${ZJ4@Pt;^iDIBcI%DJRyno>Q|IyC@{msvD0sjlm4~P762LA6NT^>rL`&k+# ze!=&P$qhQxzw-GaCL|^q3H5_hMh1Ou@>rJVi&3hr0YNp7hY0KXwhXZw`}RHdx`w)^6rvB`J>ZdfLXy`TzO_+}Ox&E? zI(S4k_k?DL)ZH%#dB`{a{8?J?7SAWx!Log$7fr8z+Ie99+_SEIv85#i*|DEm*!p#3 z147YDF^s_b`CX;d!=k|U6DgO-1 zoLBm|me)c%IDNA;i_Y@sbaBJXs&T^1E?_{KTQFtO!S@aQ!kEa9l%jwsLkN8N@->Br z)hYm|dVg{0kc3jj)j5gg!>@LLSH0&*(BJLOEVEiq5+;XuMFT-*-h^+NWkFJnfBZWU zz6q*$fu5B(#Q|$lU#o}afZ+GPMeFfdjf_pSWNEG&>WU9o8~Y0M+{APy3+v!S9|^|@ zoz{UUe4i*5NxRvjg(>a(&?8mpj>SXVRo0t5?^yaJ9mUJWTY!t;@(4aF;+wcyd(Lhe zgT+6~-WZI2W)8C5;e#;fL@sm}d3j(z=y}I*AOd7wb(O;*PK@7bRNdDM$ZnWVyEucu z*K(8@(UY|2jH6zW?2n~XIBT}>C4}ZLLB9p{7laPpbUiRifH1Cakna*ss1muCFSO6OL+0O9 z9$Dm%w5Mhkj3i9g)gNtOdjvCBioUWRm88n+Bxu*BpI)1u%OFI8sf6iOReVw&9>=*g z&RUeS7SOvSRgSJSZ|Hjs90lDHov`?M%O#jK$0;#LTPUKpg%4Y3nJ;L_o(IZ?l)Lt+ zR3R=h`zT{({e`{fgAXny;giZMJ!f0Xhf$ABcdG=`>*>R#U6w|_NBU2D<9=9DkQ^4IWG;=UpDo%y zgw+wTS=>3V$PIL6Psnx4c6}XKsqP2iTfk4gW<$m9j!~-QZhWUz17rM}w|$b%O6nB| z{=9xp(+yvPPW)+HBM|ip4SoPffU0h>X(gueHSS43la_MkbAn<;GW~fF8KB(-36>2+ zd&T%^;Iv7#?Y+UspDq%l034kwxwk%=4YWmY!M+sI=16?y3^)_OvFa|xxwoADg-0o8 zz=rQLRDgnHq5!KzKFE0?KC*+BvUM>qWd7VgKkl=XZDK&_yeyU3V|3fZWXvx-#6Fdf z9K{OYateAu#eSX#`MX3W17|E zQUIxDm{(n9&3uo85;VEEB;1m31cxcS81*v{9uP-=8dEi2M~ElfZ~lnTBbZQP z9E|aEer9OdyapiTwMfg^Np>W>1VDs)C{B6Uz3utu%6Oz>*aKl|(? zrq~si!@D!Yi5govvJvzytO_NDE6F%3HaLgx)rnt{{RLVx7BAkuIy_YzE&HJAQ#rPE z)>rd2;n2@zB0+lh)MohK0v^^k_RK(a>Xpt8-mCveI0*sTi|PlWZMH(3BC1|HX9fUf zj0a{w@{O$*Mvhm%F-n1e5oh{zl9kJNN4<&5;oWL33s<8akD14C{rdtp{VwPV`j*?x z8{V;KKiNaMMFIr-7z-93w;&RzWW8qd&`nh0G1a)b6JE|>9*R{fMVA83b)8|Dtb4{3 zGa2C4m1KQEulRi|kFYBOqvpYn^UaY6cdoI!=l8#fe?O-x>2y6CKccQ#Q#wo3o;eh9 z{vIeb`np|n8o0%S;`Mi_yYKu1um6v(71(dCl?(Xa@OmiUuW_!Ddu+9S?0S8uUAxyk&!eR)(jEU4(n0w+0VQ$sfS?x_C z6q21t;;w@|-#JfT2TrUYJ6}xPcNR=NadIbjOf-hmtYy+vRP4Nc7{{hjA(~u$oXWBp zduaN&Q_E^*^K6G+OXOMPEw3b`08P1K72U#es_2Wa4yk~%MKFKI-a&+tyrVEcG=e2L z%++pYEh3pW*T4vWK4l{PbJ6KsRK8bQxFc-gVC&hz!O=!(>G_lwkKO))3*BDOqHZHS z-+YtbOR?NUifxjFIM=!zKd7iEywdGNusap7Jkkx|A5aX_Jz^cqQLyRox_cT z-5)!Tc9sve;IkBp1A5aFR!{QH8`j)2iF(LL1B~4BRz6z+Y#xx*6FY9*;l6S<_nLLv z!#%s=?ueRsp;^+M)=K{|-RYMhIx}u3N#^CJ2WNr}j%n5tlioX|SI2~Kdc+IVRu>4e zZf!otR&|W#Rj;e@GwKYO5w|9=%=z~2=~o*Dp*@1Z5ubgZ^4Txq!D_~vAQ<+5ZYUA6 zGk&PZRgTv#h$KH9_WQ{~1(6OLw8KSy20OWyHZ*=;c}F&lzG@xfGpLMzU<_neIpo55 zGtv@`e%KQR+$eKc9{X5T&ZgT;p0uqnMiCd5;EDJ=e>eA_m{U$$0;xDAR({htr?I1< zq}D1rt6@PuOwb{Ng?!nMMxoP&Ks(K#OM(&$r;>Z*erI9(IMkI9_n$8C4n}6 zl@0^8mSP511`}{4Ng|TWrcX23=ZS4XDy7}Eesp$eHeC>B)fjlswz0*csvXEL2>tr(4hjA^hQ zvA=K*LQ$qLrLyC@TKH3|QjoqVLySj)?lI7$v-_7LaQ47I5^Sdq?NTM+fUiTMfY701 z`kmwW$Q6;VV%QQ4!|m{R+qxCR<-=Am&+Kxb{kt?%%;@Apv?+BbFCuyDvw88BAeH6cWTd2g=)5hZk&|{S1 zwPrlSxgCsv|p*oL}M7X$T(O<-O@AJn%H}P z`-Oxag~e-lcux5K$n7jLnu$+pom`h98Ip|Cn)?Rm}b2(y1;z zjU1LFM(jrfy)k04qgK)kQ;ys07on1;~-()z{rcn@W_RX8)n+ztI_o zcpu`kNCw5=^p;?t(r1@CO1$y1d$re~Tl_nX0HQZWq=Uh$ds4ufVOYVJhHVkCc*;2H z+6XKbNIa7|toa>Qmq3z_N-%Jb@Vi{q2BMbJ;VV~y>Dc4tlkMdh{}nFTJBNqaH6yde z9N)#4>gVXK-hWX2KC5+h3!R+suq68Adr0$Y^xYQ*bdMeDj$x=Ra01kO`sWr1`;Xo! z*l*sc3;5q`fjqEZZ5gjF+*55R_Y@ScWw^XQcfY*gU|5gFq!OrlP3FaWi)*6p3>(Ey zZJv0)V|2s|SMEA9JLswIzVWe-_G5mp0q9_|e)i+O=pd0h2$lqYkbY!ed3u(8?XCbF z7S%Tqilm?HKER9CgqnhMWz8dk(gh_LeUCT1&SiJV32iHp^8|M>k*ZXAXW6t&$s#$)99JmE&-8T)9bYE>mS;s`jfq5e6C|*!w%DjFkN&* zmr}XTt5r^Qx=zCTu0IWQc-^Lff5nZFS#->j>wO@ufX)5>{UvKfgWEpU=0`oazOt*@ z=x;DXz_v3Oio~6jn5Td?6Xdx=r*WS`Z?8!moxH(^(3)on`2NT@v43k+#NZoD1n^b8 zbLz!());#wW9AZ>V@8Dx0OenmjUpo-G@|u3kG@kD)gfSOMKuqfH1kkk*>t4gM3Bx1-dxa(XcXC!-8wZ?Sn@{hnVCC1IvEO-LvGZ?TL$b3x5i=5LOMAui6Wf=l4j(>U>*trT99BaRzZb zxPR9f=R2ZOP6JDciOo1;B{nCi;r>+?`69q7(@kNHIgtY$*zW$Q?rj%4j3v`0uN9R# zkuz27CC@vtLG3*WqQrP1cCT>dk#hmb!n)@xo=q$cpG38hF^2f*3j;y0TjyjYV*t*B!Cn2V^JCNp@ksKmjq^GYSz zH0$Lw+QgXHSVwkW;OxZYGFZ1U0|kgvVniVBSz`x!P_^?Az8Pm*?y2Z%F|oW#M23dkP*?z;M=G8|?8CTWymEOZhoyJ- zRY(_Z_v|>^)Qfkrs*jOhdwnE$J(k%9jhb{Zp|J#a+b4^mAWE)Hj3pY}zh|wYuAd(i zo1=8KDu@?oZn#fjn7pv@7eRR?(b=3-B`xlWmYn3`bErJfK~0yfDO`lSE-W?iao4Zc z_^WO!u8@a&&K%K?D)M{<1A(#26p!N)o$XK`Cmz?_)j9L@uiLI!W3LeqkvJPF6HYhZ zXC!3hoL_5aW`r&k4oJ`Nc{W_A_RP*VLn_lJSmnpu?Y{c`2`R_IcjHBPsW~D) z4_r3(!i|Tyv9o7ri|@;LhvGhqQew6F(OA+Z*ZBYgHDVPq)6EA%lYUx_6L5dn*N= zLe|Y^t04(?vU{CF1rw_&>bn-&i4|@?S5IB*Rb1@Ob^2B|&OUgm>5uH4BAYGhRnno7 zM;pZrh4iyCGY!JR4d=Vk9(Sijiyw6@nWu#bIciQ5jV$_gPs-l-mgVt~%UpZ-+EHI~ z;Tyt%Ws0(s**vOMiuQHNv8$1z{c8Ucmc+(mz12p`$)TdXPyYE>)Fu~F+t}%C#dQrU z=gNAFS(^6+C9gJ`+7)c}@g*FILZ6z}`e`_E)%%UpFqcZEZGfMUYY<$UQ{+EWdeBsG~<*qw+AQn4%KPen4@JHhuv_xt`Y^qbPlBw^PuX ze_I?|^cegM>AZW&IQ}wZ#;pEcAM@tm&3avqYK`5G%Xfw2-7B>Zp5D~rIx-zFb>ld! zv|gUlH2>I9HB_g?|JwX#l_Nf($SR3?Sq-bfI>w~T*Ka^jlF1Mgb|5GT*KwZQPD^gd zq1_#M3rT{UEwR*Y^kIR@7_OMmUGQyCOl>tf$?FauOgiAk|7V%S{>`^P!7UvD8BMAB)KnE_qY>9Z>}7gu2l3w)S69#dpqw->iQ>K^COZ zck{)!(SFEfXbj`g1U(t&pMo7rro$!}U*2vCeFaG!F|57II^x?keR@RVi5?p_s1_5M0Om@1u8ayxM^SK-TqRrs}*4sTQPiTWtd_ZH)_oB0h#mbqqS*#K{yhWdSo zylL>R0AW=yHlEf|Rz79}0^59|04DD}`S$Y7X!f6)+|S2~8@ijm)7|jkc*?aTv0pIR zgPR8gi6LXsWRp2^nP^mVcpcy4HI@BWXp%e%AUwt(5bMP8g^wTv%lQp2ppbn!3CPo) zi)5%(2b3ouM9E-Sey`yah6LE5DSRSBGPg{xEAa^})5!CIg z8m0wFtHn)+=L&2xSpMLgC@@>qvoVJHDPIDAqj?0IOfAsS7i9uLS%APBh($f(l`pp$ zJwB7h3XY~B6mg9{-&D0@QGU){2{s#2>9!D7d73aA3^wXD~}h zG)5+fHWoh6Ax?2#hM2ZcwpJ0DUX3eu^m}Cq6}t zfcaKNuYiwJCdJAZ70FaZmcjO7qV({(8kZ96(e zes8?>L3Ow9+o}@I(d%`)0(Po{e0L9z^^t3h@YH%Yjgh^%&5N53tsGJ4{~%fXM~^S; zH;?ZH{BI-+9vJxVlEnu=O+rF}Q+#fr0#6BF5eR&1q#?iX3I*#my`7hSCqL~58-F(* z#C?ZAiM1cihI6lyr;XY(r{ORr3)gcEN8~9M#>a$>Q?yx6O(Qs7u`aU7o z*8H>TY~3%|l0zaaPdbPLF}_@<&E%)^T)Mmnen{AJ{^P5R(EW|3Is~{Nci+8;AGGcPG7tI5vKm!*@45P za`l$03)ZJa;)YKyQ9;eXO^D^4^eqdoyGqr7`&TzJyL;s)L4(tbG2@a_Q%4h)pI`Dx zpM~!+m-Oy^?(@3WN3I+@5E1S&NPNC!cq>uHEs5>JPi3gxJPuUHn`R-TQ`*& z1;n?^JrtW>fWU1%V@)bjyqw}*3}vI4v+mdL+`=y%MX5_?*ca+jiBBmeICHK<7*Ln! z{I6Zn7JTeGS-k4RR3Rj*G8FMcB6`R-TQ>BX#ajX~KDnFYvL-vM*BxkVAR@W3`vQ?v z-braBvK&7C0a}fg0slu|sw~&i4?<|U#vrsbS)yfBR|l~yb(x)ow+CN79l_*&{!*@s zPB)RIggQ{JFhZYfaOKetb?NQMQ8egt%b5W+->m+3{^hfGNN&pW`({Ny)PH>46lRWN zMMMU`jIV(+gn%-|P^_#u*`(!^{Fr}_V*Lx&-Y}z0S?7VeW9JGUf;@vSNd_?q9K72J zR(QxwqVUiPGxz$in+VzRp1a9|)z#04$U9+>J2n|TAVo`3&PGq2s|Tz_HU>E{M;2bDuUt3Y(b&A{S>RVLTFpsRP-a*I@;*HC(*<=ut$yWjXZx;F6 zhD?SHrVnsKbEphs)QUH{ZFW+dx(Knr z=$A_@g{Y&)sFf}-7obkB&W%f+AaQ5Xq~fKaeW{v^e=mqERT>-!vqak_2nU z*HBO3G0=k5)jJv1`#qRcozf~1Wpafm-ErMBrmWIO27#(AkwPrgU;Wb{7M0KcC`gd9 zilrKNPv7J1`nC8kG@_Dc=HqF~m4{Y4c26}u4wE7XFq$J!AHZDSxMX9lERt4#vax?O z_Bi5kD_WR1g0xZElL`pM=~O@{FSrT_TQ43vdBN@(b{D@xpcS0FG>GYl33mFjTgG@o_cRC#E2789Q4iJZ=d?M^n@{5VxGE z^;1(P3u8l02|H^OJZ@EIBc}^O6ge1=TiVvx&cwp@F^Kz-g{`=)qs9Lq{$2{|Ou@e^ z<^Qb!lo9VTe>`r4gPpOesS`+>8zChF;#M<#2DC!n#_+MJ#9x0U|N1Kr^w>os@7@Kz znDLwNnwavNK@4GFsDOZ>DW4Iq2^<2337A58O?ZG$Gh;q5FBEjqXDUDn9zz%u#=~y{ zhMV&63GkVi@bVcO@xvgd{18J!9z(b>49sI{EFb`aU7V>vbqKeHvXP~!u@eX=5(P@y zS=%|NJ~1>l{R{KAp+ODx-x_7&Y;5WPVmmuWlR-nENdccAG%y+$8jKx}Tg}4B+VtWX z0?irC4fwT2GeuJa{u=^RN@x~n&w%eH!1upDM?r>Y#%NYR4&u&E=609a|DEI_3*fP& zgQ=m@|LwVjovoCi6HpH+L0%qSC=V|$82A+cLxBvG>`eaup8cOD!%=hff0}$b9VHE& z46W@R1H%dopd;vF4dQX%H?#o&)UZk07@3-wn3~8~SYKd3e?8O21A*{#K|tP@|Nffq z&m2t6@OVIQFdonU{(zwT{J?TH16|_ayu7G&h57;6{x1&B0}RJ6I27;?I5@DPexrj5 z0MgTMI2g({^DB-Qf?D#w;rLKIr;1|E+;D6v?{JekSfLZ-}S~vv7m4D3-&WGan zzu+Le`~ttX7s3lgvGQN(U_5{H0R#rDq~GZHcz96T$1iF5c)+NQ={KCfALGjhbiwcA z3%t1;waNUNmY*NBef@&t=Ya?S>-0AqAL{JDuQ*;X&+ldM^Mc`j;D9dpy-)d}FoEB3 zP%z|=x6Fqntgz;doK;t$xKp!Kgj>Hykf&cmEX! zI{uh{z!F4-_W3m} zSOD?|4g&duzrY}Tf2=JSALNhzgYohG(SI<0_#b@$;}<}kEBv)CFev{@`mc zxWFIt1;G6=U*KRKRNTy8%7BBxkUwy|Q1G9)Kj$PI!t=)(gaZrsZ~f=wU 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..72146cb --- /dev/null +++ b/pdfexport/parse_test.go @@ -0,0 +1,135 @@ +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] + if p.Nonogram == nil { + t.Fatal("expected parsed nonogram data") + } + if p.Nonogram.Width != 2 || p.Nonogram.Height != 2 { + t.Fatalf("nonogram size = %dx%d, want 2x2", p.Nonogram.Width, p.Nonogram.Height) + } + + if got, want := p.Nonogram.RowHints[0][0], 1; got != want { + t.Fatalf("first row first hint = %d, want %d", got, want) + } + if got, want := p.Nonogram.ColHints[0][0], 1; got != want { + t.Fatalf("first col first hint = %d, want %d", got, want) + } + + if got, want := p.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] + if p.Nonogram != nil { + t.Fatal("expected nonogram data to be nil for takuzu") + } + if p.Table == nil { + t.Fatal("expected parsed grid table for takuzu") + } + if !p.Table.HasHeaderRow { + t.Fatal("expected takuzu table to detect a header row") + } + if !p.Table.HasHeaderCol { + t.Fatal("expected takuzu table to detect a header column") + } + + if got, want := p.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/render.go b/pdfexport/render.go new file mode 100644 index 0000000..ddfe54d --- /dev/null +++ b/pdfexport/render.go @@ -0,0 +1,477 @@ +package pdfexport + +import ( + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/go-pdf/fpdf" +) + +const ( + a5WidthMM = 148.0 + a5HeightMM = 210.0 +) + +func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) error { + if strings.TrimSpace(outputPath) == "" { + return fmt.Errorf("output path is required") + } + if len(puzzles) == 0 { + return fmt.Errorf("no puzzles to render") + } + + if cfg.GeneratedAt.IsZero() { + cfg.GeneratedAt = time.Now() + } + if strings.TrimSpace(cfg.Title) == "" { + cfg.Title = defaultTitle(docs) + } + 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: a5WidthMM, + Ht: a5HeightMM, + }, + }) + pdf.SetAutoPageBreak(false, 0) + pdf.SetCreator("PuzzleTea", true) + pdf.SetAuthor("PuzzleTea", true) + pdf.SetTitle(cfg.Title, true) + + renderTitlePage(pdf, docs, puzzles, cfg) + for _, puzzle := range puzzles { + renderPuzzlePage(pdf, puzzle) + } + + 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 renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) { + pdf.AddPage() + pageW, pageH := pdf.GetPageSize() + margin := 12.0 + + pdf.SetTextColor(20, 20, 20) + pdf.SetFont("Helvetica", "B", 22) + pdf.SetXY(0, 24) + pdf.CellFormat(pageW, 10, cfg.Title, "", 0, "C", false, 0, "") + + pdf.SetFont("Helvetica", "", 11) + pdf.SetTextColor(70, 70, 70) + pdf.SetXY(0, 36) + pdf.CellFormat(pageW, 6, "A5 Printable Puzzle Pack", "", 0, "C", false, 0, "") + + metaY := 50.0 + pdf.SetTextColor(25, 25, 25) + pdf.SetFont("Helvetica", "", 10) + pdf.SetXY(margin, metaY) + pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format(time.RFC3339)), "", 0, "L", false, 0, "") + metaY += 6 + + categories := summarizeCategories(puzzles) + pdf.SetXY(margin, metaY) + pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Puzzles: %d", len(puzzles)), "", 0, "L", false, 0, "") + metaY += 6 + pdf.SetXY(margin, metaY) + pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Categories: %s", strings.Join(categories, ", ")), "", 0, "L", false, 0, "") + metaY += 8 + + pdf.SetFont("Helvetica", "B", 10) + pdf.SetTextColor(45, 45, 45) + pdf.SetXY(margin, metaY) + pdf.CellFormat(pageW-2*margin, 6, "Source Exports", "", 0, "L", false, 0, "") + metaY += 7 + + pdf.SetFont("Helvetica", "", 8.5) + pdf.SetTextColor(85, 85, 85) + for _, doc := range docs { + line := fmt.Sprintf("%s | Category: %s | Mode: %s | Count: %d | Seed: %s", + doc.Metadata.SourceFileName, + doc.Metadata.Category, + doc.Metadata.ModeSelection, + doc.Metadata.Count, + emptyAs(doc.Metadata.Seed, "none"), + ) + wrapped := pdf.SplitLines([]byte(line), pageW-2*margin) + for _, raw := range wrapped { + if metaY > pageH-45 { + break + } + pdf.SetXY(margin, metaY) + pdf.CellFormat(pageW-2*margin, 5, string(raw), "", 0, "L", false, 0, "") + metaY += 5 + } + if metaY > pageH-45 { + break + } + } + + pdf.SetTextColor(50, 50, 50) + pdf.SetFont("Helvetica", "B", 12) + pdf.SetXY(margin, pageH-30) + pdf.CellFormat(pageW-2*margin, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "") + + pdf.SetFont("Helvetica", "", 10) + pdf.SetXY(margin, pageH-22) + pdf.CellFormat(pageW-2*margin, 6, cfg.AdvertText, "", 0, "C", false, 0, "") +} + +func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) { + pdf.AddPage() + pageW, pageH := pdf.GetPageSize() + + pdf.SetTextColor(20, 20, 20) + pdf.SetFont("Helvetica", "B", 13) + 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, "") + + pdf.SetFont("Helvetica", "", 9) + pdf.SetTextColor(95, 95, 95) + pdf.SetXY(0, 17) + subtitle := fmt.Sprintf("Mode: %s | Source: %s | Difficulty score: %.2f", + emptyAs(puzzle.ModeSelection, "mixed modes"), + puzzle.SourceFileName, + puzzle.DifficultyScore, + ) + pdf.CellFormat(pageW, 5, subtitle, "", 0, "C", false, 0, "") + + if puzzle.Nonogram != nil { + renderNonogramPage(pdf, puzzle.Nonogram) + return + } + if puzzle.Table != nil { + renderGridTablePage(pdf, puzzle.Table) + return + } + renderFallbackPage(pdf, puzzle, pageH) +} + +func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { + if data == nil || data.Width <= 0 || data.Height <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + marginX := 10.0 + top := 28.0 + bottom := 12.0 + + rowHintCols := maxHintDepth(data.RowHints) + colHintRows := maxHintDepth(data.ColHints) + if rowHintCols < 1 { + rowHintCols = 1 + } + if colHintRows < 1 { + colHintRows = 1 + } + + totalCols := rowHintCols + data.Width + totalRows := colHintRows + data.Height + + availW := pageW - 2*marginX + availH := pageH - top - bottom + cellSize := math.Min(availW/float64(totalCols), availH/float64(totalRows)) + if cellSize > 8.6 { + cellSize = 8.6 + } + + blockW := float64(totalCols) * cellSize + blockH := float64(totalRows) * cellSize + startX := (pageW - blockW) / 2 + startY := top + (availH-blockH)/2 + + pdf.SetDrawColor(45, 45, 45) + pdf.SetLineWidth(0.12) + for r := 0; r < totalRows; r++ { + for c := 0; c < totalCols; c++ { + x := startX + float64(c)*cellSize + y := startY + float64(r)*cellSize + pdf.Rect(x, y, cellSize, cellSize, "D") + + switch { + case r < colHintRows && c >= rowHintCols: + col := c - rowHintCols + if text := colHintText(data.ColHints[col], colHintRows, r); text != "" { + drawCellText(pdf, x, y, cellSize, cellSize, text, true) + } + case r >= colHintRows && c < rowHintCols: + row := r - colHintRows + if text := rowHintText(data.RowHints[row], rowHintCols, c); text != "" { + drawCellText(pdf, x, y, cellSize, cellSize, text, true) + } + case r >= colHintRows && c >= rowHintCols: + row := r - colHintRows + col := c - rowHintCols + if row < len(data.Grid) && col < len(data.Grid[row]) { + cellText := strings.TrimSpace(data.Grid[row][col]) + if cellText != "" && cellText != "." { + drawCellText(pdf, x, y, cellSize, cellSize, cellText, false) + } + } + } + } + } + + xSep := startX + float64(rowHintCols)*cellSize + ySep := startY + float64(colHintRows)*cellSize + pdf.SetLineWidth(0.4) + pdf.Line(xSep, startY, xSep, startY+blockH) + pdf.Line(startX, ySep, startX+blockW, ySep) + + pdf.SetLineWidth(0.55) + pdf.Rect(startX, startY, blockW, blockH, "D") +} + +func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { + if table == nil || len(table.Rows) == 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + marginX := 10.0 + top := 28.0 + bottom := 12.0 + + rows := len(table.Rows) + cols := 0 + for _, row := range table.Rows { + if len(row) > cols { + cols = len(row) + } + } + if cols == 0 { + return + } + + availW := pageW - 2*marginX + availH := pageH - top - bottom + cellSize := math.Min(availW/float64(cols), availH/float64(rows)) + if cellSize > 11.2 { + cellSize = 11.2 + } + if cellSize < 3.3 { + cellSize = 3.3 + } + + blockW := float64(cols) * cellSize + blockH := float64(rows) * cellSize + startX := (pageW - blockW) / 2 + startY := top + (availH-blockH)/2 + + pdf.SetDrawColor(55, 55, 55) + pdf.SetLineWidth(0.16) + for r := 0; r < rows; r++ { + for c := 0; c < cols; c++ { + x := startX + float64(c)*cellSize + y := startY + float64(r)*cellSize + pdf.Rect(x, y, cellSize, cellSize, "D") + + var text string + if c < len(table.Rows[r]) { + text = strings.TrimSpace(table.Rows[r][c]) + } + if text == "." { + text = " " + } + if text == "" { + continue + } + + dim := (table.HasHeaderRow && r == 0) || (table.HasHeaderCol && c == 0) + drawCellText(pdf, x, y, cellSize, cellSize, text, dim) + } + } + + if table.HasHeaderRow { + ySep := startY + cellSize + pdf.SetLineWidth(0.42) + pdf.Line(startX, ySep, startX+blockW, ySep) + } + if table.HasHeaderCol { + xSep := startX + cellSize + pdf.SetLineWidth(0.42) + pdf.Line(xSep, startY, xSep, startY+blockH) + } + + pdf.SetLineWidth(0.6) + pdf.Rect(startX, startY, blockW, blockH, "D") +} + +func renderFallbackPage(pdf *fpdf.Fpdf, puzzle Puzzle, pageH float64) { + pageW, _ := pdf.GetPageSize() + marginX := 10.0 + top := 28.0 + bottom := 12.0 + availW := pageW - 2*marginX + availH := pageH - top - bottom + + lines := sanitizeBody(puzzle.Body) + fontSize := 9.2 + lineHeight := 4.8 + + pdf.SetFont("Courier", "", fontSize) + wrapped := make([]string, 0, len(lines)) + for _, line := range lines { + chunks := pdf.SplitLines([]byte(line), availW) + if len(chunks) == 0 { + wrapped = append(wrapped, "") + continue + } + for _, raw := range chunks { + wrapped = append(wrapped, string(raw)) + } + } + + if total := float64(len(wrapped)) * lineHeight; total > availH && len(wrapped) > 0 { + maxLines := int(availH / lineHeight) + if maxLines < len(wrapped) { + wrapped = append(wrapped[:max(0, maxLines-1)], "...") + } + } + + blockH := float64(len(wrapped)) * lineHeight + startY := top + (availH-blockH)/2 + + pdf.SetTextColor(50, 50, 50) + y := startY + for _, line := range wrapped { + w := pdf.GetStringWidth(line) + x := (pageW - w) / 2 + if x < marginX { + x = marginX + } + pdf.SetXY(x, y) + pdf.CellFormat(availW, lineHeight, line, "", 0, "L", false, 0, "") + y += lineHeight + } +} + +func sanitizeBody(body string) []string { + lines := strings.Split(body, "\n") + cleaned := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || trimmed == "---" { + cleaned = append(cleaned, "") + continue + } + if strings.HasPrefix(trimmed, "### ") { + cleaned = append(cleaned, strings.TrimSpace(strings.TrimPrefix(trimmed, "### "))) + continue + } + if strings.HasPrefix(trimmed, "|") { + line = strings.ReplaceAll(line, ".", " ") + } + cleaned = append(cleaned, line) + } + return cleaned +} + +func drawCellText(pdf *fpdf.Fpdf, x, y, w, h float64, text string, dim bool) { + if strings.TrimSpace(text) == "" { + return + } + if dim { + pdf.SetTextColor(130, 130, 130) + } else { + pdf.SetTextColor(25, 25, 25) + } + + fontSize := math.Max(3.1, math.Min(6.3, h*0.63)) + pdf.SetFont("Helvetica", "", fontSize) + lineH := fontSize * 0.9 + pdf.SetXY(x, y+(h-lineH)/2) + pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "") +} + +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 summarizeCategories(puzzles []Puzzle) []string { + set := map[string]struct{}{} + for _, p := range puzzles { + category := strings.TrimSpace(p.Category) + if category == "" { + continue + } + set[category] = struct{}{} + } + + categories := make([]string, 0, len(set)) + for category := range set { + categories = append(categories, category) + } + sort.Strings(categories) + if len(categories) == 0 { + return []string{"Unknown"} + } + return categories +} + +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 emptyAs(v, fallback string) string { + if strings.TrimSpace(v) == "" { + return fallback + } + return v +} diff --git a/pdfexport/types.go b/pdfexport/types.go new file mode 100644 index 0000000..e8ff887 --- /dev/null +++ b/pdfexport/types.go @@ -0,0 +1,64 @@ +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 + Nonogram *NonogramData + Table *GridTable + DifficultyScore float64 + DifficultyConfidence DifficultyConfidence + DifficultySource string +} + +type NonogramData struct { + Width int + Height int + RowHints [][]int + ColHints [][]int + Grid [][]string +} + +type GridTable struct { + Rows [][]string + HasHeaderRow bool + HasHeaderCol bool +} + +type RenderConfig struct { + Title string + AdvertText string + GeneratedAt time.Time + ShuffleSeed string +} From 39167a3fd7abd9973142f5d191914de69e53dfaa Mon Sep 17 00:00:00 2001 From: Dami Date: Sat, 21 Feb 2026 21:59:08 -0700 Subject: [PATCH 02/14] swap to jsonl export. --- README.md | 18 +++-- cmd/export_pdf.go | 10 +-- cmd/new.go | 2 +- cmd/new_export.go | 109 ++++++++++++++++++------- cmd/new_export_test.go | 44 ++++++---- pdfexport/jsonl.go | 172 ++++++++++++++++++++++++++++++++++++++++ pdfexport/jsonl_test.go | 80 +++++++++++++++++++ 7 files changed, 381 insertions(+), 54 deletions(-) create mode 100644 pdfexport/jsonl.go create mode 100644 pdfexport/jsonl_test.go diff --git a/README.md b/README.md index 9921069..483ce08 100644 --- a/README.md +++ b/README.md @@ -106,20 +106,26 @@ puzzletea new --set-seed myseed puzzletea new nonogram epic --with-seed myseed ``` -Export printable puzzle sets to markdown: +Export printable puzzle sets to JSONL: ```bash -# Stream markdown to stdout (redirect if desired) -puzzletea new nonogram mini --export 2 > nonogram-mini-set.md +# 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.md +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.md --with-seed zine-issue-01 +puzzletea new sudoku --export 10 -o sudoku-mixed.jsonl --with-seed zine-issue-01 ``` -`Lights Out` is currently excluded from markdown export because it does not translate cleanly to paper workflows. +Render one or more JSONL packs into an A5 print PDF: + +```bash +puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01 +``` + +`Lights Out` is currently excluded from export because it does not translate cleanly to paper workflows. Override the color theme: diff --git a/cmd/export_pdf.go b/cmd/export_pdf.go index 553bd8d..ce4cbeb 100644 --- a/cmd/export_pdf.go +++ b/cmd/export_pdf.go @@ -21,9 +21,9 @@ var ( ) var exportPDFCmd = &cobra.Command{ - Use: "export-pdf [more.md ...]", - Short: "Convert one or more PuzzleTea markdown exports into an A5 printable PDF", - Long: "Parse one or more markdown export files, order puzzles by progressive difficulty with seeded mixing, and render an A5 PDF with a title page and one puzzle per page.", + Use: "export-pdf [more.jsonl ...]", + Short: "Convert one or more PuzzleTea JSONL exports into an A5 printable PDF", + Long: "Parse one or more JSONL export files, order puzzles by progressive difficulty with seeded mixing, and render an A5 PDF with a title page and one puzzle per page.", Args: cobra.MinimumNArgs(1), RunE: runExportPDF, } @@ -36,14 +36,14 @@ func init() { } func runExportPDF(cmd *cobra.Command, args []string) error { - docs, err := pdfexport.ParseFiles(args) + docs, err := pdfexport.ParseJSONLFiles(args) if err != nil { return err } puzzles := flattenPuzzles(docs) if len(puzzles) == 0 { - return fmt.Errorf("no puzzles found in input markdown files") + return fmt.Errorf("no puzzles found in input jsonl files") } lookup := buildModeDifficultyLookup(app.Categories) diff --git a/cmd/new.go b/cmd/new.go index e0b1ba1..b660239 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -58,7 +58,7 @@ 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 markdown file (defaults to stdout)") + 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 index e9a7ab9..34d0314 100644 --- a/cmd/new_export.go +++ b/cmd/new_export.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "errors" "fmt" "io" @@ -13,6 +14,8 @@ import ( "github.com/FelineStateMachine/puzzletea/app" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/markdownexport" + "github.com/FelineStateMachine/puzzletea/namegen" + "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/FelineStateMachine/puzzletea/resolve" "github.com/spf13/cobra" @@ -35,7 +38,7 @@ func runNewExport(cmd *cobra.Command, args []string) error { return err } if !markdownexport.SupportsGameType(cat.Name) { - return fmt.Errorf("game %q does not support markdown export", cat.Name) + return fmt.Errorf("game %q does not support export", cat.Name) } modeArg := "" @@ -48,21 +51,13 @@ func runNewExport(cmd *cobra.Command, args []string) error { return err } - sections, err := buildExportSections(cat.Name, entries, flagExport, flagWithSeed) + generatedAt := exportNow() + records, err := buildExportRecords(cat.Name, modeSelection, entries, flagExport, flagWithSeed, generatedAt) if err != nil { return err } - doc := markdownexport.BuildDocument(markdownexport.DocumentConfig{ - Version: Version, - Category: cat.Name, - ModeSelection: modeSelection, - Count: flagExport, - Seed: flagWithSeed, - GeneratedAt: exportNow(), - }, sections) - - if err := writeExportMarkdown(cmd, flagOutput, doc); err != nil { + if err := writeExportJSONL(cmd, flagOutput, records); err != nil { return err } @@ -73,11 +68,11 @@ 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), ".md") { - return fmt.Errorf("--output must use a .md extension") + 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 markdown export (--export/--output)") + 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") @@ -119,13 +114,25 @@ func collectExportModes(cat game.Category, modeArg string) ([]exportModeEntry, s return entries, "mixed modes", nil } -func buildExportSections(gameType string, entries []exportModeEntry, count int, seed string) ([]markdownexport.PuzzleSection, error) { +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) } - sections := make([]markdownexport.PuzzleSection, 0, count) + 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 { @@ -147,24 +154,61 @@ func buildExportSections(gameType string, entries []exportModeEntry, count int, 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) + } snippet, err := markdownexport.RenderPuzzleSnippet(gameType, entry.mode, save) if err != nil { if errors.Is(err, markdownexport.ErrUnsupportedGame) { - return nil, fmt.Errorf("game %q does not support markdown export", gameType) + return nil, fmt.Errorf("game %q does not support export", gameType) } return nil, fmt.Errorf("render puzzle %d: %w", i+1, err) } - sections = append(sections, markdownexport.PuzzleSection{ - Index: i + 1, - GameType: gameType, - Mode: entry.mode, - Body: snippet, + nonogram, table, err := pdfexport.ParsePrintableFromSnippet(gameType, snippet) + if err != nil { + return nil, fmt.Errorf("build print payload for puzzle %d: %w", i+1, err) + } + + printKind := "text" + if nonogram != nil { + printKind = "nonogram" + } else if table != nil { + printKind = "grid-table" + } + + 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), + Snippet: snippet, + }, + Print: pdfexport.JSONLPrintData{ + Kind: printKind, + Paper: "A5", + MarginMM: 10, + EmptyGlyph: " ", + HintTone: "dim", + Nonogram: nonogram, + Table: table, + }, }) } - return sections, nil + return records, nil } func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error) { @@ -179,10 +223,21 @@ func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error) return seeded.SpawnSeeded(rng) } -func writeExportMarkdown(cmd *cobra.Command, path, content string) error { +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 markdown to stdout: %w", err) + return fmt.Errorf("write export jsonl to stdout: %w", err) } return nil } @@ -195,7 +250,7 @@ func writeExportMarkdown(cmd *cobra.Command, path, content string) error { } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - return fmt.Errorf("write output markdown: %w", err) + return fmt.Errorf("write output jsonl: %w", err) } return nil } diff --git a/cmd/new_export_test.go b/cmd/new_export_test.go index 9faf5ac..b58e0a3 100644 --- a/cmd/new_export_test.go +++ b/cmd/new_export_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "encoding/json" "os" "path/filepath" "strconv" @@ -9,25 +10,27 @@ import ( "testing" "time" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/spf13/cobra" ) func TestRunNewExportRejectsUnsupportedGame(t *testing.T) { withExportFlagReset(t) - flagOutput = filepath.Join(t.TempDir(), "lights.md") + flagOutput = filepath.Join(t.TempDir(), "lights.jsonl") cmd, _ := newExportTestCmd(t, false) err := runNewExport(cmd, []string{"lights-out"}) if err == nil { t.Fatal("expected unsupported game error") } - if !strings.Contains(err.Error(), "does not support markdown export") { + if !strings.Contains(err.Error(), "does not support export") { t.Fatalf("unexpected error: %v", err) } } func TestRunNewExportValidation(t *testing.T) { - t.Run("writes to stdout when output omitted", func(t *testing.T) { + t.Run("writes jsonl to stdout when output omitted", func(t *testing.T) { withExportFlagReset(t) flagExport = 2 @@ -36,12 +39,23 @@ func TestRunNewExportValidation(t *testing.T) { if err != nil { t.Fatalf("expected stdout export success, got error: %v", err) } - if !strings.Contains(out.String(), "# PuzzleTea Export") { - t.Fatalf("expected markdown output on stdout, got:\n%s", out.String()) + + 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 markdown", func(t *testing.T) { + t.Run("output extension must be jsonl", func(t *testing.T) { withExportFlagReset(t) flagOutput = filepath.Join(t.TempDir(), "out.txt") @@ -50,7 +64,7 @@ func TestRunNewExportValidation(t *testing.T) { if err == nil { t.Fatal("expected extension validation error") } - if !strings.Contains(err.Error(), ".md extension") { + if !strings.Contains(err.Error(), ".jsonl extension") { t.Fatalf("unexpected error: %v", err) } }) @@ -58,14 +72,14 @@ func TestRunNewExportValidation(t *testing.T) { t.Run("set-seed cannot be combined with output", func(t *testing.T) { withExportFlagReset(t) flagSetSeed = "abc" - flagOutput = filepath.Join(t.TempDir(), "out.md") + 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 markdown export (--export/--output)") { + if !strings.Contains(err.Error(), "--set-seed cannot be combined with export (--export/--output)") { t.Fatalf("unexpected error: %v", err) } }) @@ -79,8 +93,8 @@ func TestRunNewExportReproducibleWithSeed(t *testing.T) { exportNow = func() time.Time { return fixedNow } t.Cleanup(func() { exportNow = previousNow }) - fileA := filepath.Join(t.TempDir(), "a.md") - fileB := filepath.Join(t.TempDir(), "b.md") + fileA := filepath.Join(t.TempDir(), "a.jsonl") + fileB := filepath.Join(t.TempDir(), "b.jsonl") flagExport = 3 flagWithSeed = "zine-seed-01" @@ -105,14 +119,14 @@ func TestRunNewExportReproducibleWithSeed(t *testing.T) { t.Fatal(err) } if string(a) != string(b) { - t.Fatal("expected deterministic markdown output for identical seed and args") + t.Fatal("expected deterministic jsonl output for identical seed and args") } } func TestRunNewExportOverwritesOutputFile(t *testing.T) { withExportFlagReset(t) - file := filepath.Join(t.TempDir(), "out.md") + file := filepath.Join(t.TempDir(), "out.jsonl") if err := os.WriteFile(file, []byte("old"), 0o644); err != nil { t.Fatal(err) } @@ -133,8 +147,8 @@ func TestRunNewExportOverwritesOutputFile(t *testing.T) { if string(data) == "old" { t.Fatal("expected output file to be overwritten") } - if !strings.Contains(string(data), "# PuzzleTea Export") { - t.Fatal("expected markdown export header") + if !strings.Contains(string(data), pdfexport.ExportSchemaV1) { + t.Fatal("expected jsonl export schema marker") } } diff --git a/pdfexport/jsonl.go b/pdfexport/jsonl.go new file mode 100644 index 0000000..4b9f645 --- /dev/null +++ b/pdfexport/jsonl.go @@ -0,0 +1,172 @@ +package pdfexport + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ExportSchemaV1 = "puzzletea.export.v1" + +type JSONLRecord struct { + Schema string `json:"schema"` + Pack JSONLPackMeta `json:"pack"` + Puzzle JSONLPuzzle `json:"puzzle"` + Print JSONLPrintData `json:"print"` +} + +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"` + Snippet string `json:"snippet,omitempty"` +} + +type JSONLPrintData struct { + Kind string `json:"kind"` + Paper string `json:"paper"` + MarginMM float64 `json:"margin_mm"` + EmptyGlyph string `json:"empty_glyph"` + HintTone string `json:"hint_tone"` + Nonogram *NonogramData `json:"nonogram,omitempty"` + Table *GridTable `json:"table,omitempty"` +} + +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, + Body: record.Puzzle.Snippet, + Nonogram: record.Print.Nonogram, + Table: record.Print.Table, + } + + if p.Nonogram == nil && p.Table == nil && strings.TrimSpace(record.Puzzle.Snippet) != "" { + nonogram, table, err := ParsePrintableFromSnippet(category, record.Puzzle.Snippet) + if err != nil { + return PackDocument{}, fmt.Errorf("%s:%d: parse printable snippet: %w", path, lineNo, err) + } + p.Nonogram = nonogram + p.Table = table + } + + 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 len(puzzles) == 0 { + return PackDocument{}, fmt.Errorf("%s: no puzzle records found", path) + } + + if doc.Metadata.Count == 0 { + doc.Metadata.Count = len(puzzles) + } + doc.Puzzles = puzzles + return doc, nil +} + +func ParsePrintableFromSnippet(category, snippet string) (*NonogramData, *GridTable, error) { + lines := strings.Split(strings.ReplaceAll(strings.ReplaceAll(snippet, "\r\n", "\n"), "\r", "\n"), "\n") + if strings.EqualFold(strings.TrimSpace(category), "nonogram") { + nonogram, err := parseNonogramBody(lines, "snippet", 1) + if err != nil { + return nil, nil, err + } + return nonogram, nil, nil + } + + table, err := parseGridTableBody(lines, "snippet", 1) + if err != nil { + return nil, nil, err + } + return nil, table, nil +} diff --git a/pdfexport/jsonl_test.go b/pdfexport/jsonl_test.go new file mode 100644 index 0000000..bc9cbec --- /dev/null +++ b/pdfexport/jsonl_test.go @@ -0,0 +1,80 @@ +package pdfexport + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseJSONLFile(t *testing.T) { + temp := t.TempDir() + path := filepath.Join(temp, "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}`), + Snippet: "### Puzzle Grid with Integrated Hints\n\n" + + "| R1 | C1 | C2 |\n" + + "| --- | --- | --- |\n" + + "| . | 1 | 2 |\n" + + "| 1 | . | . |\n", + }, + Print: JSONLPrintData{ + Kind: "nonogram", + Paper: "A5", + MarginMM: 10, + EmptyGlyph: " ", + HintTone: "dim", + }, + } + + 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) + } + + 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) + } + if doc.Puzzles[0].Nonogram == nil { + t.Fatal("expected nonogram print payload from snippet fallback") + } +} + +func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) { + temp := t.TempDir() + path := filepath.Join(temp, "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) + } +} From c82d6433d7ca18173f959703aa2f43043ac7db28 Mon Sep 17 00:00:00 2001 From: Dami Date: Sat, 21 Feb 2026 23:25:09 -0700 Subject: [PATCH 03/14] feat: add hashi pdf renderer and print payload pipeline --- cmd/new_export.go | 55 +++ cmd/new_export_test.go | 78 ++++ pdfexport/font.go | 20 + pdfexport/font_test.go | 73 +++ pdfexport/jsonl.go | 30 +- pdfexport/jsonl_test.go | 292 ++++++++++++ pdfexport/printdata.go | 519 +++++++++++++++++++++ pdfexport/printdata_test.go | 212 +++++++++ pdfexport/render.go | 665 ++++++++++++++++++++++++--- pdfexport/render_hashi.go | 89 ++++ pdfexport/render_hitori_takuzu.go | 184 ++++++++ pdfexport/render_nonogram_test.go | 70 +++ pdfexport/render_nurikabe_shikaku.go | 124 +++++ pdfexport/render_takuzu_test.go | 44 ++ pdfexport/types.go | 54 +++ 15 files changed, 2432 insertions(+), 77 deletions(-) create mode 100644 pdfexport/font.go create mode 100644 pdfexport/font_test.go create mode 100644 pdfexport/printdata.go create mode 100644 pdfexport/printdata_test.go create mode 100644 pdfexport/render_hashi.go create mode 100644 pdfexport/render_hitori_takuzu.go create mode 100644 pdfexport/render_nonogram_test.go create mode 100644 pdfexport/render_nurikabe_shikaku.go create mode 100644 pdfexport/render_takuzu_test.go diff --git a/cmd/new_export.go b/cmd/new_export.go index 34d0314..6aaff3f 100644 --- a/cmd/new_export.go +++ b/cmd/new_export.go @@ -171,9 +171,52 @@ func buildExportRecords( return nil, fmt.Errorf("build print payload for puzzle %d: %w", i+1, err) } + var nurikabe *pdfexport.NurikabeData + var shikaku *pdfexport.ShikakuData + var hashi *pdfexport.HashiData + var sudoku *pdfexport.SudokuData + var wordSearch *pdfexport.WordSearchData + switch normalizeExportGameType(gameType) { + case "hashiwokakero": + hashi, err = pdfexport.ParseHashiPrintData(save) + if err != nil { + return nil, fmt.Errorf("build hashiwokakero print payload for puzzle %d: %w", i+1, err) + } + case "nurikabe": + nurikabe, err = pdfexport.ParseNurikabePrintData(save) + if err != nil { + return nil, fmt.Errorf("build nurikabe print payload for puzzle %d: %w", i+1, err) + } + case "shikaku": + shikaku, err = pdfexport.ParseShikakuPrintData(save) + if err != nil { + return nil, fmt.Errorf("build shikaku print payload for puzzle %d: %w", i+1, err) + } + case "sudoku": + sudoku, err = pdfexport.ParseSudokuPrintData(save) + if err != nil { + return nil, fmt.Errorf("build sudoku print payload for puzzle %d: %w", i+1, err) + } + case "wordsearch": + wordSearch, err = pdfexport.ParseWordSearchPrintData(save) + if err != nil { + return nil, fmt.Errorf("build word search print payload for puzzle %d: %w", i+1, err) + } + } + printKind := "text" if nonogram != nil { printKind = "nonogram" + } else if hashi != nil { + printKind = "hashi" + } else if nurikabe != nil { + printKind = "nurikabe" + } else if shikaku != nil { + printKind = "shikaku" + } else if sudoku != nil { + printKind = "sudoku" + } else if wordSearch != nil { + printKind = "word-search" } else if table != nil { printKind = "grid-table" } @@ -203,6 +246,11 @@ func buildExportRecords( EmptyGlyph: " ", HintTone: "dim", Nonogram: nonogram, + Hashi: hashi, + Nurikabe: nurikabe, + Shikaku: shikaku, + Sudoku: sudoku, + WordSearch: wordSearch, Table: table, }, }) @@ -223,6 +271,13 @@ func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error) return seeded.SpawnSeeded(rng) } +func normalizeExportGameType(gameType string) string { + gameType = strings.ToLower(strings.TrimSpace(gameType)) + gameType = strings.ReplaceAll(gameType, "-", "") + gameType = strings.Join(strings.Fields(gameType), "") + return gameType +} + func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error { var b strings.Builder for _, record := range records { diff --git a/cmd/new_export_test.go b/cmd/new_export_test.go index b58e0a3..aae7247 100644 --- a/cmd/new_export_test.go +++ b/cmd/new_export_test.go @@ -152,6 +152,84 @@ func TestRunNewExportOverwritesOutputFile(t *testing.T) { } } +func TestRunNewExportSudokuPrintPayload(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 pdfexport.JSONLRecord + if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + t.Fatal(err) + } + if got, want := record.Print.Kind, "sudoku"; got != want { + t.Fatalf("print kind = %q, want %q", got, want) + } + if record.Print.Sudoku == nil { + t.Fatal("expected sudoku print payload") + } +} + +func TestRunNewExportWordSearchPrintPayload(t *testing.T) { + withExportFlagReset(t) + flagExport = 1 + + cmd, out := newExportTestCmd(t, true) + if err := runNewExport(cmd, []string{"wordsearch", "easy 10x10"}); err != nil { + t.Fatalf("expected word search 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 pdfexport.JSONLRecord + if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + t.Fatal(err) + } + if got, want := record.Print.Kind, "word-search"; got != want { + t.Fatalf("print kind = %q, want %q", got, want) + } + if record.Print.WordSearch == nil { + t.Fatal("expected word search print payload") + } +} + +func TestRunNewExportHashiPrintPayload(t *testing.T) { + withExportFlagReset(t) + flagExport = 1 + + cmd, out := newExportTestCmd(t, true) + if err := runNewExport(cmd, []string{"hashiwokakero", "easy 7x7"}); err != nil { + t.Fatalf("expected hashi 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 pdfexport.JSONLRecord + if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + t.Fatal(err) + } + if got, want := record.Print.Kind, "hashi"; got != want { + t.Fatalf("print kind = %q, want %q", got, want) + } + if record.Print.Hashi == nil { + t.Fatal("expected hashi print payload") + } +} + func withExportFlagReset(t *testing.T) { t.Helper() diff --git a/pdfexport/font.go b/pdfexport/font.go new file mode 100644 index 0000000..fb7adbe --- /dev/null +++ b/pdfexport/font.go @@ -0,0 +1,20 @@ +package pdfexport + +const ( + standardCellFontMin = 4.2 + standardCellFontMax = 8.0 +) + +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..4ed1b52 --- /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: 4.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.0, + }, + } + + 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: 4.2, + }, + { + name: "in range", + in: 6.5, + want: 6.5, + }, + { + name: "above max", + in: 9.1, + want: 8.0, + }, + } + + 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/jsonl.go b/pdfexport/jsonl.go index 4b9f645..edf8e51 100644 --- a/pdfexport/jsonl.go +++ b/pdfexport/jsonl.go @@ -37,13 +37,20 @@ type JSONLPuzzle struct { } type JSONLPrintData struct { - Kind string `json:"kind"` - Paper string `json:"paper"` - MarginMM float64 `json:"margin_mm"` - EmptyGlyph string `json:"empty_glyph"` - HintTone string `json:"hint_tone"` - Nonogram *NonogramData `json:"nonogram,omitempty"` - Table *GridTable `json:"table,omitempty"` + Kind string `json:"kind"` + Paper string `json:"paper"` + MarginMM float64 `json:"margin_mm"` + EmptyGlyph string `json:"empty_glyph"` + HintTone string `json:"hint_tone"` + Nonogram *NonogramData `json:"nonogram,omitempty"` + Nurikabe *NurikabeData `json:"nurikabe,omitempty"` + Shikaku *ShikakuData `json:"shikaku,omitempty"` + Hashi *HashiData `json:"hashi,omitempty"` + Hitori *HitoriData `json:"hitori,omitempty"` + Takuzu *TakuzuData `json:"takuzu,omitempty"` + Sudoku *SudokuData `json:"sudoku,omitempty"` + WordSearch *WordSearchData `json:"word_search,omitempty"` + Table *GridTable `json:"table,omitempty"` } func ParseJSONLFiles(paths []string) ([]PackDocument, error) { @@ -121,9 +128,18 @@ func ParseJSONLFile(path string) (PackDocument, error) { Name: record.Puzzle.Name, Index: record.Puzzle.Index, Body: record.Puzzle.Snippet, + SaveData: append([]byte(nil), record.Puzzle.Save...), Nonogram: record.Print.Nonogram, + Nurikabe: record.Print.Nurikabe, + Shikaku: record.Print.Shikaku, + Hashi: record.Print.Hashi, + Hitori: record.Print.Hitori, + Takuzu: record.Print.Takuzu, + Sudoku: record.Print.Sudoku, + WordSearch: record.Print.WordSearch, Table: record.Print.Table, } + hydratePuzzlePrintData(&p) if p.Nonogram == nil && p.Table == nil && strings.TrimSpace(record.Puzzle.Snippet) != "" { nonogram, table, err := ParsePrintableFromSnippet(category, record.Puzzle.Snippet) diff --git a/pdfexport/jsonl_test.go b/pdfexport/jsonl_test.go index bc9cbec..643827d 100644 --- a/pdfexport/jsonl_test.go +++ b/pdfexport/jsonl_test.go @@ -78,3 +78,295 @@ func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) { + temp := t.TempDir() + path := filepath.Join(temp, "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#."}`), + }, + Print: JSONLPrintData{ + Kind: "grid-table", + }, + } + + 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) + } + + 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) + } + if doc.Puzzles[0].Takuzu == nil { + t.Fatal("expected takuzu print payload from save hydration") + } + if got, want := doc.Puzzles[0].Takuzu.Givens[1][1], ""; got != want { + t.Fatalf("takuzu row 1 col 1 = %q, want empty", got) + } +} + +func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) { + temp := t.TempDir() + path := filepath.Join(temp, "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}]}`), + }, + Print: JSONLPrintData{ + Kind: "sudoku", + }, + } + + 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) + } + + 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) + } + if doc.Puzzles[0].Sudoku == nil { + t.Fatal("expected sudoku print payload from save hydration") + } + if got, want := doc.Puzzles[0].Sudoku.Givens[0][0], 5; got != want { + t.Fatalf("sudoku givens[0][0] = %d, want %d", got, want) + } +} + +func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) { + temp := t.TempDir() + path := filepath.Join(temp, "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"}]}`), + }, + Print: JSONLPrintData{ + Kind: "word-search", + }, + } + + 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) + } + + 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) + } + if doc.Puzzles[0].WordSearch == nil { + t.Fatal("expected word search print payload from save hydration") + } + if got, want := len(doc.Puzzles[0].WordSearch.Words), 2; got != want { + t.Fatalf("word count = %d, want %d", got, want) + } + if got, want := doc.Puzzles[0].WordSearch.Words[0], "ACE"; got != want { + t.Fatalf("first word = %q, want %q", got, want) + } +} + +func TestParseJSONLFileHydratesNurikabeFromSave(t *testing.T) { + temp := t.TempDir() + path := filepath.Join(temp, "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??"}`), + }, + Print: JSONLPrintData{ + Kind: "nurikabe", + }, + } + + 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) + } + + 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) + } + if doc.Puzzles[0].Nurikabe == nil { + t.Fatal("expected nurikabe print payload from save hydration") + } + if got, want := doc.Puzzles[0].Nurikabe.Clues[1][1], 2; got != want { + t.Fatalf("nurikabe clues[1][1] = %d, want %d", got, want) + } +} + +func TestParseJSONLFileHydratesShikakuFromSave(t *testing.T) { + temp := t.TempDir() + path := filepath.Join(temp, "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}]}`), + }, + Print: JSONLPrintData{ + Kind: "shikaku", + }, + } + + 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) + } + + 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) + } + if doc.Puzzles[0].Shikaku == nil { + t.Fatal("expected shikaku print payload from save hydration") + } + if got, want := doc.Puzzles[0].Shikaku.Clues[1][1], 4; got != want { + t.Fatalf("shikaku clues[1][1] = %d, want %d", got, want) + } +} + +func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) { + temp := t.TempDir() + path := filepath.Join(temp, "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":[]}`), + }, + Print: JSONLPrintData{ + Kind: "hashi", + }, + } + + 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) + } + + 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) + } + if doc.Puzzles[0].Hashi == nil { + t.Fatal("expected hashi print payload from save hydration") + } + if got, want := len(doc.Puzzles[0].Hashi.Islands), 2; got != want { + t.Fatalf("hashi island count = %d, want %d", got, want) + } +} diff --git a/pdfexport/printdata.go b/pdfexport/printdata.go new file mode 100644 index 0000000..b5156aa --- /dev/null +++ b/pdfexport/printdata.go @@ -0,0 +1,519 @@ +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 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 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 hydratePuzzlePrintData(p *Puzzle) { + if p == nil { + return + } + + switch normalizePrintableCategory(p.Category) { + case "hashiwokakero": + if p.Hashi != nil { + return + } + hashi, err := ParseHashiPrintData(p.SaveData) + if err == nil { + p.Hashi = hashi + } + case "nurikabe": + if p.Nurikabe != nil { + return + } + nurikabe, err := ParseNurikabePrintData(p.SaveData) + if err == nil { + p.Nurikabe = nurikabe + } + case "shikaku": + if p.Shikaku != nil { + return + } + shikaku, err := ParseShikakuPrintData(p.SaveData) + if err == nil { + p.Shikaku = shikaku + } + case "hitori": + if p.Hitori != nil { + return + } + hitori, err := ParseHitoriPrintData(p.SaveData) + if err == nil { + p.Hitori = hitori + } + case "takuzu": + if p.Takuzu != nil { + return + } + takuzu, err := ParseTakuzuPrintData(p.SaveData) + if err == nil { + p.Takuzu = takuzu + } + case "sudoku": + if p.Sudoku != nil { + return + } + sudoku, err := ParseSudokuPrintData(p.SaveData) + if err == nil { + p.Sudoku = sudoku + } + case "wordsearch": + if p.WordSearch != nil { + return + } + wordSearch, err := ParseWordSearchPrintData(p.SaveData) + if err == nil { + p.WordSearch = wordSearch + } + } +} + +func normalizePrintableCategory(category string) string { + category = strings.ToLower(strings.TrimSpace(category)) + category = strings.ReplaceAll(category, "-", "") + category = strings.Join(strings.Fields(category), "") + return category +} + +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 +} diff --git a/pdfexport/printdata_test.go b/pdfexport/printdata_test.go new file mode 100644 index 0000000..2b80dd4 --- /dev/null +++ b/pdfexport/printdata_test.go @@ -0,0 +1,212 @@ +package pdfexport + +import "testing" + +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) + } +} + +func TestHydratePuzzlePrintDataForTakuzu(t *testing.T) { + p := Puzzle{ + Category: "Takuzu", + SaveData: []byte(`{"size":2,"state":"01\n10","provided":"##\n#."}`), + } + + hydratePuzzlePrintData(&p) + if p.Takuzu == nil { + t.Fatal("expected takuzu payload from save hydration") + } + if got, want := p.Takuzu.Givens[0][1], "1"; got != want { + t.Fatalf("row 0 col 1 = %q, want %q", got, want) + } + if got, want := p.Takuzu.Givens[1][1], ""; got != want { + t.Fatalf("row 1 col 1 = %q, want empty", got) + } +} + +func TestHydratePuzzlePrintDataForNurikabeAndShikaku(t *testing.T) { + nurikabePuzzle := Puzzle{ + Category: "Nurikabe", + SaveData: []byte(`{"width":2,"height":2,"clues":"1,0\n0,2","marks":"??\n??"}`), + } + hydratePuzzlePrintData(&nurikabePuzzle) + if nurikabePuzzle.Nurikabe == nil { + t.Fatal("expected nurikabe payload from save hydration") + } + if got, want := nurikabePuzzle.Nurikabe.Clues[1][1], 2; got != want { + t.Fatalf("nurikabe row 1 col 1 = %d, want %d", got, want) + } + + shikakuPuzzle := Puzzle{ + Category: "Shikaku", + SaveData: []byte(`{"width":2,"height":2,"clues":[{"x":0,"y":0,"value":1},{"x":1,"y":1,"value":3}]}`), + } + hydratePuzzlePrintData(&shikakuPuzzle) + if shikakuPuzzle.Shikaku == nil { + t.Fatal("expected shikaku payload from save hydration") + } + if got, want := shikakuPuzzle.Shikaku.Clues[1][1], 3; got != want { + t.Fatalf("shikaku row 1 col 1 = %d, want %d", got, want) + } +} + +func TestHydratePuzzlePrintDataForHashi(t *testing.T) { + puzzle := Puzzle{ + Category: "Hashiwokakero", + SaveData: []byte(`{"width":5,"height":5,"islands":[{"x":0,"y":0,"required":2},{"x":4,"y":4,"required":3}],"bridges":[]}`), + } + + hydratePuzzlePrintData(&puzzle) + if puzzle.Hashi == nil { + t.Fatal("expected hashi payload from save hydration") + } + if got, want := len(puzzle.Hashi.Islands), 2; got != want { + t.Fatalf("island count = %d, want %d", got, want) + } + if got, want := puzzle.Hashi.Islands[1].Required, 3; got != want { + t.Fatalf("island[1].required = %d, want %d", got, want) + } +} diff --git a/pdfexport/render.go b/pdfexport/render.go index ddfe54d..01289db 100644 --- a/pdfexport/render.go +++ b/pdfexport/render.go @@ -48,6 +48,15 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend pdf.SetCreator("PuzzleTea", true) pdf.SetAuthor("PuzzleTea", true) pdf.SetTitle(cfg.Title, true) + pdf.SetFooterFunc(func() { + if pdf.PageNo() <= 1 { + return + } + pdf.SetY(-8) + pdf.SetFont("Helvetica", "", 8) + pdf.SetTextColor(105, 105, 105) + pdf.CellFormat(0, 4, strconv.Itoa(pdf.PageNo()), "", 0, "C", false, 0, "") + }) renderTitlePage(pdf, docs, puzzles, cfg) for _, puzzle := range puzzles { @@ -80,13 +89,13 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg pdf.SetFont("Helvetica", "", 11) pdf.SetTextColor(70, 70, 70) pdf.SetXY(0, 36) - pdf.CellFormat(pageW, 6, "A5 Printable Puzzle Pack", "", 0, "C", false, 0, "") + pdf.CellFormat(pageW, 6, "PuzzleTea Puzzle Pack", "", 0, "C", false, 0, "") metaY := 50.0 pdf.SetTextColor(25, 25, 25) pdf.SetFont("Helvetica", "", 10) pdf.SetXY(margin, metaY) - pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format(time.RFC3339)), "", 0, "L", false, 0, "") + pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format("January 2, 2006")), "", 0, "L", false, 0, "") metaY += 6 categories := summarizeCategories(puzzles) @@ -103,29 +112,7 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg pdf.CellFormat(pageW-2*margin, 6, "Source Exports", "", 0, "L", false, 0, "") metaY += 7 - pdf.SetFont("Helvetica", "", 8.5) - pdf.SetTextColor(85, 85, 85) - for _, doc := range docs { - line := fmt.Sprintf("%s | Category: %s | Mode: %s | Count: %d | Seed: %s", - doc.Metadata.SourceFileName, - doc.Metadata.Category, - doc.Metadata.ModeSelection, - doc.Metadata.Count, - emptyAs(doc.Metadata.Seed, "none"), - ) - wrapped := pdf.SplitLines([]byte(line), pageW-2*margin) - for _, raw := range wrapped { - if metaY > pageH-45 { - break - } - pdf.SetXY(margin, metaY) - pdf.CellFormat(pageW-2*margin, 5, string(raw), "", 0, "L", false, 0, "") - metaY += 5 - } - if metaY > pageH-45 { - break - } - } + renderSourceExportsTable(pdf, docs, margin, metaY, pageW-2*margin, pageH-45) pdf.SetTextColor(50, 50, 50) pdf.SetFont("Helvetica", "B", 12) @@ -140,6 +127,7 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) { pdf.AddPage() pageW, pageH := pdf.GetPageSize() + hydratePuzzlePrintData(&puzzle) pdf.SetTextColor(20, 20, 20) pdf.SetFont("Helvetica", "B", 13) @@ -150,17 +138,45 @@ func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) { pdf.SetFont("Helvetica", "", 9) pdf.SetTextColor(95, 95, 95) pdf.SetXY(0, 17) - subtitle := fmt.Sprintf("Mode: %s | Source: %s | Difficulty score: %.2f", - emptyAs(puzzle.ModeSelection, "mixed modes"), - puzzle.SourceFileName, - puzzle.DifficultyScore, - ) + 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 puzzle.Nonogram != nil { renderNonogramPage(pdf, puzzle.Nonogram) return } + if puzzle.Nurikabe != nil { + renderNurikabePage(pdf, puzzle.Nurikabe) + return + } + if puzzle.Shikaku != nil { + renderShikakuPage(pdf, puzzle.Shikaku) + return + } + if puzzle.Hashi != nil { + renderHashiPage(pdf, puzzle.Hashi) + return + } + if puzzle.Hitori != nil { + renderHitoriPage(pdf, puzzle.Hitori) + return + } + if puzzle.Takuzu != nil { + renderTakuzuPage(pdf, puzzle.Takuzu) + return + } + if puzzle.Sudoku != nil { + renderSudokuPage(pdf, puzzle.Sudoku) + return + } + if puzzle.WordSearch != nil { + renderWordSearchPage(pdf, puzzle.WordSearch) + return + } if puzzle.Table != nil { renderGridTablePage(pdf, puzzle.Table) return @@ -175,11 +191,15 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { pageW, pageH := pdf.GetPageSize() marginX := 10.0 + padding := 3.0 top := 28.0 bottom := 12.0 - rowHintCols := maxHintDepth(data.RowHints) - colHintRows := maxHintDepth(data.ColHints) + rowHints := normalizeNonogramHintsForRender(data.RowHints, data.Height) + colHints := normalizeNonogramHintsForRender(data.ColHints, data.Width) + + rowHintCols := maxHintDepth(rowHints) + colHintRows := maxHintDepth(colHints) if rowHintCols < 1 { rowHintCols = 1 } @@ -190,58 +210,398 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { totalCols := rowHintCols + data.Width totalRows := colHintRows + data.Height - availW := pageW - 2*marginX - availH := pageH - top - bottom + availW := pageW - 2*(marginX+padding) + availH := pageH - top - bottom - 2*padding cellSize := math.Min(availW/float64(totalCols), availH/float64(totalRows)) - if cellSize > 8.6 { - cellSize = 8.6 + if cellSize > 8.8 { + cellSize = 8.8 + } + if cellSize < 3.2 { + cellSize = 3.2 } blockW := float64(totalCols) * cellSize blockH := float64(totalRows) * cellSize - startX := (pageW - blockW) / 2 - startY := top + (availH-blockH)/2 + gridW := float64(data.Width) * cellSize + gridH := float64(data.Height) * cellSize + startX := marginX + padding + (availW-blockW)/2 + startY := top + padding + (availH-blockH)/2 + + xSep := startX + float64(rowHintCols)*cellSize + ySep := startY + float64(colHintRows)*cellSize + + 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(0.60) + pdf.Rect(xSep, ySep, gridW, gridH, "D") +} + +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(0.12) - for r := 0; r < totalRows; r++ { - for c := 0; c < totalCols; c++ { - x := startX + float64(c)*cellSize - y := startY + float64(r)*cellSize - pdf.Rect(x, y, cellSize, cellSize, "D") + 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) + } +} - switch { - case r < colHintRows && c >= rowHintCols: - col := c - rowHintCols - if text := colHintText(data.ColHints[col], colHintRows, r); text != "" { - drawCellText(pdf, x, y, cellSize, cellSize, text, true) - } - case r >= colHintRows && c < rowHintCols: - row := r - colHintRows - if text := rowHintText(data.RowHints[row], rowHintCols, c); text != "" { - drawCellText(pdf, x, y, cellSize, cellSize, text, true) - } - case r >= colHintRows && c >= rowHintCols: - row := r - colHintRows - col := c - rowHintCols - if row < len(data.Grid) && col < len(data.Grid[row]) { - cellText := strings.TrimSpace(data.Grid[row][col]) - if cellText != "" && cellText != "." { - drawCellText(pdf, x, y, cellSize, cellSize, cellText, false) - } - } +func drawNonogramHintText(pdf *fpdf.Fpdf, x, y, w, h float64, text string) { + if strings.TrimSpace(text) == "" { + return + } + + // Darker, compact hint typography for dense nonograms. + pdf.SetTextColor(55, 55, 55) + fontSize := standardCellFontSize(h, 0.70) + pdf.SetFont("Helvetica", "", fontSize) + lineH := fontSize * 0.86 + pdf.SetXY(x, y+(h-lineH)/2) + pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "") +} + +func renderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) { + if data == nil { + return + } + + pageW, pageH := pdf.GetPageSize() + marginX := 10.0 + top := 28.0 + bottom := 16.0 + availW := pageW - 2*marginX + availH := pageH - top - bottom + + cellSize := math.Min(availW/9.0, availH/9.0) + if cellSize > 12.5 { + cellSize = 12.5 + } + if cellSize < 10.5 { + cellSize = 10.5 + } + + boardW := 9.0 * cellSize + boardH := 9.0 * cellSize + startX := (pageW - boardW) / 2 + startY := top + (availH-boardH)/2 + + drawSudokuGridLines(pdf, startX, startY, cellSize) + drawSudokuGivens(pdf, startX, startY, cellSize, data.Givens) + + pdf.SetFont("Helvetica", "", 8) + pdf.SetTextColor(85, 85, 85) + pdf.SetXY(marginX, pageH-11) + pdf.CellFormat(pageW-2*marginX, 5, "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.70 + case index%3 == 0: + return 0.55 + default: + return 0.15 + } +} + +func drawSudokuGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [9][9]int) { + fontSize := standardCellFontSize(cellSize, 0.62) + lineH := fontSize * 0.85 + pdf.SetFont("Helvetica", "B", fontSize) + pdf.SetTextColor(20, 20, 20) + + 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, "") } } +} - xSep := startX + float64(rowHintCols)*cellSize - ySep := startY + float64(colHintRows)*cellSize - pdf.SetLineWidth(0.4) - pdf.Line(xSep, startY, xSep, startY+blockH) - pdf.Line(startX, ySep, startX+blockW, ySep) +func renderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) { + if data == nil || data.Width <= 0 || data.Height <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + marginX := 10.0 + top := 28.0 + bottom := 12.0 + availW := pageW - 2*marginX + availH := pageH - top - bottom + + columnCount := wordSearchColumnCount(data.Width, len(data.Words)) + wordFontSize := 8.3 + wordLineHeight := 4.2 + gridListGap := 4.0 + + 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 + } + + cellSize := math.Min(availW/float64(data.Width), gridAreaH/float64(data.Height)) + if cellSize > 10.5 { + cellSize = 10.5 + } + if cellSize < 4.2 { + cellSize = 4.2 + } + + gridW := float64(data.Width) * cellSize + gridH := float64(data.Height) * cellSize + gridX := (pageW - gridW) / 2 + gridY := top + (gridAreaH-gridH)/2 + + drawWordSearchGrid(pdf, data, gridX, gridY, cellSize) + drawWordBank(pdf, data.Words, marginX, gridY+gridH+gridListGap, availW, pageH-bottom-(gridY+gridH+gridListGap), columnCount) +} + +func drawWordSearchGrid(pdf *fpdf.Fpdf, data *WordSearchData, startX, startY, cellSize float64) { + pdf.SetDrawColor(45, 45, 45) + pdf.SetLineWidth(0.12) + 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 := standardCellFontSize(cellSize, 0.74) + lineH := fontSize * 0.86 + pdf.SetFont("Helvetica", "B", fontSize) + pdf.SetTextColor(18, 18, 18) + pdf.SetXY(cellX, cellY+(cellSize-lineH)/2) + pdf.CellFormat(cellSize, lineH, cellText, "", 0, "C", false, 0, "") + } + } pdf.SetLineWidth(0.55) - pdf.Rect(startX, startY, blockW, blockH, "D") + 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("Helvetica", "B", 9.2) + pdf.SetXY(x, y) + pdf.CellFormat(width, 4.8, "Word Bank", "", 0, "L", false, 0, "") + + pdf.SetFont("Helvetica", "", 8.3) + pdf.SetTextColor(70, 70, 70) + 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("Helvetica", "", 8.8) + pdf.SetTextColor(95, 95, 95) + 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(25, 25, 25) + pdf.SetFont("Helvetica", "", 8.8) + 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("Helvetica", "", 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 renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { @@ -399,7 +759,7 @@ func drawCellText(pdf *fpdf.Fpdf, x, y, w, h float64, text string, dim bool) { pdf.SetTextColor(25, 25, 25) } - fontSize := math.Max(3.1, math.Min(6.3, h*0.63)) + fontSize := standardCellFontSize(h, 0.63) pdf.SetFont("Helvetica", "", fontSize) lineH := fontSize * 0.9 pdf.SetXY(x, y+(h-lineH)/2) @@ -438,6 +798,48 @@ func maxHintDepth(hints [][]int) int { 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(0.30) + + 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 summarizeCategories(puzzles []Puzzle) []string { set := map[string]struct{}{} for _, p := range puzzles { @@ -469,9 +871,132 @@ func defaultTitle(docs []PackDocument) string { 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(0.14) + pdf.SetFillColor(245, 245, 245) + pdf.SetTextColor(45, 45, 45) + pdf.SetFont("Helvetica", "B", 8.4) + + 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("Helvetica", "", 8.1) + pdf.SetTextColor(70, 70, 70) + 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 } + +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_hashi.go b/pdfexport/render_hashi.go new file mode 100644 index 0000000..0d02b30 --- /dev/null +++ b/pdfexport/render_hashi.go @@ -0,0 +1,89 @@ +package pdfexport + +import ( + "math" + "strconv" + "unicode/utf8" + + "github.com/go-pdf/fpdf" +) + +func renderHashiPage(pdf *fpdf.Fpdf, data *HashiData) { + if data == nil || data.Width <= 0 || data.Height <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + marginX := 10.0 + top := 28.0 + bottom := 22.0 + + availW := pageW - 2*marginX + availH := pageH - top - bottom + + spanX := max(data.Width-1, 1) + spanY := max(data.Height-1, 1) + step := math.Min(availW/float64(spanX), availH/float64(spanY)) + step = math.Max(5.2, math.Min(16.0, step)) + + boardW := float64(spanX) * step + boardH := float64(spanY) * step + originX := (pageW - boardW) / 2 + originY := top + (availH-boardH)/2 + + drawHashiGuideDots(pdf, originX, originY, data.Width, data.Height, step) + drawHashiIslands(pdf, originX, originY, step, data.Islands) + + ruleY := originY + boardH + 5 + if ruleY+4 <= pageH-6 { + pdf.SetTextColor(85, 85, 85) + pdf.SetFont("Helvetica", "", 7.2) + pdf.SetXY(marginX, ruleY) + pdf.CellFormat(pageW-2*marginX, 4, "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(235, 235, 235) + 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 drawHashiIslands(pdf *fpdf.Fpdf, originX, originY, step float64, islands []HashiIsland) { + radius := math.Max(1.4, math.Min(3.2, step*0.23)) + pdf.SetDrawColor(20, 20, 20) + pdf.SetFillColor(255, 255, 255) + pdf.SetLineWidth(0.26) + + 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 drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int) { + text := strconv.Itoa(required) + fontSize := standardCellFontSize(radius*2.0, 0.95) + runeCount := utf8.RuneCountInString(text) + switch { + case runeCount >= 3: + fontSize *= 0.70 + case runeCount == 2: + fontSize *= 0.82 + } + fontSize = clampStandardCellFontSize(fontSize) + + pdf.SetTextColor(18, 18, 18) + pdf.SetFont("Helvetica", "B", fontSize) + lineH := fontSize * 0.88 + pdf.SetXY(cx-radius, cy-lineH/2) + pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "") +} diff --git a/pdfexport/render_hitori_takuzu.go b/pdfexport/render_hitori_takuzu.go new file mode 100644 index 0000000..4072c40 --- /dev/null +++ b/pdfexport/render_hitori_takuzu.go @@ -0,0 +1,184 @@ +package pdfexport + +import ( + "math" + "strings" + "unicode/utf8" + + "github.com/go-pdf/fpdf" +) + +func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) { + if data == nil || data.Size <= 0 { + return + } + + size := data.Size + pageW, pageH := pdf.GetPageSize() + marginX := 10.0 + top := 28.0 + bottom := 22.0 + + availW := pageW - 2*marginX + availH := pageH - top - bottom + cellSize := math.Min(availW/float64(size), availH/float64(size)) + cellSize = math.Max(5.0, math.Min(12.0, cellSize)) + + blockW := float64(size) * cellSize + blockH := float64(size) * cellSize + startX := (pageW - blockW) / 2 + startY := top + (availH-blockH)/2 + + pdf.SetDrawColor(55, 55, 55) + pdf.SetLineWidth(0.16) + 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(0.62) + pdf.Rect(startX, startY, blockW, blockH, "D") + + ruleY := startY + blockH + 5 + if ruleY+4 <= pageH-6 { + pdf.SetTextColor(85, 85, 85) + pdf.SetFont("Helvetica", "", 7.3) + pdf.SetXY(marginX, ruleY) + pdf.CellFormat( + pageW-2*marginX, + 4, + "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 := standardCellFontSize(cellSize, 0.58) + runeCount := utf8.RuneCountInString(text) + switch { + case runeCount >= 3: + fontSize *= 0.7 + case runeCount == 2: + fontSize *= 0.82 + } + fontSize = clampStandardCellFontSize(fontSize) + + pdf.SetTextColor(20, 20, 20) + pdf.SetFont("Helvetica", "B", fontSize) + lineH := fontSize * 0.92 + pdf.SetXY(x, y+(cellSize-lineH)/2) + pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") +} + +func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) { + if data == nil || data.Size <= 0 { + return + } + + size := data.Size + pageW, pageH := pdf.GetPageSize() + marginX := 10.0 + top := 28.0 + bottom := 24.0 + + availW := pageW - 2*marginX + availH := pageH - top - bottom + cellSize := math.Min(availW/float64(size), availH/float64(size)) + cellSize = math.Max(4.4, math.Min(11.0, cellSize)) + + blockW := float64(size) * cellSize + blockH := float64(size) * cellSize + startX := (pageW - blockW) / 2 + startY := top + (availH-blockH)/2 + + pdf.SetDrawColor(60, 60, 60) + pdf.SetLineWidth(0.14) + 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(145, 145, 145) + pdf.SetLineWidth(0.24) + 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(0.60) + pdf.Rect(startX, startY, blockW, blockH, "D") + + ruleY := startY + blockH + 4 + if ruleY+8 <= pageH-6 { + pdf.SetTextColor(85, 85, 85) + pdf.SetFont("Helvetica", "", 7.1) + pdf.SetXY(marginX, ruleY) + pdf.CellFormat(pageW-2*marginX, 4, "No three equal adjacent in any row or column.", "", 0, "C", false, 0, "") + pdf.SetXY(marginX, ruleY+4) + pdf.CellFormat(pageW-2*marginX, 4, "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(15, 15, 15) + pdf.SetFont("Helvetica", "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 := standardCellFontSize(cellSize, 0.68) + switch { + case size >= 14: + fontSize *= 0.94 + case size >= 12: + fontSize *= 0.97 + } + return clampStandardCellFontSize(fontSize) +} diff --git a/pdfexport/render_nonogram_test.go b/pdfexport/render_nonogram_test.go new file mode 100644 index 0000000..0d910a3 --- /dev/null +++ b/pdfexport/render_nonogram_test.go @@ -0,0 +1,70 @@ +package pdfexport + +import "testing" + +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]) + } +} diff --git a/pdfexport/render_nurikabe_shikaku.go b/pdfexport/render_nurikabe_shikaku.go new file mode 100644 index 0000000..d66d7bd --- /dev/null +++ b/pdfexport/render_nurikabe_shikaku.go @@ -0,0 +1,124 @@ +package pdfexport + +import ( + "math" + "strconv" + "unicode/utf8" + + "github.com/go-pdf/fpdf" +) + +func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) { + if data == nil || data.Width <= 0 || data.Height <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + marginX := 10.0 + top := 28.0 + bottom := 22.0 + + availW := pageW - 2*marginX + availH := pageH - top - bottom + cellSize := math.Min(availW/float64(data.Width), availH/float64(data.Height)) + cellSize = math.Max(4.8, math.Min(12.0, cellSize)) + + blockW := float64(data.Width) * cellSize + blockH := float64(data.Height) * cellSize + startX := (pageW - blockW) / 2 + startY := top + (availH-blockH)/2 + + pdf.SetDrawColor(55, 55, 55) + pdf.SetLineWidth(0.15) + 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 + } + drawRectanglePuzzleClue(pdf, cellX, cellY, cellSize, data.Clues[y][x]) + } + } + + pdf.SetDrawColor(35, 35, 35) + pdf.SetLineWidth(0.62) + pdf.Rect(startX, startY, blockW, blockH, "D") + + ruleY := startY + blockH + 5 + if ruleY+4 <= pageH-6 { + pdf.SetTextColor(85, 85, 85) + pdf.SetFont("Helvetica", "", 7.2) + pdf.SetXY(marginX, ruleY) + pdf.CellFormat(pageW-2*marginX, 4, "Expand each numbered island to its size; connect all sea cells into one wall.", "", 0, "C", false, 0, "") + } +} + +func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) { + if data == nil || data.Width <= 0 || data.Height <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + marginX := 10.0 + top := 28.0 + bottom := 22.0 + + availW := pageW - 2*marginX + availH := pageH - top - bottom + cellSize := math.Min(availW/float64(data.Width), availH/float64(data.Height)) + cellSize = math.Max(4.8, math.Min(12.0, cellSize)) + + blockW := float64(data.Width) * cellSize + blockH := float64(data.Height) * cellSize + startX := (pageW - blockW) / 2 + startY := top + (availH-blockH)/2 + + pdf.SetDrawColor(55, 55, 55) + pdf.SetLineWidth(0.15) + 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 + } + drawRectanglePuzzleClue(pdf, cellX, cellY, cellSize, data.Clues[y][x]) + } + } + + pdf.SetDrawColor(35, 35, 35) + pdf.SetLineWidth(0.62) + pdf.Rect(startX, startY, blockW, blockH, "D") + + ruleY := startY + blockH + 5 + if ruleY+4 <= pageH-6 { + pdf.SetTextColor(85, 85, 85) + pdf.SetFont("Helvetica", "", 7.2) + pdf.SetXY(marginX, ruleY) + pdf.CellFormat(pageW-2*marginX, 4, "Partition into rectangles where each clue equals its rectangle area.", "", 0, "C", false, 0, "") + } +} + +func drawRectanglePuzzleClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) { + text := strconv.Itoa(value) + fontSize := standardCellFontSize(cellSize, 0.58) + runeCount := utf8.RuneCountInString(text) + switch { + case runeCount >= 3: + fontSize *= 0.72 + case runeCount == 2: + fontSize *= 0.84 + } + fontSize = clampStandardCellFontSize(fontSize) + + pdf.SetTextColor(20, 20, 20) + pdf.SetFont("Helvetica", "B", fontSize) + lineH := fontSize * 0.92 + pdf.SetXY(x, y+(cellSize-lineH)/2) + pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") +} diff --git a/pdfexport/render_takuzu_test.go b/pdfexport/render_takuzu_test.go new file mode 100644 index 0000000..987fce6 --- /dev/null +++ b/pdfexport/render_takuzu_test.go @@ -0,0 +1,44 @@ +package pdfexport + +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: 4.2, + wantMax: 4.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/pdfexport/types.go b/pdfexport/types.go index e8ff887..0ff3302 100644 --- a/pdfexport/types.go +++ b/pdfexport/types.go @@ -35,7 +35,15 @@ type Puzzle struct { Name string Index int Body string + SaveData []byte Nonogram *NonogramData + Nurikabe *NurikabeData + Shikaku *ShikakuData + Hashi *HashiData + Hitori *HitoriData + Takuzu *TakuzuData + Sudoku *SudokuData + WordSearch *WordSearchData Table *GridTable DifficultyScore float64 DifficultyConfidence DifficultyConfidence @@ -50,6 +58,52 @@ type NonogramData struct { 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 From 8d700dec92f33d404c169028fa0b580df5c0c7c2 Mon Sep 17 00:00:00 2001 From: Dami Date: Sun, 22 Feb 2026 10:43:24 -0700 Subject: [PATCH 04/14] print unification and new font with proper license inclusion --- README.md | 9 +- cmd/export_pdf.go | 4 +- cmd/new_export.go | 76 ---- cmd/new_export_test.go | 63 +-- pdfexport/font.go | 4 +- pdfexport/font_test.go | 8 +- pdfexport/fonts.go | 27 ++ .../fonts/AtkinsonHyperlegibleNext-Bold.ttf | Bin 0 -> 47968 bytes .../AtkinsonHyperlegibleNext-Regular.ttf | Bin 0 -> 48064 bytes pdfexport/fonts/OFL.txt | 93 +++++ pdfexport/jsonl.go | 33 +- pdfexport/jsonl_test.go | 130 ++----- pdfexport/render.go | 360 +++++++++++------- pdfexport/render_hashi.go | 67 ++-- pdfexport/render_hitori_takuzu.go | 119 +++--- pdfexport/render_layout_test.go | 104 +++++ pdfexport/render_nurikabe_shikaku.go | 87 +++-- pdfexport/render_takuzu_test.go | 4 +- pdfexport/render_tokens.go | 159 ++++++++ 19 files changed, 811 insertions(+), 536 deletions(-) create mode 100644 pdfexport/fonts.go create mode 100644 pdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttf create mode 100644 pdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf create mode 100644 pdfexport/fonts/OFL.txt create mode 100644 pdfexport/render_layout_test.go create mode 100644 pdfexport/render_tokens.go diff --git a/README.md b/README.md index 483ce08..9853c4a 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,19 @@ puzzletea new nonogram mini -e 6 -o nonogram-mini-set.jsonl puzzletea new sudoku --export 10 -o sudoku-mixed.jsonl --with-seed zine-issue-01 ``` -Render one or more JSONL packs into an A5 print PDF: +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 ``` +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: diff --git a/cmd/export_pdf.go b/cmd/export_pdf.go index ce4cbeb..975935f 100644 --- a/cmd/export_pdf.go +++ b/cmd/export_pdf.go @@ -22,8 +22,8 @@ var ( var exportPDFCmd = &cobra.Command{ Use: "export-pdf [more.jsonl ...]", - Short: "Convert one or more PuzzleTea JSONL exports into an A5 printable PDF", - Long: "Parse one or more JSONL export files, order puzzles by progressive difficulty with seeded mixing, and render an A5 PDF with a title page and one puzzle per page.", + 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 and one puzzle per page.", Args: cobra.MinimumNArgs(1), RunE: runExportPDF, } diff --git a/cmd/new_export.go b/cmd/new_export.go index 6aaff3f..674e331 100644 --- a/cmd/new_export.go +++ b/cmd/new_export.go @@ -166,61 +166,6 @@ func buildExportRecords( return nil, fmt.Errorf("render puzzle %d: %w", i+1, err) } - nonogram, table, err := pdfexport.ParsePrintableFromSnippet(gameType, snippet) - if err != nil { - return nil, fmt.Errorf("build print payload for puzzle %d: %w", i+1, err) - } - - var nurikabe *pdfexport.NurikabeData - var shikaku *pdfexport.ShikakuData - var hashi *pdfexport.HashiData - var sudoku *pdfexport.SudokuData - var wordSearch *pdfexport.WordSearchData - switch normalizeExportGameType(gameType) { - case "hashiwokakero": - hashi, err = pdfexport.ParseHashiPrintData(save) - if err != nil { - return nil, fmt.Errorf("build hashiwokakero print payload for puzzle %d: %w", i+1, err) - } - case "nurikabe": - nurikabe, err = pdfexport.ParseNurikabePrintData(save) - if err != nil { - return nil, fmt.Errorf("build nurikabe print payload for puzzle %d: %w", i+1, err) - } - case "shikaku": - shikaku, err = pdfexport.ParseShikakuPrintData(save) - if err != nil { - return nil, fmt.Errorf("build shikaku print payload for puzzle %d: %w", i+1, err) - } - case "sudoku": - sudoku, err = pdfexport.ParseSudokuPrintData(save) - if err != nil { - return nil, fmt.Errorf("build sudoku print payload for puzzle %d: %w", i+1, err) - } - case "wordsearch": - wordSearch, err = pdfexport.ParseWordSearchPrintData(save) - if err != nil { - return nil, fmt.Errorf("build word search print payload for puzzle %d: %w", i+1, err) - } - } - - printKind := "text" - if nonogram != nil { - printKind = "nonogram" - } else if hashi != nil { - printKind = "hashi" - } else if nurikabe != nil { - printKind = "nurikabe" - } else if shikaku != nil { - printKind = "shikaku" - } else if sudoku != nil { - printKind = "sudoku" - } else if wordSearch != nil { - printKind = "word-search" - } else if table != nil { - printKind = "grid-table" - } - records = append(records, pdfexport.JSONLRecord{ Schema: pdfexport.ExportSchemaV1, Pack: pdfexport.JSONLPackMeta{ @@ -239,20 +184,6 @@ func buildExportRecords( Save: json.RawMessage(save), Snippet: snippet, }, - Print: pdfexport.JSONLPrintData{ - Kind: printKind, - Paper: "A5", - MarginMM: 10, - EmptyGlyph: " ", - HintTone: "dim", - Nonogram: nonogram, - Hashi: hashi, - Nurikabe: nurikabe, - Shikaku: shikaku, - Sudoku: sudoku, - WordSearch: wordSearch, - Table: table, - }, }) } @@ -271,13 +202,6 @@ func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error) return seeded.SpawnSeeded(rng) } -func normalizeExportGameType(gameType string) string { - gameType = strings.ToLower(strings.TrimSpace(gameType)) - gameType = strings.ReplaceAll(gameType, "-", "") - gameType = strings.Join(strings.Fields(gameType), "") - return gameType -} - func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error { var b strings.Builder for _, record := range records { diff --git a/cmd/new_export_test.go b/cmd/new_export_test.go index aae7247..1a7cd63 100644 --- a/cmd/new_export_test.go +++ b/cmd/new_export_test.go @@ -152,7 +152,7 @@ func TestRunNewExportOverwritesOutputFile(t *testing.T) { } } -func TestRunNewExportSudokuPrintPayload(t *testing.T) { +func TestRunNewExportOmitsPrintPayload(t *testing.T) { withExportFlagReset(t) flagExport = 1 @@ -166,67 +166,12 @@ func TestRunNewExportSudokuPrintPayload(t *testing.T) { t.Fatalf("jsonl lines = %d, want 1", len(lines)) } - var record pdfexport.JSONLRecord + var record map[string]any if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { t.Fatal(err) } - if got, want := record.Print.Kind, "sudoku"; got != want { - t.Fatalf("print kind = %q, want %q", got, want) - } - if record.Print.Sudoku == nil { - t.Fatal("expected sudoku print payload") - } -} - -func TestRunNewExportWordSearchPrintPayload(t *testing.T) { - withExportFlagReset(t) - flagExport = 1 - - cmd, out := newExportTestCmd(t, true) - if err := runNewExport(cmd, []string{"wordsearch", "easy 10x10"}); err != nil { - t.Fatalf("expected word search 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 pdfexport.JSONLRecord - if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { - t.Fatal(err) - } - if got, want := record.Print.Kind, "word-search"; got != want { - t.Fatalf("print kind = %q, want %q", got, want) - } - if record.Print.WordSearch == nil { - t.Fatal("expected word search print payload") - } -} - -func TestRunNewExportHashiPrintPayload(t *testing.T) { - withExportFlagReset(t) - flagExport = 1 - - cmd, out := newExportTestCmd(t, true) - if err := runNewExport(cmd, []string{"hashiwokakero", "easy 7x7"}); err != nil { - t.Fatalf("expected hashi 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 pdfexport.JSONLRecord - if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { - t.Fatal(err) - } - if got, want := record.Print.Kind, "hashi"; got != want { - t.Fatalf("print kind = %q, want %q", got, want) - } - if record.Print.Hashi == nil { - t.Fatal("expected hashi print payload") + if _, ok := record["print"]; ok { + t.Fatal("did not expect print payload in export record") } } diff --git a/pdfexport/font.go b/pdfexport/font.go index fb7adbe..654e8ad 100644 --- a/pdfexport/font.go +++ b/pdfexport/font.go @@ -1,8 +1,8 @@ package pdfexport const ( - standardCellFontMin = 4.2 - standardCellFontMax = 8.0 + standardCellFontMin = 5.2 + standardCellFontMax = 8.2 ) func standardCellFontSize(cellSize, scale float64) float64 { diff --git a/pdfexport/font_test.go b/pdfexport/font_test.go index 4ed1b52..dd4aa69 100644 --- a/pdfexport/font_test.go +++ b/pdfexport/font_test.go @@ -13,7 +13,7 @@ func TestStandardCellFontSizeBounds(t *testing.T) { name: "clamps low", cellSize: 3.0, scale: 0.6, - want: 4.2, + want: 5.2, }, { name: "keeps in range", @@ -25,7 +25,7 @@ func TestStandardCellFontSizeBounds(t *testing.T) { name: "clamps high", cellSize: 20.0, scale: 0.7, - want: 8.0, + want: 8.2, }, } @@ -48,7 +48,7 @@ func TestClampStandardCellFontSizeBounds(t *testing.T) { { name: "below min", in: 3.9, - want: 4.2, + want: 5.2, }, { name: "in range", @@ -58,7 +58,7 @@ func TestClampStandardCellFontSizeBounds(t *testing.T) { { name: "above max", in: 9.1, - want: 8.0, + want: 8.2, }, } diff --git a/pdfexport/fonts.go b/pdfexport/fonts.go new file mode 100644 index 0000000..425c551 --- /dev/null +++ b/pdfexport/fonts.go @@ -0,0 +1,27 @@ +package pdfexport + +import ( + _ "embed" + "fmt" + + "github.com/go-pdf/fpdf" +) + +const sansFontFamily = "AtkinsonHyperlegibleNext" + +var ( + //go:embed fonts/AtkinsonHyperlegibleNext-Regular.ttf + atkinsonRegularTTF []byte + + //go:embed fonts/AtkinsonHyperlegibleNext-Bold.ttf + atkinsonBoldTTF []byte +) + +func registerPDFFonts(pdf *fpdf.Fpdf) error { + pdf.AddUTF8FontFromBytes(sansFontFamily, "", atkinsonRegularTTF) + pdf.AddUTF8FontFromBytes(sansFontFamily, "B", atkinsonBoldTTF) + 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 0000000000000000000000000000000000000000..a23aed3e7757d60dd2c3bc610a807a396a0b13c3 GIT binary patch literal 47968 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 LHDcJotV{8KC%+(! literal 0 HcmV?d00001 diff --git a/pdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf b/pdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3fa771fee8bb6500758f135500fccd45be100223 GIT binary patch literal 48064 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 l6?~KAA|@{Md+hyAF2UKIc8k?&2t-U_uPv 8.8 { - cellSize = 8.8 - } - if cellSize < 3.2 { - cellSize = 3.2 + layout := layoutNonogram( + pageW, + pageH, + data.Width, + data.Height, + rowHintCols, + colHintRows, + ) + cellSize := layout.cellSize + if cellSize <= 0 { + return } - blockW := float64(totalCols) * cellSize - blockH := float64(totalRows) * cellSize gridW := float64(data.Width) * cellSize gridH := float64(data.Height) * cellSize - startX := marginX + padding + (availW-blockW)/2 - startY := top + padding + (availH-blockH)/2 - - xSep := startX + float64(rowHintCols)*cellSize - ySep := startY + float64(colHintRows)*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++ { @@ -254,8 +262,24 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { drawNonogramMajorLines(pdf, xSep, ySep, cellSize, data.Width, data.Height, 5) - pdf.SetLineWidth(0.60) + pdf.SetLineWidth(outerBorderLineMM) pdf.Rect(xSep, ySep, gridW, gridH, "D") + + ruleY := ySep + gridH + 3.5 + ruleY = instructionY(ruleY-3.5, pageH, 1) + setInstructionStyle(pdf) + pdf.SetXY(pageMarginXMM, ruleY) + pdf.CellFormat( + pageW-2*pageMarginXMM, + 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( @@ -274,7 +298,7 @@ func drawNonogramPuzzleGrid( gridH := float64(height) * cellSize pdf.SetDrawColor(45, 45, 45) - pdf.SetLineWidth(0.12) + pdf.SetLineWidth(thinGridLineMM) for col := 0; col <= width; col++ { x := startX + float64(col)*cellSize pdf.Line(x, startY, x, startY+gridH) @@ -290,47 +314,100 @@ func drawNonogramHintText(pdf *fpdf.Fpdf, x, y, w, h float64, text string) { return } - // Darker, compact hint typography for dense nonograms. - pdf.SetTextColor(55, 55, 55) + // Hints are puzzle-critical, so keep them bold and centered. + pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) fontSize := standardCellFontSize(h, 0.70) - pdf.SetFont("Helvetica", "", fontSize) + pdf.SetFont(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, + gridCols, + gridRows, + rowHintCols, + colHintRows int, +) nonogramLayout { + totalCols := rowHintCols + gridCols + totalRows := colHintRows + gridRows + area := puzzleBoardRect(pageW, pageH, 1) + cellSize := fitBoardCellSize(totalCols, totalRows, area, boardFamilyNonogram) + 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 renderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) { if data == nil { return } pageW, pageH := pdf.GetPageSize() - marginX := 10.0 - top := 28.0 - bottom := 16.0 - availW := pageW - 2*marginX - availH := pageH - top - bottom - - cellSize := math.Min(availW/9.0, availH/9.0) - if cellSize > 12.5 { - cellSize = 12.5 - } - if cellSize < 10.5 { - cellSize = 10.5 + area := puzzleBoardRect(pageW, pageH, 1) + cellSize := fitBoardCellSize(9, 9, area, boardFamilySudoku) + if cellSize <= 0 { + return } - boardW := 9.0 * cellSize boardH := 9.0 * cellSize - startX := (pageW - boardW) / 2 - startY := top + (availH-boardH)/2 + startX, startY := centeredOrigin(area, 9, 9, cellSize) drawSudokuGridLines(pdf, startX, startY, cellSize) drawSudokuGivens(pdf, startX, startY, cellSize, data.Givens) - pdf.SetFont("Helvetica", "", 8) - pdf.SetTextColor(85, 85, 85) - pdf.SetXY(marginX, pageH-11) - pdf.CellFormat(pageW-2*marginX, 5, "Fill rows, columns, and 3x3 boxes with 1-9", "", 0, "C", false, 0, "") + ruleY := instructionY(startY+boardH, pageH, 1) + setInstructionStyle(pdf) + pdf.SetXY(pageMarginXMM, ruleY) + pdf.CellFormat( + pageW-2*pageMarginXMM, + instructionLineHMM, + "Fill rows, columns, and 3x3 boxes with 1-9", + "", + 0, + "C", + false, + 0, + "", + ) } func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) { @@ -352,19 +429,19 @@ func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) { func sudokuLineWidthFor(index int) float64 { switch { case index == 0 || index == 9: - return 0.70 + return 0.72 case index%3 == 0: - return 0.55 + return 0.56 default: - return 0.15 + return thinGridLineMM } } func drawSudokuGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [9][9]int) { fontSize := standardCellFontSize(cellSize, 0.62) lineH := fontSize * 0.85 - pdf.SetFont("Helvetica", "B", fontSize) - pdf.SetTextColor(20, 20, 20) + pdf.SetFont(sansFontFamily, "B", fontSize) + pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) for y := range 9 { for x := range 9 { @@ -386,16 +463,14 @@ func renderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) { } pageW, pageH := pdf.GetPageSize() - marginX := 10.0 - top := 28.0 - bottom := 12.0 - availW := pageW - 2*marginX - availH := pageH - top - bottom + body := puzzleBodyRect(pageW, pageH) + availW := body.w + availH := body.h columnCount := wordSearchColumnCount(data.Width, len(data.Words)) - wordFontSize := 8.3 + wordFontSize := puzzleWordBankFontSize wordLineHeight := 4.2 - gridListGap := 4.0 + gridListGap := wordSearchGridListGap estimatedWordLines := estimateWordBankLineCount(pdf, data.Words, columnCount, availW, wordFontSize) wordBankHeight := 7.0 + float64(estimatedWordLines)*wordLineHeight @@ -412,26 +487,32 @@ func renderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) { gridAreaH = availH * 0.5 } - cellSize := math.Min(availW/float64(data.Width), gridAreaH/float64(data.Height)) - if cellSize > 10.5 { - cellSize = 10.5 - } - if cellSize < 4.2 { - cellSize = 4.2 + gridArea := rectMM{x: body.x, y: body.y, w: availW, h: gridAreaH} + cellSize := fitBoardCellSize(data.Width, data.Height, gridArea, boardFamilyWordSearch) + if cellSize <= 0 { + return } gridW := float64(data.Width) * cellSize gridH := float64(data.Height) * cellSize - gridX := (pageW - gridW) / 2 - gridY := top + (gridAreaH-gridH)/2 + gridX := body.x + (gridArea.w-gridW)/2 + gridY := body.y + (gridArea.h-gridH)/2 drawWordSearchGrid(pdf, data, gridX, gridY, cellSize) - drawWordBank(pdf, data.Words, marginX, gridY+gridH+gridListGap, availW, pageH-bottom-(gridY+gridH+gridListGap), columnCount) + drawWordBank( + pdf, + data.Words, + body.x, + gridY+gridH+gridListGap, + availW, + pageH-puzzleBottomInsetMM-(gridY+gridH+gridListGap), + columnCount, + ) } func drawWordSearchGrid(pdf *fpdf.Fpdf, data *WordSearchData, startX, startY, cellSize float64) { pdf.SetDrawColor(45, 45, 45) - pdf.SetLineWidth(0.12) + pdf.SetLineWidth(thinGridLineMM) for y := range data.Height { for x := range data.Width { cellX := startX + float64(x)*cellSize @@ -448,14 +529,14 @@ func drawWordSearchGrid(pdf *fpdf.Fpdf, data *WordSearchData, startX, startY, ce fontSize := standardCellFontSize(cellSize, 0.74) lineH := fontSize * 0.86 - pdf.SetFont("Helvetica", "B", fontSize) - pdf.SetTextColor(18, 18, 18) + pdf.SetFont(sansFontFamily, "B", fontSize) + pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) pdf.SetXY(cellX, cellY+(cellSize-lineH)/2) pdf.CellFormat(cellSize, lineH, cellText, "", 0, "C", false, 0, "") } } - pdf.SetLineWidth(0.55) + pdf.SetLineWidth(outerBorderLineMM) pdf.Rect(startX, startY, float64(data.Width)*cellSize, float64(data.Height)*cellSize, "D") } @@ -465,19 +546,19 @@ func drawWordBank(pdf *fpdf.Fpdf, words []string, x, y, width, height float64, c } pdf.SetTextColor(40, 40, 40) - pdf.SetFont("Helvetica", "B", 9.2) + pdf.SetFont(sansFontFamily, "B", puzzleWordBankHeadSize) pdf.SetXY(x, y) pdf.CellFormat(width, 4.8, "Word Bank", "", 0, "L", false, 0, "") - pdf.SetFont("Helvetica", "", 8.3) - pdf.SetTextColor(70, 70, 70) + pdf.SetFont(sansFontFamily, "", puzzleWordBankFontSize) + pdf.SetTextColor(ruleTextGray, ruleTextGray, 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("Helvetica", "", 8.8) - pdf.SetTextColor(95, 95, 95) + pdf.SetFont(sansFontFamily, "", puzzleWordBankHeadSize) + pdf.SetTextColor(secondaryTextGray, secondaryTextGray, secondaryTextGray) pdf.SetXY(x, listY) pdf.CellFormat(width, 4.6, "(word list unavailable)", "", 0, "L", false, 0, "") return @@ -500,8 +581,8 @@ func drawWordBank(pdf *fpdf.Fpdf, words []string, x, y, width, height float64, c return } - pdf.SetTextColor(25, 25, 25) - pdf.SetFont("Helvetica", "", 8.8) + pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) + pdf.SetFont(sansFontFamily, "B", puzzleWordBankHeadSize) for c := range columns { colX := x + float64(c)*(colWidth+columnGap) curY := listY @@ -540,7 +621,7 @@ func estimateWordBankLineCount(pdf *fpdf.Fpdf, words []string, columns int, avai colWidth = availW } - pdf.SetFont("Helvetica", "", fontSize) + pdf.SetFont(sansFontFamily, "", fontSize) lineCounts := make([]int, columns) for _, word := range words { text := strings.ToUpper(strings.TrimSpace(word)) @@ -610,9 +691,6 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { } pageW, pageH := pdf.GetPageSize() - marginX := 10.0 - top := 28.0 - bottom := 12.0 rows := len(table.Rows) cols := 0 @@ -625,23 +703,18 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { return } - availW := pageW - 2*marginX - availH := pageH - top - bottom - cellSize := math.Min(availW/float64(cols), availH/float64(rows)) - if cellSize > 11.2 { - cellSize = 11.2 - } - if cellSize < 3.3 { - cellSize = 3.3 + area := puzzleBoardRect(pageW, pageH, 0) + cellSize := fitBoardCellSize(cols, rows, area, boardFamilyTable) + if cellSize <= 0 { + return } blockW := float64(cols) * cellSize blockH := float64(rows) * cellSize - startX := (pageW - blockW) / 2 - startY := top + (availH-blockH)/2 + startX, startY := centeredOrigin(area, cols, rows, cellSize) pdf.SetDrawColor(55, 55, 55) - pdf.SetLineWidth(0.16) + pdf.SetLineWidth(thinGridLineMM) for r := 0; r < rows; r++ { for c := 0; c < cols; c++ { x := startX + float64(c)*cellSize @@ -666,26 +739,24 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { if table.HasHeaderRow { ySep := startY + cellSize - pdf.SetLineWidth(0.42) + pdf.SetLineWidth(majorGridLineMM) pdf.Line(startX, ySep, startX+blockW, ySep) } if table.HasHeaderCol { xSep := startX + cellSize - pdf.SetLineWidth(0.42) + pdf.SetLineWidth(majorGridLineMM) pdf.Line(xSep, startY, xSep, startY+blockH) } - pdf.SetLineWidth(0.6) + pdf.SetLineWidth(outerBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") } func renderFallbackPage(pdf *fpdf.Fpdf, puzzle Puzzle, pageH float64) { pageW, _ := pdf.GetPageSize() - marginX := 10.0 - top := 28.0 - bottom := 12.0 - availW := pageW - 2*marginX - availH := pageH - top - bottom + area := puzzleBoardRect(pageW, pageH, 0) + availW := area.w + availH := area.h lines := sanitizeBody(puzzle.Body) fontSize := 9.2 @@ -712,15 +783,15 @@ func renderFallbackPage(pdf *fpdf.Fpdf, puzzle Puzzle, pageH float64) { } blockH := float64(len(wrapped)) * lineHeight - startY := top + (availH-blockH)/2 + startY := area.y + (availH-blockH)/2 pdf.SetTextColor(50, 50, 50) y := startY for _, line := range wrapped { w := pdf.GetStringWidth(line) x := (pageW - w) / 2 - if x < marginX { - x = marginX + if x < area.x { + x = area.x } pdf.SetXY(x, y) pdf.CellFormat(availW, lineHeight, line, "", 0, "L", false, 0, "") @@ -754,13 +825,13 @@ func drawCellText(pdf *fpdf.Fpdf, x, y, w, h float64, text string, dim bool) { return } if dim { - pdf.SetTextColor(130, 130, 130) + pdf.SetTextColor(dimTextGray, dimTextGray, dimTextGray) } else { - pdf.SetTextColor(25, 25, 25) + pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) } fontSize := standardCellFontSize(h, 0.63) - pdf.SetFont("Helvetica", "", fontSize) + pdf.SetFont(sansFontFamily, "B", fontSize) lineH := fontSize * 0.9 pdf.SetXY(x, y+(h-lineH)/2) pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "") @@ -828,7 +899,7 @@ func drawNonogramMajorLines( } pdf.SetDrawColor(45, 45, 45) - pdf.SetLineWidth(0.30) + pdf.SetLineWidth(majorGridLineMM) for col := step; col < width; col += step { x := puzzleStartX + float64(col)*cellSize @@ -861,6 +932,27 @@ func summarizeCategories(puzzles []Puzzle) []string { return categories } +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) @@ -907,10 +999,10 @@ func renderSourceExportsTable( } pdf.SetDrawColor(125, 125, 125) - pdf.SetLineWidth(0.14) + pdf.SetLineWidth(thinGridLineMM) pdf.SetFillColor(245, 245, 245) pdf.SetTextColor(45, 45, 45) - pdf.SetFont("Helvetica", "B", 8.4) + pdf.SetFont(sansFontFamily, "B", 8.9) curX := x for i, header := range headers { @@ -919,8 +1011,8 @@ func renderSourceExportsTable( curX += columnWidths[i] } - pdf.SetFont("Helvetica", "", 8.1) - pdf.SetTextColor(70, 70, 70) + pdf.SetFont(sansFontFamily, "", 8.6) + pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray) for i := 0; i < rowCount; i++ { rowY := y + headerHeight + float64(i)*rowHeight mode := "" diff --git a/pdfexport/render_hashi.go b/pdfexport/render_hashi.go index 0d02b30..5688084 100644 --- a/pdfexport/render_hashi.go +++ b/pdfexport/render_hashi.go @@ -14,37 +14,43 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *HashiData) { } pageW, pageH := pdf.GetPageSize() - marginX := 10.0 - top := 28.0 - bottom := 22.0 - - availW := pageW - 2*marginX - availH := pageH - top - bottom spanX := max(data.Width-1, 1) spanY := max(data.Height-1, 1) - step := math.Min(availW/float64(spanX), availH/float64(spanY)) - step = math.Max(5.2, math.Min(16.0, step)) + area := puzzleBoardRect(pageW, pageH, 1) + step := fitBoardCellSize(spanX, spanY, area, boardFamilyHashi) + if step <= 0 { + return + } boardW := float64(spanX) * step boardH := float64(spanY) * step - originX := (pageW - boardW) / 2 - originY := top + (availH-boardH)/2 + originX, originY := centeredOrigin(area, spanX, spanY, step) + islandRadius := hashiIslandRadius(step) drawHashiGuideDots(pdf, originX, originY, data.Width, data.Height, step) - drawHashiIslands(pdf, originX, originY, step, data.Islands) + drawHashiBoardBorder(pdf, originX, originY, boardW, boardH, islandRadius) + drawHashiIslands(pdf, originX, originY, step, islandRadius, data.Islands) - ruleY := originY + boardH + 5 - if ruleY+4 <= pageH-6 { - pdf.SetTextColor(85, 85, 85) - pdf.SetFont("Helvetica", "", 7.2) - pdf.SetXY(marginX, ruleY) - pdf.CellFormat(pageW-2*marginX, 4, "Connect islands horizontally/vertically with up to two bridges and no crossings.", "", 0, "C", false, 0, "") - } + // Add an explicit blank line before the Hashi hint text. + ruleY := instructionY(originY+boardH+instructionLineHMM, pageH, 1) + setInstructionStyle(pdf) + pdf.SetXY(pageMarginXMM, ruleY) + pdf.CellFormat( + pageW-2*pageMarginXMM, + 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(235, 235, 235) + 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++ { @@ -55,11 +61,20 @@ func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height } } -func drawHashiIslands(pdf *fpdf.Fpdf, originX, originY, step float64, islands []HashiIsland) { - radius := math.Max(1.4, math.Min(3.2, step*0.23)) +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(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 []HashiIsland) { pdf.SetDrawColor(20, 20, 20) pdf.SetFillColor(255, 255, 255) - pdf.SetLineWidth(0.26) + pdf.SetLineWidth(majorGridLineMM) for _, island := range islands { cx := originX + float64(island.X)*step @@ -69,6 +84,10 @@ func drawHashiIslands(pdf *fpdf.Fpdf, originX, originY, step float64, islands [] } } +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 := standardCellFontSize(radius*2.0, 0.95) @@ -81,8 +100,8 @@ func drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int) } fontSize = clampStandardCellFontSize(fontSize) - pdf.SetTextColor(18, 18, 18) - pdf.SetFont("Helvetica", "B", fontSize) + pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) + pdf.SetFont(sansFontFamily, "B", fontSize) lineH := fontSize * 0.88 pdf.SetXY(cx-radius, cy-lineH/2) pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "") diff --git a/pdfexport/render_hitori_takuzu.go b/pdfexport/render_hitori_takuzu.go index 4072c40..46d288f 100644 --- a/pdfexport/render_hitori_takuzu.go +++ b/pdfexport/render_hitori_takuzu.go @@ -1,7 +1,6 @@ package pdfexport import ( - "math" "strings" "unicode/utf8" @@ -15,22 +14,18 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) { size := data.Size pageW, pageH := pdf.GetPageSize() - marginX := 10.0 - top := 28.0 - bottom := 22.0 - - availW := pageW - 2*marginX - availH := pageH - top - bottom - cellSize := math.Min(availW/float64(size), availH/float64(size)) - cellSize = math.Max(5.0, math.Min(12.0, cellSize)) + area := puzzleBoardRect(pageW, pageH, 1) + cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact) + if cellSize <= 0 { + return + } blockW := float64(size) * cellSize blockH := float64(size) * cellSize - startX := (pageW - blockW) / 2 - startY := top + (availH-blockH)/2 + startX, startY := centeredOrigin(area, size, size, cellSize) pdf.SetDrawColor(55, 55, 55) - pdf.SetLineWidth(0.16) + pdf.SetLineWidth(thinGridLineMM) for y := 0; y < size; y++ { for x := 0; x < size; x++ { cellX := startX + float64(x)*cellSize @@ -49,26 +44,23 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) { } pdf.SetDrawColor(35, 35, 35) - pdf.SetLineWidth(0.62) + pdf.SetLineWidth(outerBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") - ruleY := startY + blockH + 5 - if ruleY+4 <= pageH-6 { - pdf.SetTextColor(85, 85, 85) - pdf.SetFont("Helvetica", "", 7.3) - pdf.SetXY(marginX, ruleY) - pdf.CellFormat( - pageW-2*marginX, - 4, - "Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected.", - "", - 0, - "C", - false, - 0, - "", - ) - } + ruleY := instructionY(startY+blockH, pageH, 1) + setInstructionStyle(pdf) + pdf.SetXY(pageMarginXMM, ruleY) + pdf.CellFormat( + pageW-2*pageMarginXMM, + 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) { @@ -82,8 +74,8 @@ func drawHitoriCellNumber(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) { } fontSize = clampStandardCellFontSize(fontSize) - pdf.SetTextColor(20, 20, 20) - pdf.SetFont("Helvetica", "B", fontSize) + pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) + pdf.SetFont(sansFontFamily, "B", fontSize) lineH := fontSize * 0.92 pdf.SetXY(x, y+(cellSize-lineH)/2) pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") @@ -96,22 +88,18 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) { size := data.Size pageW, pageH := pdf.GetPageSize() - marginX := 10.0 - top := 28.0 - bottom := 24.0 - - availW := pageW - 2*marginX - availH := pageH - top - bottom - cellSize := math.Min(availW/float64(size), availH/float64(size)) - cellSize = math.Max(4.4, math.Min(11.0, cellSize)) + area := puzzleBoardRect(pageW, pageH, 2) + cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact) + if cellSize <= 0 { + return + } blockW := float64(size) * cellSize blockH := float64(size) * cellSize - startX := (pageW - blockW) / 2 - startY := top + (availH-blockH)/2 + startX, startY := centeredOrigin(area, size, size, cellSize) pdf.SetDrawColor(60, 60, 60) - pdf.SetLineWidth(0.14) + pdf.SetLineWidth(thinGridLineMM) for y := 0; y < size; y++ { for x := 0; x < size; x++ { cellX := startX + float64(x)*cellSize @@ -121,8 +109,8 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) { } if data.GroupEveryTwo { - pdf.SetDrawColor(145, 145, 145) - pdf.SetLineWidth(0.24) + pdf.SetDrawColor(130, 130, 130) + pdf.SetLineWidth(majorGridLineMM) for i := 2; i < size; i += 2 { x := startX + float64(i)*cellSize y := startY + float64(i)*cellSize @@ -148,25 +136,42 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) { } pdf.SetDrawColor(35, 35, 35) - pdf.SetLineWidth(0.60) + pdf.SetLineWidth(outerBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") - ruleY := startY + blockH + 4 - if ruleY+8 <= pageH-6 { - pdf.SetTextColor(85, 85, 85) - pdf.SetFont("Helvetica", "", 7.1) - pdf.SetXY(marginX, ruleY) - pdf.CellFormat(pageW-2*marginX, 4, "No three equal adjacent in any row or column.", "", 0, "C", false, 0, "") - pdf.SetXY(marginX, ruleY+4) - pdf.CellFormat(pageW-2*marginX, 4, "Each row/column has equal 0 and 1 counts, and rows/columns are unique.", "", 0, "C", false, 0, "") - } + ruleY := instructionY(startY+blockH, pageH, 2) + setInstructionStyle(pdf) + pdf.SetXY(pageMarginXMM, ruleY) + pdf.CellFormat( + pageW-2*pageMarginXMM, + instructionLineHMM, + "No three equal adjacent in any row or column.", + "", + 0, + "C", + false, + 0, + "", + ) + pdf.SetXY(pageMarginXMM, ruleY+instructionLineHMM) + pdf.CellFormat( + pageW-2*pageMarginXMM, + 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(15, 15, 15) - pdf.SetFont("Helvetica", "B", fontSize) + pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) + pdf.SetFont(sansFontFamily, "B", fontSize) lineH := fontSize * 0.9 pdf.SetXY(x, y+(cellSize-lineH)/2) pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") diff --git a/pdfexport/render_layout_test.go b/pdfexport/render_layout_test.go new file mode 100644 index 0000000..e9f4b14 --- /dev/null +++ b/pdfexport/render_layout_test.go @@ -0,0 +1,104 @@ +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, 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 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}, + } + + boardArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, 1) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + layout := layoutNonogram( + halfLetterWidthMM, + halfLetterHeightMM, + 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/pdfexport/render_nurikabe_shikaku.go b/pdfexport/render_nurikabe_shikaku.go index d66d7bd..85fd064 100644 --- a/pdfexport/render_nurikabe_shikaku.go +++ b/pdfexport/render_nurikabe_shikaku.go @@ -1,7 +1,6 @@ package pdfexport import ( - "math" "strconv" "unicode/utf8" @@ -14,22 +13,18 @@ func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) { } pageW, pageH := pdf.GetPageSize() - marginX := 10.0 - top := 28.0 - bottom := 22.0 - - availW := pageW - 2*marginX - availH := pageH - top - bottom - cellSize := math.Min(availW/float64(data.Width), availH/float64(data.Height)) - cellSize = math.Max(4.8, math.Min(12.0, cellSize)) + area := puzzleBoardRect(pageW, pageH, 1) + cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact) + if cellSize <= 0 { + return + } blockW := float64(data.Width) * cellSize blockH := float64(data.Height) * cellSize - startX := (pageW - blockW) / 2 - startY := top + (availH-blockH)/2 + startX, startY := centeredOrigin(area, data.Width, data.Height, cellSize) pdf.SetDrawColor(55, 55, 55) - pdf.SetLineWidth(0.15) + pdf.SetLineWidth(thinGridLineMM) for y := 0; y < data.Height; y++ { for x := 0; x < data.Width; x++ { cellX := startX + float64(x)*cellSize @@ -44,16 +39,23 @@ func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) { } pdf.SetDrawColor(35, 35, 35) - pdf.SetLineWidth(0.62) + pdf.SetLineWidth(outerBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") - ruleY := startY + blockH + 5 - if ruleY+4 <= pageH-6 { - pdf.SetTextColor(85, 85, 85) - pdf.SetFont("Helvetica", "", 7.2) - pdf.SetXY(marginX, ruleY) - pdf.CellFormat(pageW-2*marginX, 4, "Expand each numbered island to its size; connect all sea cells into one wall.", "", 0, "C", false, 0, "") - } + ruleY := instructionY(startY+blockH, pageH, 1) + setInstructionStyle(pdf) + pdf.SetXY(pageMarginXMM, ruleY) + pdf.CellFormat( + pageW-2*pageMarginXMM, + instructionLineHMM, + "Expand each numbered island to its size; connect all sea cells into one wall.", + "", + 0, + "C", + false, + 0, + "", + ) } func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) { @@ -62,22 +64,18 @@ func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) { } pageW, pageH := pdf.GetPageSize() - marginX := 10.0 - top := 28.0 - bottom := 22.0 - - availW := pageW - 2*marginX - availH := pageH - top - bottom - cellSize := math.Min(availW/float64(data.Width), availH/float64(data.Height)) - cellSize = math.Max(4.8, math.Min(12.0, cellSize)) + area := puzzleBoardRect(pageW, pageH, 1) + cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact) + if cellSize <= 0 { + return + } blockW := float64(data.Width) * cellSize blockH := float64(data.Height) * cellSize - startX := (pageW - blockW) / 2 - startY := top + (availH-blockH)/2 + startX, startY := centeredOrigin(area, data.Width, data.Height, cellSize) pdf.SetDrawColor(55, 55, 55) - pdf.SetLineWidth(0.15) + pdf.SetLineWidth(thinGridLineMM) for y := 0; y < data.Height; y++ { for x := 0; x < data.Width; x++ { cellX := startX + float64(x)*cellSize @@ -92,16 +90,23 @@ func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) { } pdf.SetDrawColor(35, 35, 35) - pdf.SetLineWidth(0.62) + pdf.SetLineWidth(outerBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") - ruleY := startY + blockH + 5 - if ruleY+4 <= pageH-6 { - pdf.SetTextColor(85, 85, 85) - pdf.SetFont("Helvetica", "", 7.2) - pdf.SetXY(marginX, ruleY) - pdf.CellFormat(pageW-2*marginX, 4, "Partition into rectangles where each clue equals its rectangle area.", "", 0, "C", false, 0, "") - } + ruleY := instructionY(startY+blockH, pageH, 1) + setInstructionStyle(pdf) + pdf.SetXY(pageMarginXMM, ruleY) + pdf.CellFormat( + pageW-2*pageMarginXMM, + instructionLineHMM, + "Partition into rectangles where each clue equals its rectangle area.", + "", + 0, + "C", + false, + 0, + "", + ) } func drawRectanglePuzzleClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) { @@ -116,8 +121,8 @@ func drawRectanglePuzzleClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) } fontSize = clampStandardCellFontSize(fontSize) - pdf.SetTextColor(20, 20, 20) - pdf.SetFont("Helvetica", "B", fontSize) + pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) + pdf.SetFont(sansFontFamily, "B", fontSize) lineH := fontSize * 0.92 pdf.SetXY(x, y+(cellSize-lineH)/2) pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") diff --git a/pdfexport/render_takuzu_test.go b/pdfexport/render_takuzu_test.go index 987fce6..5a39c9a 100644 --- a/pdfexport/render_takuzu_test.go +++ b/pdfexport/render_takuzu_test.go @@ -14,8 +14,8 @@ func TestTakuzuGivenFontSize(t *testing.T) { name: "small cell keeps readable minimum", cellSize: 3.0, size: 14, - wantMin: 4.2, - wantMax: 4.2, + wantMin: 5.2, + wantMax: 5.2, }, { name: "12x12 remains comfortably readable", diff --git a/pdfexport/render_tokens.go b/pdfexport/render_tokens.go new file mode 100644 index 0000000..57a6419 --- /dev/null +++ b/pdfexport/render_tokens.go @@ -0,0 +1,159 @@ +package pdfexport + +import ( + "math" + + "github.com/go-pdf/fpdf" +) + +const ( + halfLetterWidthMM = 139.7 + halfLetterHeightMM = 215.9 + + footerTextGray = 78 + secondaryTextGray = 60 + ruleTextGray = 54 + dimTextGray = 92 + primaryTextGray = 20 + + pageMarginXMM = 10.0 + 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) rectMM { + return rectMM{ + x: pageMarginXMM, + y: puzzleTopMM, + w: pageW - 2*pageMarginXMM, + h: pageH - puzzleTopMM - puzzleBottomInsetMM, + } +} + +func puzzleBoardRect(pageW, pageH float64, ruleLines int) rectMM { + rect := puzzleBodyRect(pageW, pageH) + if ruleLines > 0 { + rect.h -= instructionGapMM + float64(ruleLines)*instructionLineHMM + if rect.h < 0 { + rect.h = 0 + } + } + return rect +} + +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) +} From a8f05ce31fbc1f820a85b057ee4618dd32c4e6b4 Mon Sep 17 00:00:00 2001 From: Dami Date: Sun, 22 Feb 2026 13:56:18 -0700 Subject: [PATCH 05/14] pdf export wrapin up --- README.md | 4 +- cmd/export_pdf.go | 86 ++++- cmd/export_pdf_test.go | 115 ++++++ pdfexport/fonts.go | 9 +- ...L.txt => AtkinsonHyperlegibleNext-OFL.txt} | 0 pdfexport/fonts/DMSerifDisplay-OFL.txt | 93 +++++ pdfexport/fonts/DMSerifDisplay-Regular.ttf | Bin 0 -> 76580 bytes pdfexport/render.go | 80 +++- pdfexport/render_cover.go | 360 ++++++++++++++++++ pdfexport/render_cover_test.go | 40 ++ pdfexport/render_hashi.go | 7 +- pdfexport/render_hitori_takuzu.go | 18 +- pdfexport/render_layout_test.go | 77 +++- pdfexport/render_nurikabe_shikaku.go | 14 +- pdfexport/render_tokens.go | 28 +- pdfexport/types.go | 13 +- 16 files changed, 888 insertions(+), 56 deletions(-) create mode 100644 cmd/export_pdf_test.go rename pdfexport/fonts/{OFL.txt => AtkinsonHyperlegibleNext-OFL.txt} (100%) create mode 100644 pdfexport/fonts/DMSerifDisplay-OFL.txt create mode 100644 pdfexport/fonts/DMSerifDisplay-Regular.ttf create mode 100644 pdfexport/render_cover.go create mode 100644 pdfexport/render_cover_test.go diff --git a/README.md b/README.md index 9853c4a..1eef26d 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,11 @@ 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 +puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01 --volume 1 --title "Catacombs & Pines" ``` +`--title` sets the cover subtitle, and `--volume` sets the cover volume number. + Font license note (Atkinson Hyperlegible Next): - Follow the SIL OFL 1.1 requirements in `pdfexport/fonts/OFL.txt`. diff --git a/cmd/export_pdf.go b/cmd/export_pdf.go index 975935f..d3c6184 100644 --- a/cmd/export_pdf.go +++ b/cmd/export_pdf.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "path/filepath" + "strconv" "strings" "time" @@ -16,8 +17,10 @@ import ( var ( flagPDFOutput string flagPDFTitle string + flagPDFVolume int flagPDFAdvert string flagPDFShuffleSeed string + flagPDFCoverColor string ) var exportPDFCmd = &cobra.Command{ @@ -30,9 +33,11 @@ var exportPDFCmd = &cobra.Command{ func init() { exportPDFCmd.Flags().StringVarP(&flagPDFOutput, "output", "o", "", "write output PDF path (defaults to -print.pdf)") - exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "title shown on the generated title page") + exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "subtitle shown on the cover") + exportPDFCmd.Flags().IntVar(&flagPDFVolume, "volume", 1, "volume number shown on the cover (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 cover page: hex "#RRGGBB", decimal "R,G,B", or omit for random vibrant nature tone`) } func runExportPDF(cmd *cobra.Command, args []string) error { @@ -64,16 +69,9 @@ func runExportPDF(cmd *cobra.Command, args []string) error { return fmt.Errorf("--output must use a .pdf extension") } - title := strings.TrimSpace(flagPDFTitle) - if title == "" { - title = defaultPDFTitle(docs) - } - - cfg := pdfexport.RenderConfig{ - Title: title, - AdvertText: flagPDFAdvert, - GeneratedAt: time.Now(), - ShuffleSeed: shuffleSeed, + cfg, err := buildRenderConfigForPDF(docs, shuffleSeed, time.Now()) + if err != nil { + return err } if err := pdfexport.WritePDF(output, docs, ordered, cfg); err != nil { return err @@ -101,6 +99,39 @@ func defaultPDFTitle(docs []pdfexport.PackDocument) string { 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, + 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)) @@ -176,3 +207,36 @@ func normalizeDifficultyToken(s string) string { 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 (random vibrant nature tone). +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..e0c4058 --- /dev/null +++ b/cmd/export_pdf_test.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "strings" + "testing" + "time" + + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +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" + 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.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 snapshotExportPDFFlags() func() { + oldTitle := flagPDFTitle + oldVolume := flagPDFVolume + oldAdvert := flagPDFAdvert + oldCoverColor := flagPDFCoverColor + oldOutput := flagPDFOutput + oldShuffle := flagPDFShuffleSeed + + return func() { + flagPDFTitle = oldTitle + flagPDFVolume = oldVolume + flagPDFAdvert = oldAdvert + flagPDFCoverColor = oldCoverColor + flagPDFOutput = oldOutput + flagPDFShuffleSeed = oldShuffle + } +} diff --git a/pdfexport/fonts.go b/pdfexport/fonts.go index 425c551..f317348 100644 --- a/pdfexport/fonts.go +++ b/pdfexport/fonts.go @@ -7,7 +7,10 @@ import ( "github.com/go-pdf/fpdf" ) -const sansFontFamily = "AtkinsonHyperlegibleNext" +const ( + sansFontFamily = "AtkinsonHyperlegibleNext" + coverFontFamily = "DMSerifDisplay" +) var ( //go:embed fonts/AtkinsonHyperlegibleNext-Regular.ttf @@ -15,11 +18,15 @@ var ( //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()) } diff --git a/pdfexport/fonts/OFL.txt b/pdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt similarity index 100% rename from pdfexport/fonts/OFL.txt rename to pdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt 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 0000000000000000000000000000000000000000..bc5ed0cea657f264ea9c2a11a54da62ed619c5da GIT binary patch literal 76580 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 K4DH(t+KnzcNyyLu literal 0 HcmV?d00001 diff --git a/pdfexport/render.go b/pdfexport/render.go index 8cd0cf4..249a709 100644 --- a/pdfexport/render.go +++ b/pdfexport/render.go @@ -24,8 +24,14 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend 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 = defaultTitle(docs) + cfg.Title = fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber) } if strings.TrimSpace(cfg.AdvertText) == "" { cfg.AdvertText = "Find more puzzles at github.com/FelineStateMachine/puzzletea" @@ -46,21 +52,37 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend pdf.SetCreator("PuzzleTea", true) pdf.SetAuthor("PuzzleTea", true) pdf.SetTitle(cfg.Title, true) + footerExcludedPages := map[int]struct{}{} pdf.SetFooterFunc(func() { - if pdf.PageNo() <= 1 { + pageNo := pdf.PageNo() + if pageNo <= 2 { + return + } + if _, skip := footerExcludedPages[pageNo]; skip { return } pdf.SetY(-8) pdf.SetFont(sansFontFamily, "", 8) pdf.SetTextColor(footerTextGray, footerTextGray, footerTextGray) - pdf.CellFormat(0, 4, strconv.Itoa(pdf.PageNo()), "", 0, "C", false, 0, "") + pdf.CellFormat(0, 4, strconv.Itoa(pageNo), "", 0, "C", false, 0, "") }) + coverColor := resolveCoverColor(cfg) + renderCoverPage(pdf, puzzles, cfg) renderTitlePage(pdf, docs, puzzles, cfg) for _, puzzle := range puzzles { renderPuzzlePage(pdf, puzzle) } + totalPagesWithoutPadding := pdf.PageNo() + 1 // include upcoming back cover + for range saddleStitchPadCount(totalPagesWithoutPadding) { + renderPadPage(pdf) + footerExcludedPages[pdf.PageNo()] = struct{}{} + } + + renderBackCoverPage(pdf, cfg, coverColor) + footerExcludedPages[pdf.PageNo()] = struct{}{} + dir := filepath.Dir(outputPath) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { @@ -74,6 +96,22 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend return nil } +func saddleStitchPadCount(totalPages int) int { + if totalPages <= 0 { + return 0 + } + + remainder := totalPages % 4 + if remainder == 0 { + return 0 + } + return 4 - remainder +} + +func renderPadPage(pdf *fpdf.Fpdf) { + pdf.AddPage() +} + func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) { pdf.AddPage() pageW, pageH := pdf.GetPageSize() @@ -82,14 +120,19 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg pdf.SetTextColor(20, 20, 20) pdf.SetFont(sansFontFamily, "B", 22) pdf.SetXY(0, 24) - pdf.CellFormat(pageW, 10, cfg.Title, "", 0, "C", false, 0, "") + 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, 36) + pdf.SetXY(0, 44) pdf.CellFormat(pageW, 6, "PuzzleTea Puzzle Pack", "", 0, "C", false, 0, "") - metaY := 50.0 + metaY := 56.0 pdf.SetTextColor(25, 25, 25) pdf.SetFont(sansFontFamily, "", 10) pdf.SetXY(margin, metaY) @@ -205,6 +248,7 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { } pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() rowHints := normalizeNonogramHintsForRender(data.RowHints, data.Height) colHints := normalizeNonogramHintsForRender(data.ColHints, data.Width) @@ -221,6 +265,7 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { layout := layoutNonogram( pageW, pageH, + pageNo, data.Width, data.Height, rowHintCols, @@ -267,10 +312,11 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { ruleY := ySep + gridH + 3.5 ruleY = instructionY(ruleY-3.5, pageH, 1) + body := puzzleBodyRect(pageW, pageH, pageNo) setInstructionStyle(pdf) - pdf.SetXY(pageMarginXMM, ruleY) + pdf.SetXY(body.x, ruleY) pdf.CellFormat( - pageW-2*pageMarginXMM, + body.w, instructionLineHMM, "Use row/column hints to fill blocks in order; groups are separated by at least one blank cell.", "", @@ -334,6 +380,7 @@ type nonogramLayout struct { func layoutNonogram( pageW, pageH float64, + pageNo, gridCols, gridRows, rowHintCols, @@ -341,7 +388,7 @@ func layoutNonogram( ) nonogramLayout { totalCols := rowHintCols + gridCols totalRows := colHintRows + gridRows - area := puzzleBoardRect(pageW, pageH, 1) + area := puzzleBoardRect(pageW, pageH, pageNo, 1) cellSize := fitBoardCellSize(totalCols, totalRows, area, boardFamilyNonogram) if cellSize <= 0 { return nonogramLayout{} @@ -382,7 +429,8 @@ func renderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) { } pageW, pageH := pdf.GetPageSize() - area := puzzleBoardRect(pageW, pageH, 1) + pageNo := pdf.PageNo() + area := puzzleBoardRect(pageW, pageH, pageNo, 1) cellSize := fitBoardCellSize(9, 9, area, boardFamilySudoku) if cellSize <= 0 { return @@ -396,9 +444,9 @@ func renderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) { ruleY := instructionY(startY+boardH, pageH, 1) setInstructionStyle(pdf) - pdf.SetXY(pageMarginXMM, ruleY) + pdf.SetXY(area.x, ruleY) pdf.CellFormat( - pageW-2*pageMarginXMM, + area.w, instructionLineHMM, "Fill rows, columns, and 3x3 boxes with 1-9", "", @@ -463,7 +511,8 @@ func renderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) { } pageW, pageH := pdf.GetPageSize() - body := puzzleBodyRect(pageW, pageH) + pageNo := pdf.PageNo() + body := puzzleBodyRect(pageW, pageH, pageNo) availW := body.w availH := body.h @@ -691,6 +740,7 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { } pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() rows := len(table.Rows) cols := 0 @@ -703,7 +753,7 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { return } - area := puzzleBoardRect(pageW, pageH, 0) + area := puzzleBoardRect(pageW, pageH, pageNo, 0) cellSize := fitBoardCellSize(cols, rows, area, boardFamilyTable) if cellSize <= 0 { return @@ -754,7 +804,7 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { func renderFallbackPage(pdf *fpdf.Fpdf, puzzle Puzzle, pageH float64) { pageW, _ := pdf.GetPageSize() - area := puzzleBoardRect(pageW, pageH, 0) + area := puzzleBoardRect(pageW, pageH, pdf.PageNo(), 0) availW := area.w availH := area.h diff --git a/pdfexport/render_cover.go b/pdfexport/render_cover.go new file mode 100644 index 0000000..a19a942 --- /dev/null +++ b/pdfexport/render_cover.go @@ -0,0 +1,360 @@ +package pdfexport + +import ( + "fmt" + "hash/fnv" + "math" + "math/rand" + "strings" + + "github.com/go-pdf/fpdf" +) + +var natureTonePalette = []RGB{ + {250, 76, 56}, // riso red + {255, 112, 0}, // fluorescent orange + {255, 183, 3}, // sunflower + {53, 169, 255}, // sky blue + {0, 204, 160}, // seafoam + {152, 226, 68}, // neon moss + {255, 82, 133}, // hot pink + {104, 83, 255}, // ultramarine +} + +func resolveCoverColor(cfg RenderConfig) RGB { + if cfg.CoverColor != nil { + return *cfg.CoverColor + } + seed := strings.TrimSpace(cfg.ShuffleSeed) + var idx int + if seed != "" { + h := fnv.New64a() + h.Write([]byte(seed)) + idx = int(h.Sum64() % uint64(len(natureTonePalette))) + } else { + idx = rand.Intn(len(natureTonePalette)) + } + return natureTonePalette[idx] +} + +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 := resolveCoverColor(cfg) + 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} + drawWoodcutScene(pdf, scene, coverColor, ink, cfg.ShuffleSeed) + + 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 drawWoodcutScene(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB, seed string) { + drawStippleTexture(pdf, scene, ink, seed) + drawHatchingBands(pdf, scene, ink) + drawPineForestMass(pdf, scene, ink) + drawIsometricRuins(pdf, scene, bg, ink) + drawSkullMotifs(pdf, scene, bg, ink) +} + +func drawStippleTexture(pdf *fpdf.Fpdf, scene rectMM, ink RGB, seed string) { + h := fnv.New64a() + h.Write([]byte(strings.TrimSpace(seed) + "-stipple")) + rng := rand.New(rand.NewSource(int64(h.Sum64()))) + + pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) + for x := scene.x + 1.0; x < scene.x+scene.w-1.0; x += 3.1 { + for y := scene.y + 2.0; y < scene.y+scene.h*0.58; y += 3.2 { + if rng.Float64() < 0.22 { + jitterX := (rng.Float64() - 0.5) * 0.9 + jitterY := (rng.Float64() - 0.5) * 0.9 + pdf.Circle(x+jitterX, y+jitterY, 0.11, "F") + } + } + } +} + +func drawHatchingBands(pdf *fpdf.Fpdf, scene rectMM, ink RGB) { + pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetLineWidth(0.28) + + baseY := scene.y + scene.h*0.64 + for i := -8; i < 34; i++ { + x0 := scene.x + float64(i)*4.4 + y0 := baseY + x1 := x0 + scene.h*0.55 + y1 := baseY + scene.h*0.30 + pdf.Line(x0, y0, x1, y1) + } + + for i := -8; i < 30; i++ { + x0 := scene.x + float64(i)*5.0 + y0 := baseY + 7.5 + x1 := x0 + scene.h*0.48 + y1 := baseY + scene.h*0.33 + pdf.Line(x0, y0, x1, y1) + } +} + +func drawPineForestMass(pdf *fpdf.Fpdf, scene rectMM, ink RGB) { + pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) + + layers := []struct { + y float64 + count int + w float64 + h float64 + offset float64 + }{ + {y: scene.y + scene.h*0.45, count: 22, w: 4.4, h: 13.0, offset: 0.8}, + {y: scene.y + scene.h*0.53, count: 19, w: 5.4, h: 16.0, offset: 1.4}, + {y: scene.y + scene.h*0.62, count: 16, w: 6.2, h: 18.4, offset: 2.0}, + } + + for _, layer := range layers { + step := scene.w / float64(layer.count) + for i := 0; i < layer.count; i++ { + cx := scene.x + float64(i)*step + layer.offset + pts := []fpdf.PointType{ + {X: cx, Y: layer.y - layer.h}, + {X: cx - layer.w, Y: layer.y}, + {X: cx + layer.w, Y: layer.y}, + } + pdf.Polygon(pts, "F") + pdf.SetLineWidth(0.22) + pdf.Line(cx, layer.y, cx, layer.y+2.4) + } + } +} + +func drawIsometricRuins(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB) { + pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) + + centerX := scene.x + scene.w*0.52 + baseY := scene.y + scene.h*0.80 + drawIsometricGrid(pdf, centerX-44, baseY-17, 88, 34, ink) + + drawStoneArch(pdf, centerX-22, baseY-35, 25, 27, 8, bg, ink) + drawStoneArch(pdf, centerX+8, baseY-48, 20, 23, 7, bg, ink) + + drawRubbleBlock(pdf, centerX-39, baseY-6, 9, 5, 5, ink) + drawRubbleBlock(pdf, centerX+31, baseY-3, 10, 5, 6, ink) + drawRubbleBlock(pdf, centerX+12, baseY+5, 8, 4, 4, ink) +} + +func drawIsometricGrid(pdf *fpdf.Fpdf, x, y, w, h float64, ink RGB) { + pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetLineWidth(0.26) + + for i := -8; i < 14; i++ { + sx := x + float64(i)*4 + pdf.Line(sx, y, sx+w*0.5, y+h*0.5) + } + for i := -8; i < 14; i++ { + sx := x + w + float64(i)*4 + pdf.Line(sx, y, sx-w*0.5, y+h*0.5) + } +} + +func drawStoneArch(pdf *fpdf.Fpdf, x, y, w, h, depth float64, bg, ink RGB) { + frontLeft := []fpdf.PointType{{X: x, Y: y}, {X: x + w*0.18, Y: y - depth}, {X: x + w*0.18, Y: y + h - depth}, {X: x, Y: y + h}} + frontRight := []fpdf.PointType{{X: x + w*0.82, Y: y - depth}, {X: x + w, Y: y}, {X: x + w, Y: y + h}, {X: x + w*0.82, Y: y + h - depth}} + + pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.Polygon(frontLeft, "F") + pdf.Polygon(frontRight, "F") + + outerR := w * 0.50 + innerR := w * 0.33 + cx := x + w*0.5 + cy := y + h*0.42 + drawArcPolyline(pdf, cx, cy, outerR, math.Pi, 2*math.Pi, 24) + + pdf.SetDrawColor(int(bg.R), int(bg.G), int(bg.B)) + pdf.SetLineWidth(2.0) + drawArcPolyline(pdf, cx, cy, innerR, math.Pi, 2*math.Pi, 24) + pdf.Line(x+w*0.33, y+h, x+w*0.33, cy) + pdf.Line(x+w*0.67, y+h-depth*0.2, x+w*0.67, cy) + + pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetLineWidth(0.28) + for i := 0; i < 6; i++ { + sy := y + 3 + float64(i)*4.2 + pdf.Line(x+1.5, sy, x+w*0.16, sy-0.6) + pdf.Line(x+w*0.84, sy-0.5, x+w-1.5, sy+0.4) + } +} + +func drawArcPolyline(pdf *fpdf.Fpdf, cx, cy, r, start, end float64, segments int) { + if segments < 2 { + segments = 2 + } + step := (end - start) / float64(segments) + px := cx + math.Cos(start)*r + py := cy + math.Sin(start)*r + for i := 1; i <= segments; i++ { + a := start + float64(i)*step + x := cx + math.Cos(a)*r + y := cy + math.Sin(a)*r + pdf.Line(px, py, x, y) + px, py = x, y + } +} + +func drawRubbleBlock(pdf *fpdf.Fpdf, x, y, w, h, skew float64, ink RGB) { + pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) + pts := []fpdf.PointType{ + {X: x, Y: y}, + {X: x + w, Y: y - skew*0.2}, + {X: x + w + skew, Y: y + h}, + {X: x + skew, Y: y + h + skew*0.2}, + } + pdf.Polygon(pts, "F") +} + +func drawSkullMotifs(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB) { + centers := []struct { + x float64 + y float64 + r float64 + }{ + {x: scene.x + scene.w*0.30, y: scene.y + scene.h*0.84, r: 2.9}, + {x: scene.x + scene.w*0.69, y: scene.y + scene.h*0.87, r: 2.4}, + } + + for _, c := range centers { + pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.Circle(c.x, c.y, c.r, "F") + + pdf.SetFillColor(int(bg.R), int(bg.G), int(bg.B)) + pdf.Circle(c.x-c.r*0.35, c.y-c.r*0.1, c.r*0.22, "F") + pdf.Circle(c.x+c.r*0.35, c.y-c.r*0.08, c.r*0.20, "F") + jaw := []fpdf.PointType{{X: c.x - c.r*0.48, Y: c.y + c.r*0.2}, {X: c.x + c.r*0.48, Y: c.y + c.r*0.2}, {X: c.x, Y: c.y + c.r*0.72}} + pdf.Polygon(jaw, "F") + + pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) + pdf.SetLineWidth(0.22) + pdf.Line(c.x-c.r*0.6, c.y-c.r*0.85, c.x-c.r*1.15, c.y-c.r*1.45) + pdf.Line(c.x+c.r*0.5, c.y-c.r*0.7, c.x+c.r*1.0, c.y-c.r*1.3) + } +} + +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, 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, bg, ink RGB) { + drawStippleTexture(pdf, scene, ink, "back-cover") + drawHatchingBands(pdf, scene, ink) + drawPineForestMass(pdf, scene, ink) + drawStoneArch(pdf, scene.x+scene.w*0.58, scene.y+scene.h*0.56, 22, 24, 7, bg, ink) + drawRubbleBlock(pdf, scene.x+scene.w*0.28, scene.y+scene.h*0.72, 8, 4, 4, ink) +} diff --git a/pdfexport/render_cover_test.go b/pdfexport/render_cover_test.go new file mode 100644 index 0000000..7643bfd --- /dev/null +++ b/pdfexport/render_cover_test.go @@ -0,0 +1,40 @@ +package pdfexport + +import ( + "reflect" + "testing" + + "github.com/go-pdf/fpdf" +) + +func TestResolveCoverColorDeterministicWithSeed(t *testing.T) { + cfgA := RenderConfig{ShuffleSeed: "zine-seed"} + cfgB := RenderConfig{ShuffleSeed: "zine-seed"} + + colorA := resolveCoverColor(cfgA) + colorB := resolveCoverColor(cfgB) + if colorA != colorB { + t.Fatalf("resolveCoverColor mismatch for identical seed: %+v vs %+v", colorA, colorB) + } +} + +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_hashi.go b/pdfexport/render_hashi.go index 5688084..f02fba7 100644 --- a/pdfexport/render_hashi.go +++ b/pdfexport/render_hashi.go @@ -14,10 +14,11 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *HashiData) { } pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() spanX := max(data.Width-1, 1) spanY := max(data.Height-1, 1) - area := puzzleBoardRect(pageW, pageH, 1) + area := puzzleBoardRect(pageW, pageH, pageNo, 1) step := fitBoardCellSize(spanX, spanY, area, boardFamilyHashi) if step <= 0 { return @@ -35,9 +36,9 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *HashiData) { // Add an explicit blank line before the Hashi hint text. ruleY := instructionY(originY+boardH+instructionLineHMM, pageH, 1) setInstructionStyle(pdf) - pdf.SetXY(pageMarginXMM, ruleY) + pdf.SetXY(area.x, ruleY) pdf.CellFormat( - pageW-2*pageMarginXMM, + area.w, instructionLineHMM, "Connect islands horizontally/vertically with up to two bridges and no crossings.", "", diff --git a/pdfexport/render_hitori_takuzu.go b/pdfexport/render_hitori_takuzu.go index 46d288f..d89aa49 100644 --- a/pdfexport/render_hitori_takuzu.go +++ b/pdfexport/render_hitori_takuzu.go @@ -14,7 +14,8 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) { size := data.Size pageW, pageH := pdf.GetPageSize() - area := puzzleBoardRect(pageW, pageH, 1) + pageNo := pdf.PageNo() + area := puzzleBoardRect(pageW, pageH, pageNo, 1) cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact) if cellSize <= 0 { return @@ -49,9 +50,9 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) { ruleY := instructionY(startY+blockH, pageH, 1) setInstructionStyle(pdf) - pdf.SetXY(pageMarginXMM, ruleY) + pdf.SetXY(area.x, ruleY) pdf.CellFormat( - pageW-2*pageMarginXMM, + area.w, instructionLineHMM, "Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected.", "", @@ -88,7 +89,8 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) { size := data.Size pageW, pageH := pdf.GetPageSize() - area := puzzleBoardRect(pageW, pageH, 2) + pageNo := pdf.PageNo() + area := puzzleBoardRect(pageW, pageH, pageNo, 2) cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact) if cellSize <= 0 { return @@ -141,9 +143,9 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) { ruleY := instructionY(startY+blockH, pageH, 2) setInstructionStyle(pdf) - pdf.SetXY(pageMarginXMM, ruleY) + pdf.SetXY(area.x, ruleY) pdf.CellFormat( - pageW-2*pageMarginXMM, + area.w, instructionLineHMM, "No three equal adjacent in any row or column.", "", @@ -153,9 +155,9 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) { 0, "", ) - pdf.SetXY(pageMarginXMM, ruleY+instructionLineHMM) + pdf.SetXY(area.x, ruleY+instructionLineHMM) pdf.CellFormat( - pageW-2*pageMarginXMM, + area.w, instructionLineHMM, "Each row/column has equal 0 and 1 counts, and rows/columns are unique.", "", diff --git a/pdfexport/render_layout_test.go b/pdfexport/render_layout_test.go index e9f4b14..a0e9c8c 100644 --- a/pdfexport/render_layout_test.go +++ b/pdfexport/render_layout_test.go @@ -12,7 +12,7 @@ func TestThinGridLineFloor(t *testing.T) { } func TestCenteredOriginKeepsBoardCentered(t *testing.T) { - pageArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, 1) + pageArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, 2, 1) tests := []struct { name string cols int @@ -56,12 +56,14 @@ func TestLayoutNonogramCentersGrid(t *testing.T) { {name: "deep hints", rowHintCol: 5, colHintRow: 4}, } - boardArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, 1) + const pageNo = 3 + boardArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, pageNo, 1) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { layout := layoutNonogram( halfLetterWidthMM, halfLetterHeightMM, + pageNo, 10, 10, tt.rowHintCol, @@ -102,3 +104,74 @@ func TestLayoutNonogramCentersGrid(t *testing.T) { }) } } + +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) + } + }) + } +} diff --git a/pdfexport/render_nurikabe_shikaku.go b/pdfexport/render_nurikabe_shikaku.go index 85fd064..71770ba 100644 --- a/pdfexport/render_nurikabe_shikaku.go +++ b/pdfexport/render_nurikabe_shikaku.go @@ -13,7 +13,8 @@ func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) { } pageW, pageH := pdf.GetPageSize() - area := puzzleBoardRect(pageW, pageH, 1) + pageNo := pdf.PageNo() + area := puzzleBoardRect(pageW, pageH, pageNo, 1) cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact) if cellSize <= 0 { return @@ -44,9 +45,9 @@ func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) { ruleY := instructionY(startY+blockH, pageH, 1) setInstructionStyle(pdf) - pdf.SetXY(pageMarginXMM, ruleY) + pdf.SetXY(area.x, ruleY) pdf.CellFormat( - pageW-2*pageMarginXMM, + area.w, instructionLineHMM, "Expand each numbered island to its size; connect all sea cells into one wall.", "", @@ -64,7 +65,8 @@ func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) { } pageW, pageH := pdf.GetPageSize() - area := puzzleBoardRect(pageW, pageH, 1) + pageNo := pdf.PageNo() + area := puzzleBoardRect(pageW, pageH, pageNo, 1) cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact) if cellSize <= 0 { return @@ -95,9 +97,9 @@ func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) { ruleY := instructionY(startY+blockH, pageH, 1) setInstructionStyle(pdf) - pdf.SetXY(pageMarginXMM, ruleY) + pdf.SetXY(area.x, ruleY) pdf.CellFormat( - pageW-2*pageMarginXMM, + area.w, instructionLineHMM, "Partition into rectangles where each clue equals its rectangle area.", "", diff --git a/pdfexport/render_tokens.go b/pdfexport/render_tokens.go index 57a6419..3613240 100644 --- a/pdfexport/render_tokens.go +++ b/pdfexport/render_tokens.go @@ -17,6 +17,7 @@ const ( primaryTextGray = 20 pageMarginXMM = 10.0 + bindingGutterExtraMM = 2.6 puzzleTopMM = 28.0 puzzleBottomInsetMM = 16.0 instructionGapMM = 4.2 @@ -61,17 +62,18 @@ type boardSizing struct { targetFill float64 } -func puzzleBodyRect(pageW, pageH float64) rectMM { +func puzzleBodyRect(pageW, pageH float64, pageNo int) rectMM { + leftMargin, rightMargin := puzzleHorizontalMargins(pageNo) return rectMM{ - x: pageMarginXMM, + x: leftMargin, y: puzzleTopMM, - w: pageW - 2*pageMarginXMM, + w: pageW - leftMargin - rightMargin, h: pageH - puzzleTopMM - puzzleBottomInsetMM, } } -func puzzleBoardRect(pageW, pageH float64, ruleLines int) rectMM { - rect := puzzleBodyRect(pageW, pageH) +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 { @@ -81,6 +83,22 @@ func puzzleBoardRect(pageW, pageH float64, ruleLines int) rectMM { 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 diff --git a/pdfexport/types.go b/pdfexport/types.go index 0ff3302..d5d379b 100644 --- a/pdfexport/types.go +++ b/pdfexport/types.go @@ -110,9 +110,14 @@ type GridTable struct { HasHeaderCol bool } +type RGB struct{ R, G, B uint8 } + type RenderConfig struct { - Title string - AdvertText string - GeneratedAt time.Time - ShuffleSeed string + Title string + CoverSubtitle string + VolumeNumber int + AdvertText string + GeneratedAt time.Time + ShuffleSeed string + CoverColor *RGB // nil = random vibrant nature tone } From 1cf1738c0f610104fcf382767dd84d15d4420777 Mon Sep 17 00:00:00 2001 From: Dami Date: Sun, 22 Feb 2026 13:56:35 -0700 Subject: [PATCH 06/14] remove testing files --- pack-01/issue-01.pdf | Bin 85063 -> 0 bytes pack-01/nonogram.md | 332 -------------------------------------- pack-01/shikaku.md | 368 ------------------------------------------- pack-01/takuzu.md | 252 ----------------------------- 4 files changed, 952 deletions(-) delete mode 100644 pack-01/issue-01.pdf delete mode 100644 pack-01/nonogram.md delete mode 100644 pack-01/shikaku.md delete mode 100644 pack-01/takuzu.md diff --git a/pack-01/issue-01.pdf b/pack-01/issue-01.pdf deleted file mode 100644 index c5dbdd295a89c3c6f2334fb0b88ba39073b87186..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85063 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

`a>;WAZ_}P9)s#Qb6JykrEtQ@r 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 Date: Mon, 23 Feb 2026 13:04:01 -0700 Subject: [PATCH 07/14] generative artwork pdf cover --- cmd/export_pdf.go | 3 + cmd/export_pdf_test.go | 6 + nonogram-pack-01.md | 776 --------------------------------- pdfexport/cover_art.go | 841 ++++++++++++++++++++++++++++++++++++ pdfexport/cover_art_test.go | 89 ++++ pdfexport/jsonl.go | 14 +- pdfexport/jsonl_test.go | 98 +++++ pdfexport/printdata.go | 130 ++++++ pdfexport/printdata_test.go | 51 +++ pdfexport/render.go | 123 ++++-- pdfexport/render_cover.go | 212 +-------- pdfexport/types.go | 1 + 12 files changed, 1325 insertions(+), 1019 deletions(-) delete mode 100644 nonogram-pack-01.md create mode 100644 pdfexport/cover_art.go create mode 100644 pdfexport/cover_art_test.go diff --git a/cmd/export_pdf.go b/cmd/export_pdf.go index d3c6184..cc17520 100644 --- a/cmd/export_pdf.go +++ b/cmd/export_pdf.go @@ -17,6 +17,7 @@ import ( var ( flagPDFOutput string flagPDFTitle string + flagPDFHeader string flagPDFVolume int flagPDFAdvert string flagPDFShuffleSeed string @@ -34,6 +35,7 @@ var exportPDFCmd = &cobra.Command{ 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 cover") + 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 cover (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") @@ -123,6 +125,7 @@ func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string, cfg := pdfexport.RenderConfig{ CoverSubtitle: subtitle, + HeaderText: strings.TrimSpace(flagPDFHeader), VolumeNumber: flagPDFVolume, AdvertText: flagPDFAdvert, GeneratedAt: now, diff --git a/cmd/export_pdf_test.go b/cmd/export_pdf_test.go index e0c4058..843527e 100644 --- a/cmd/export_pdf_test.go +++ b/cmd/export_pdf_test.go @@ -53,6 +53,7 @@ func TestBuildRenderConfigForPDFUsesTitleAsCoverSubtitle(t *testing.T) { defer reset() flagPDFTitle = "Catacombs & Pines" + flagPDFHeader = "Custom heading paragraph" flagPDFVolume = 7 flagPDFAdvert = "Custom advert" flagPDFCoverColor = "" @@ -69,6 +70,9 @@ func TestBuildRenderConfigForPDFUsesTitleAsCoverSubtitle(t *testing.T) { 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") } @@ -98,6 +102,7 @@ func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) { func snapshotExportPDFFlags() func() { oldTitle := flagPDFTitle + oldHeader := flagPDFHeader oldVolume := flagPDFVolume oldAdvert := flagPDFAdvert oldCoverColor := flagPDFCoverColor @@ -106,6 +111,7 @@ func snapshotExportPDFFlags() func() { return func() { flagPDFTitle = oldTitle + flagPDFHeader = oldHeader flagPDFVolume = oldVolume flagPDFAdvert = oldAdvert flagPDFCoverColor = oldCoverColor diff --git a/nonogram-pack-01.md b/nonogram-pack-01.md deleted file mode 100644 index 1989a3b..0000000 --- a/nonogram-pack-01.md +++ /dev/null @@ -1,776 +0,0 @@ -# PuzzleTea Export - -- Generated: 2026-02-21T20:42:05-07:00 -- Version: v1.6.0-1-gd260f2e-dirty -- Category: Nonogram -- Mode Selection: Standard -- Count: 31 -- Seed: meow - -## ember-newt - 1 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | 6 | 4 | 2 | 1 | . | . | . | 1 | 2 | . | -| . | . | . | . | 1 | 1 | 4 | 5 | 2 | 5 | 5 | 2 | 5 | 2 | -| . | . | . | . | 1 | 1 | 1 | 2 | 4 | 2 | 3 | 3 | 1 | 7 | -| 1 | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 6 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 7 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 6 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 3 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## ember-moss - 2 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | 1 | . | . | . | . | 2 | . | . | 2 | -| . | . | . | . | . | 4 | . | . | . | . | 1 | . | . | 2 | -| . | . | . | . | 1 | 1 | 3 | 6 | 3 | 3 | 1 | 2 | 1 | 1 | -| . | . | . | . | 5 | 1 | 2 | 3 | 3 | 6 | 2 | 5 | 8 | 1 | -| . | 1 | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 6 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | -| 2 | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 3 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## stone-viper - 3 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | R5 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | 2 | 2 | . | . | 4 | . | 1 | 1 | 2 | 2 | -| . | . | . | . | . | 3 | 1 | 3 | 1 | 1 | 2 | 3 | 1 | 1 | 1 | -| . | . | . | . | . | 2 | 2 | 5 | 3 | 2 | 3 | 3 | 1 | 4 | 2 | -| . | . | . | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 6 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## lonely-vale - 4 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | 1 | . | . | . | . | . | . | -| . | . | . | . | . | . | 4 | . | . | 3 | 2 | . | . | -| . | . | . | 6 | 3 | 2 | 1 | . | 5 | 3 | 3 | 5 | 6 | -| . | . | . | 3 | 6 | 4 | 1 | 9 | 3 | 1 | 1 | 1 | 2 | -| . | 7 | 1 | . | . | . | . | . | . | . | . | . | . | -| 3 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 7 | . | . | . | . | . | . | . | . | . | . | -| 1 | 4 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 5 | . | . | . | . | . | . | . | . | . | . | -| 2 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | -| 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## arctic-basalt - 5 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | 1 | . | . | . | . | . | . | . | . | -| . | . | . | . | . | 1 | . | 1 | . | . | . | . | . | . | -| . | . | . | . | 6 | 1 | 5 | 1 | 8 | 2 | 4 | 2 | 3 | 4 | -| . | . | . | . | 1 | 4 | 3 | 2 | 1 | 3 | 4 | 5 | 2 | 2 | -| 3 | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| 1 | 3 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| 3 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 6 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## calm-cloud - 6 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | 5 | 3 | 5 | . | . | 2 | 3 | 4 | . | 3 | -| . | . | . | 1 | 1 | 1 | 5 | 1 | 2 | 3 | 3 | 4 | 2 | -| . | . | . | 2 | 2 | 1 | 3 | 8 | 4 | 1 | 1 | 5 | 1 | -| . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 4 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 7 | . | . | . | . | . | . | . | . | . | . | -| 1 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 6 | . | . | . | . | . | . | . | . | . | . | -| . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 6 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## scarlet-sage - 7 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | . | 1 | . | . | . | 1 | -| . | . | . | . | . | . | 2 | . | 5 | 1 | . | 1 | 1 | 1 | -| . | . | . | . | 8 | 4 | 1 | 4 | 1 | 1 | . | 2 | 2 | 2 | -| . | . | . | . | 1 | 2 | 1 | 3 | 2 | 3 | 8 | 3 | 1 | 1 | -| . | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 8 | . | . | . | . | . | . | . | . | . | . | -| 2 | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | -| 2 | 1 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 6 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 4 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## wandering-viper - 8 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | . | . | 2 | . | 1 | . | -| . | . | . | . | . | 5 | . | 2 | 1 | . | 2 | . | 2 | 3 | -| . | . | . | . | 4 | 1 | 5 | 4 | 1 | 2 | 1 | 1 | 1 | 2 | -| . | . | . | . | 4 | 1 | 3 | 2 | 4 | 6 | 2 | 6 | 2 | 1 | -| . | . | 1 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 4 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | -| 2 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 7 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 8 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 3 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## twilight-owl - 9 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | 3 | . | . | . | 3 | 2 | . | 4 | . | . | -| . | . | . | . | 1 | 6 | 7 | . | 1 | 1 | . | 2 | 1 | 2 | -| . | . | . | . | 1 | 1 | 1 | 6 | 2 | 1 | 5 | 2 | 6 | 2 | -| . | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| 3 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 4 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| 1 | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## silver-bison - 10 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | . | . | . | . | 1 | -| . | . | . | . | 1 | 2 | . | . | . | 2 | . | . | 2 | -| . | . | . | 5 | 3 | 1 | . | . | . | 1 | 2 | . | 1 | -| . | . | . | 2 | 1 | 1 | 3 | 2 | 8 | 1 | 2 | 3 | 1 | -| . | . | . | 1 | 1 | 1 | 6 | 6 | 1 | 2 | 3 | 6 | 1 | -| . | 2 | 7 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 7 | . | . | . | . | . | . | . | . | . | . | -| 4 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| 2 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 2 | . | . | . | . | . | . | . | . | . | . | -| 1 | 5 | 1 | . | . | . | . | . | . | . | . | . | . | -| 2 | 3 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 4 | 5 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## pearl-river - 11 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | 1 | . | 1 | 1 | . | . | 3 | . | . | . | -| . | . | . | . | 4 | 6 | 3 | 6 | 1 | 4 | 3 | 5 | 5 | 1 | -| . | . | . | . | 3 | 2 | 3 | 1 | 2 | 1 | 2 | 1 | 2 | 4 | -| . | 1 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 4 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## eager-cloud - 12 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | 1 | . | . | . | . | . | -| . | . | . | . | 1 | 1 | . | . | 2 | 2 | . | . | . | . | -| . | . | . | . | 6 | 1 | 5 | 3 | 2 | 1 | . | 2 | . | 2 | -| . | . | . | . | 1 | 2 | 3 | 4 | 1 | 2 | 9 | 4 | 6 | 3 | -| . | . | 7 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| 1 | 3 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## sandy-quartz - 13 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | 1 | 1 | . | . | . | . | . | . | . | -| . | . | . | . | 1 | 1 | 3 | . | . | 3 | 1 | 1 | . | 1 | -| . | . | . | . | 1 | 1 | 2 | 2 | 2 | 1 | 4 | 6 | 4 | 2 | -| . | . | . | . | 6 | 1 | 1 | 4 | 3 | 1 | 2 | 1 | 3 | 2 | -| 1 | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 3 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## cosmic-tide - 14 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | . | . | . | . | . | 1 | -| . | . | . | . | . | . | 1 | 1 | . | . | 1 | . | 1 | 1 | -| . | . | . | . | 6 | . | 1 | 3 | . | 2 | 3 | 4 | 4 | 2 | -| . | . | . | . | 3 | 9 | 3 | 4 | 8 | 1 | 2 | 4 | 3 | 1 | -| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | -| 2 | 2 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| 1 | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## copper-river - 15 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | R5 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | 1 | . | . | . | . | . | 3 | -| . | . | . | . | . | 4 | 2 | 1 | 2 | 2 | . | 1 | 3 | 1 | 1 | -| . | . | . | . | . | 1 | 3 | 3 | 1 | 1 | 4 | 3 | 1 | 4 | 1 | -| . | . | . | . | . | 1 | 1 | 4 | 1 | 2 | 3 | 3 | 1 | 2 | 1 | -| . | . | 5 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | -| 1 | 2 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 2 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 4 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 3 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## deep-badger - 16 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | 1 | . | . | . | . | . | . | . | . | -| . | . | . | 1 | 1 | . | . | . | . | . | . | . | . | -| . | . | . | 1 | 2 | 1 | . | . | 4 | . | 2 | . | . | -| . | . | . | 1 | 1 | 3 | 7 | 3 | 1 | 2 | 5 | 6 | 3 | -| . | . | . | 2 | 1 | 1 | 1 | 5 | 2 | 4 | 1 | 1 | 2 | -| 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 8 | . | . | . | . | . | . | . | . | . | . | -| 2 | 4 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 9 | . | . | . | . | . | . | . | . | . | . | -| 1 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 5 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## mighty-trout - 17 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | . | . | 1 | . | . | 1 | -| . | . | . | . | 1 | 5 | . | 2 | . | . | 1 | 2 | . | 1 | -| . | . | . | . | 2 | 1 | 4 | 1 | 2 | 2 | 1 | 5 | 2 | 1 | -| . | . | . | . | 2 | 1 | 3 | 1 | 4 | 4 | 2 | 1 | 3 | 1 | -| . | . | 5 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| 3 | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 7 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## blazing-trout - 18 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | . | . | 2 | . | . | . | -| . | . | . | . | 1 | 1 | 4 | . | . | . | 1 | . | 1 | . | -| . | . | . | . | 2 | 3 | 1 | 1 | 4 | 3 | 2 | 7 | 2 | 6 | -| . | . | . | . | 5 | 3 | 2 | 7 | 2 | 6 | 1 | 2 | 4 | 3 | -| 4 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 4 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 7 | . | . | . | . | . | . | . | . | . | . | -| 1 | 4 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | -| 2 | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 6 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## gilded-ivy - 19 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | 3 | . | . | . | . | . | . | . | . | -| . | . | . | . | . | 2 | 4 | 2 | . | . | . | . | . | 1 | -| . | . | . | . | 2 | 1 | 1 | 4 | 4 | 6 | 4 | 1 | 6 | 4 | -| . | . | . | . | 3 | 1 | 1 | 1 | 2 | 2 | 1 | 7 | 3 | 2 | -| . | 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 7 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 6 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 4 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## woven-lagoon - 20 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | 3 | . | . | . | 1 | . | . | 1 | . | . | -| . | . | . | . | 1 | . | 1 | . | 1 | . | . | 2 | . | 2 | -| . | . | . | . | 1 | . | 3 | 2 | 1 | 6 | 2 | 1 | 3 | 2 | -| . | . | . | . | 1 | 3 | 3 | 3 | 2 | 2 | 1 | 1 | 3 | 3 | -| 1 | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| 1 | 4 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 2 | . | . | . | . | . | . | . | . | . | . | -| 1 | 3 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## fading-aurora - 21 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | 1 | 1 | 1 | 5 | . | 3 | . | 1 | 2 | . | -| . | . | . | 4 | 4 | 5 | 1 | 5 | 2 | 8 | 2 | 1 | 4 | -| . | . | . | 1 | 2 | 2 | 1 | 1 | 1 | 1 | 3 | 1 | 1 | -| 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 7 | . | . | . | . | . | . | . | . | . | . | -| 1 | 5 | 2 | . | . | . | . | . | . | . | . | . | . | -| 5 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| 5 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 5 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 4 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## crimson-owl - 22 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | 1 | 1 | 2 | 2 | . | 2 | . | 3 | -| . | . | . | . | 2 | 2 | 1 | 3 | 4 | 4 | 6 | 5 | 5 | 3 | -| . | . | . | . | 3 | 1 | 5 | 3 | 1 | 1 | 1 | 1 | 4 | 1 | -| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 8 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 6 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 4 | . | . | . | . | . | . | . | . | . | . | -| 1 | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 3 | 3 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## timber-falcon - 23 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | 2 | . | . | . | . | 2 | . | . | -| . | . | . | . | . | . | 3 | . | . | . | 2 | 2 | 2 | . | -| . | . | . | . | . | 5 | 1 | 6 | 6 | . | 1 | 1 | 1 | 1 | -| . | . | . | . | 9 | 1 | 1 | 2 | 2 | 6 | 4 | 1 | 2 | 5 | -| . | . | 2 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 8 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 6 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 8 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 5 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## blazing-acorn - 24 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | 1 | . | . | 1 | . | . | . | . | . | . | -| . | . | . | . | 1 | 1 | 3 | 2 | 3 | . | . | 1 | . | . | -| . | . | . | . | 1 | 2 | 3 | 1 | 1 | 2 | 5 | 2 | 6 | 2 | -| . | . | . | . | 2 | 2 | 1 | 2 | 2 | 3 | 2 | 4 | 2 | 4 | -| . | 1 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | -| 2 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 3 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 7 | . | . | . | . | . | . | . | . | . | . | -| . | . | 6 | 3 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## onyx-pond - 25 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | R5 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | 2 | . | . | . | . | . | . | -| . | . | . | . | . | 2 | 1 | . | 1 | . | 2 | . | 3 | 2 | . | -| . | . | . | . | . | 3 | 2 | 5 | 1 | 1 | 1 | 7 | 1 | 1 | . | -| . | . | . | . | . | 1 | 2 | 4 | 1 | 8 | 3 | 2 | 3 | 2 | 9 | -| . | . | . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 2 | 5 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 5 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 2 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## desert-hollow - 26 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | . | . | . | 3 | . | . | -| . | . | . | . | . | . | 1 | . | . | . | . | 1 | . | 2 | -| . | . | . | . | 7 | 2 | 5 | 3 | 1 | 4 | 7 | 1 | 7 | 3 | -| . | . | . | . | 1 | 5 | 1 | 3 | 5 | 4 | 2 | 1 | 2 | 2 | -| . | . | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 5 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | -| 3 | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 5 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 2 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## emerald-newt - 27 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | 1 | . | . | . | . | . | . | -| . | . | . | . | 3 | . | . | 1 | 3 | . | . | . | . | . | -| . | . | . | . | 2 | 6 | 4 | 3 | 1 | 6 | 6 | 7 | 2 | 2 | -| . | . | . | . | 1 | 3 | 3 | 1 | 2 | 2 | 2 | 1 | 3 | 6 | -| . | . | 1 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 4 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 8 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 5 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | -| 2 | 1 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## velvet-viper - 28 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | 1 | 2 | 1 | . | . | . | . | -| . | . | . | . | 1 | 2 | . | 4 | 1 | 3 | 1 | 2 | 4 | 1 | -| . | . | . | . | 3 | 1 | 2 | 1 | 1 | 1 | 4 | 4 | 2 | 5 | -| . | . | . | . | 3 | 2 | 6 | 1 | 3 | 1 | 1 | 1 | 1 | 1 | -| . | . | 1 | 8 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| 1 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 3 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 4 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 5 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | . | 9 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## crystal-larch - 29 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | 3 | . | . | 1 | . | 1 | 1 | -| . | . | . | . | . | . | . | 1 | 2 | . | 1 | 1 | 1 | 1 | -| . | . | . | . | 1 | . | . | 1 | 3 | . | 2 | 2 | 3 | 3 | -| . | . | . | . | 6 | 7 | 6 | 1 | 3 | 8 | 1 | 2 | 1 | 1 | -| . | 1 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 1 | . | . | . | . | . | . | . | . | . | . | -| 3 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 7 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 3 | 5 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 3 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | 1 | 3 | 2 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## aqua-robin - 30 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | . | . | . | . | . | 1 | . | . | . | -| . | . | . | . | . | . | . | . | 5 | . | 1 | . | . | . | -| . | . | . | . | 5 | 2 | 3 | 3 | 1 | 6 | 2 | 5 | 6 | 2 | -| . | . | . | . | 4 | 7 | 5 | 2 | 1 | 1 | 3 | 3 | 3 | 3 | -| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | -| . | . | 6 | 2 | . | . | . | . | . | . | . | . | . | . | -| . | . | 1 | 7 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| . | . | 2 | 6 | . | . | . | . | . | . | . | . | . | . | -| . | 2 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | -| 3 | 1 | 1 | 1 | . | . | . | . | . | . | . | . | . | . | -| . | . | 4 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | . | 5 | 4 | . | . | . | . | . | . | . | . | . | . | -| . | 3 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. - ---- - -## starry-trout - 31 - -### Puzzle Grid with Integrated Hints - -| R1 | R2 | R3 | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| . | . | . | . | . | 1 | . | . | . | . | . | 2 | . | -| . | . | . | . | 2 | 1 | 4 | 2 | 1 | 1 | . | 1 | . | -| . | . | . | 2 | 2 | 2 | 2 | 2 | 1 | 1 | 4 | 2 | 7 | -| . | . | . | 4 | 4 | 2 | 2 | 3 | 1 | 5 | 3 | 1 | 2 | -| . | 3 | 5 | . | . | . | . | . | . | . | . | . | . | -| 1 | 2 | 3 | . | . | . | . | . | . | . | . | . | . | -| 1 | 6 | 1 | . | . | . | . | . | . | . | . | . | . | -| 2 | 1 | 3 | . | . | . | . | . | . | . | . | . | . | -| 2 | 3 | 1 | . | . | . | . | . | . | . | . | . | . | -| 1 | 3 | 4 | . | . | . | . | . | . | . | . | . | . | -| 2 | 1 | 4 | . | . | . | . | . | . | . | . | . | . | -| 3 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| 5 | 1 | 2 | . | . | . | . | . | . | . | . | . | . | -| 1 | 2 | 1 | . | . | . | . | . | . | . | . | . | . | - -Row hints are right-aligned beside each row. Column hints are stacked above each column and bottom-aligned to the grid. diff --git a/pdfexport/cover_art.go b/pdfexport/cover_art.go new file mode 100644 index 0000000..b3acfe1 --- /dev/null +++ b/pdfexport/cover_art.go @@ -0,0 +1,841 @@ +package pdfexport + +import ( + "bytes" + "encoding/binary" + "fmt" + "hash/fnv" + "image" + "image/color" + "image/png" + "math" + "math/rand" + "strings" + "time" + + "github.com/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 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 { + rng := coverStreamRNG(seed, "direction") + motif := int(seed % 5) + + warm := blendRGB( + brightenRGB(base, 0.30+rng.Float64()*0.28), + color.RGBA{R: 255, G: 230, B: 180, A: 255}, + 0.34+rng.Float64()*0.38, + ) + cool := blendRGB( + brightenRGB(base, 0.18+rng.Float64()*0.24), + color.RGBA{R: 88, G: 212, B: 230, A: 255}, + 0.30+rng.Float64()*0.42, + ) + dusk := blendRGB( + darkenRGB(base, 0.50+rng.Float64()*0.22), + color.RGBA{R: 9, G: 18, B: 32, A: 255}, + 0.44+rng.Float64()*0.30, + ) + ink := blendRGB(dusk, color.RGBA{R: 22, G: 36, B: 52, A: 255}, 0.40+rng.Float64()*0.25) + + top := blendRGB(warm, color.RGBA{R: 255, G: 255, B: 251, A: 255}, 0.15+rng.Float64()*0.18) + mid := blendRGB(cool, warm, 0.16+rng.Float64()*0.26) + bottom := blendRGB(dusk, color.RGBA{R: 4, G: 9, B: 16, A: 255}, 0.30+rng.Float64()*0.22) + + glows := make([]coverGlow, 0, 4) + glowCount := 3 + rng.Intn(2) + for i := 0; i < glowCount; i++ { + col := warm + if i%2 == 1 { + col = cool + } + col = jitterRGBA(col, rng, 18) + glows = append(glows, coverGlow{ + center: coverVec2{ + x: 0.08 + rng.Float64()*0.84, + y: 0.10 + rng.Float64()*0.82, + }, + spread: 0.42 + rng.Float64()*0.70, + strength: 0.16 + rng.Float64()*0.50, + col: col, + }) + } + + field := coverFieldProfile{ + freqX: 4.0 + rng.Float64()*5.8, + freqY: 3.8 + rng.Float64()*5.6, + freqMixX: 0.5 + rng.Float64()*1.7, + freqMixY: 0.5 + rng.Float64()*1.7, + freqCurlX: 0.4 + rng.Float64()*1.9, + freqCurlY: 0.4 + rng.Float64()*1.9, + ampX: 0.65 + rng.Float64()*0.95, + ampY: 0.65 + rng.Float64()*0.95, + ampMix: 0.28 + rng.Float64()*0.86, + ampCurl: 0.20 + rng.Float64()*0.86, + phaseX: 0.25 + rng.Float64()*1.40, + phaseY: 0.25 + rng.Float64()*1.40, + phaseMix: 0.20 + rng.Float64()*1.10, + phaseCurl: 0.20 + rng.Float64()*1.10, + swirl: (rng.Float64()*2 - 1) * 0.34, + shear: (rng.Float64()*2 - 1) * 0.34, + pinch: (rng.Float64()*2 - 1) * 0.52, + pivot: coverVec2{ + x: 0.22 + rng.Float64()*0.56, + y: 0.20 + rng.Float64()*0.62, + }, + } + + flowLayers := make([]coverFlowLayer, 0, 4) + layerCount := 2 + rng.Intn(3) + for i := 0; i < layerCount; i++ { + t := float64(i) / float64(maxInt(1, layerCount-1)) + a := jitterRGBA(lerpRGB(warm, cool, 0.22+t*0.54), rng, 20) + b := jitterRGBA(lerpRGB(cool, warm, 0.18+t*0.66), rng, 20) + flowLayers = append(flowLayers, coverFlowLayer{ + count: 250 + rng.Intn(320), + steps: 90 + rng.Intn(120), + step: 1.18 + rng.Float64()*1.72, + alpha: uint8(14 + rng.Intn(38)), + radius: 0.76 + rng.Float64()*0.86, + drift: 0.44 + rng.Float64()*1.68, + phase: rng.Float64() * math.Pi * 2, + waveXFreq: 5.0 + rng.Float64()*7.2, + waveYFreq: 5.0 + rng.Float64()*7.2, + waveXAmp: 0.05 + rng.Float64()*0.33, + waveYAmp: 0.05 + rng.Float64()*0.33, + a: a, + b: b, + }) + } + + lattice := coverLatticeProfile{ + enabled: true, + layout: rng.Intn(3), + cols: 7 + rng.Intn(6), + rows: 6 + rng.Intn(6), + neighbors: 2 + rng.Intn(3), + jitterX: 24 + rng.Float64()*46, + jitterY: 28 + rng.Float64()*52, + center: coverVec2{x: 0.30 + rng.Float64()*0.40, y: 0.26 + rng.Float64()*0.44}, + radialWarp: (rng.Float64()*2 - 1) * 0.34, + edge: color.RGBA{R: ink.R, G: ink.G, B: ink.B, A: uint8(40 + rng.Intn(60))}, + nodeOuter: blendRGB(warm, color.RGBA{R: 255, G: 245, B: 218, A: 255}, 0.35), + nodeInner: blendRGB(dusk, color.RGBA{R: 16, G: 30, B: 48, A: 255}, 0.25), + nodeSize: 3.6 + rng.Float64()*3.6, + coreSize: 1.5 + rng.Float64()*1.6, + } + + orbit := coverOrbitProfile{ + enabled: true, + center: coverVec2{x: 0.34 + rng.Float64()*0.32, y: 0.30 + rng.Float64()*0.32}, + count: 8 + rng.Intn(18), + radiusStart: 40 + rng.Float64()*50, + radiusStep: 15 + rng.Float64()*18, + arcCoverage: 0.42 + rng.Float64()*0.58, + segmentsMin: 38 + rng.Intn(34), + segmentsJitter: 46 + rng.Intn(52), + eccentricity: 0.70 + rng.Float64()*0.48, + wobble: 2.8 + rng.Float64()*8.4, + dotSize: 0.86 + rng.Float64()*0.76, + colorA: jitterRGBA(lerpRGB(warm, cool, 0.32), rng, 16), + colorB: jitterRGBA(lerpRGB(cool, warm, 0.70), rng, 16), + alphaBase: uint8(12 + rng.Intn(20)), + alphaStep: uint8(1 + rng.Intn(2)), + } + + marks := coverMarkProfile{ + enabled: true, + count: 24 + rng.Intn(54), + gridW: 8 + rng.Intn(8), + gridH: 10 + rng.Intn(9), + jitter: 8 + rng.Float64()*28, + sizeMin: 1.8 + rng.Float64()*1.3, + sizeMax: 3.8 + rng.Float64()*2.8, + alphaBase: uint8(32 + rng.Intn(36)), + alphaRange: uint8(12 + rng.Intn(32)), + colorA: jitterRGBA(lerpRGB(warm, cool, 0.20), rng, 20), + colorB: jitterRGBA(lerpRGB(cool, warm, 0.72), rng, 20), + cutout: blendRGB(dusk, color.RGBA{R: 5, G: 10, B: 18, A: 255}, 0.35), + } + + direction := coverArtDirection{ + motif: motif, + top: top, + mid: mid, + bottom: bottom, + verticalCurve: 1.24 + rng.Float64()*1.08, + glows: glows, + field: field, + flowLayers: flowLayers, + lattice: lattice, + orbit: orbit, + marks: marks, + grainCount: 32000 + rng.Intn(34000), + grainWarm: blendRGB(warm, color.RGBA{R: 255, G: 246, B: 226, A: 255}, 0.42), + grainCool: blendRGB(dusk, color.RGBA{R: 5, G: 12, B: 20, A: 255}, 0.52), + } + + switch direction.motif { + case 0: + direction.lattice.layout = 0 + direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1) + direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*0.72 + 0.10) + direction.marks.count += 10 + case 1: + for i := range direction.flowLayers { + direction.flowLayers[i].count += 120 + direction.flowLayers[i].steps += 18 + direction.flowLayers[i].alpha = minUint8(70, direction.flowLayers[i].alpha+12) + direction.flowLayers[i].drift *= 1.24 + } + direction.lattice.edge.A = maxUint8(18, direction.lattice.edge.A-22) + direction.orbit.count = maxInt(6, direction.orbit.count/2) + direction.grainCount += 6000 + case 2: + direction.orbit.count += 12 + direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.18 + 0.08) + direction.orbit.radiusStep *= 0.86 + direction.lattice.enabled = rng.Float64() > 0.26 + for i := range direction.flowLayers { + direction.flowLayers[i].count = maxInt(180, direction.flowLayers[i].count-60) + } + case 3: + direction.lattice.layout = 2 + direction.marks.count += 30 + direction.marks.sizeMax += 1.4 + direction.orbit.arcCoverage *= 0.74 + direction.field.swirl *= 1.25 + case 4: + direction.lattice.layout = 1 + direction.lattice.rows += 2 + direction.orbit.eccentricity = clamp(0.55, 1.42, direction.orbit.eccentricity*1.16) + direction.field.pinch *= 1.34 + direction.grainCount += 9000 + } + + return direction +} + +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 brightenRGB(base RGB, amount float64) color.RGBA { + return blendRGB(rgbToColor(base), color.RGBA{R: 255, G: 255, B: 255, A: 255}, amount) +} + +func darkenRGB(base RGB, amount float64) color.RGBA { + return blendRGB(rgbToColor(base), color.RGBA{R: 0, G: 0, B: 0, A: 255}, amount) +} + +func rgbToColor(c RGB) color.RGBA { + return color.RGBA{R: c.R, G: c.G, B: c.B, A: 255} +} + +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..19f3a70 --- /dev/null +++ b/pdfexport/cover_art_test.go @@ -0,0 +1,89 @@ +package pdfexport + +import ( + "bytes" + "image" + "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 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) +} diff --git a/pdfexport/jsonl.go b/pdfexport/jsonl.go index 758b33f..e32b48d 100644 --- a/pdfexport/jsonl.go +++ b/pdfexport/jsonl.go @@ -114,7 +114,7 @@ func ParseJSONLFile(path string) (PackDocument, error) { } hydratePuzzlePrintData(&p) - if p.Nonogram == nil && p.Table == nil && strings.TrimSpace(record.Puzzle.Snippet) != "" { + if !hasRenderablePrintData(p) && strings.TrimSpace(record.Puzzle.Snippet) != "" { nonogram, table, err := ParsePrintableFromSnippet(category, record.Puzzle.Snippet) if err != nil { return PackDocument{}, fmt.Errorf("%s:%d: parse printable snippet: %w", path, lineNo, err) @@ -159,3 +159,15 @@ func ParsePrintableFromSnippet(category, snippet string) (*NonogramData, *GridTa } return nil, table, nil } + +func hasRenderablePrintData(p Puzzle) bool { + return p.Nonogram != nil || + p.Nurikabe != nil || + p.Shikaku != nil || + p.Hashi != nil || + p.Hitori != nil || + p.Takuzu != nil || + p.Sudoku != nil || + p.WordSearch != nil || + p.Table != nil +} diff --git a/pdfexport/jsonl_test.go b/pdfexport/jsonl_test.go index 8b23c5d..a048fc3 100644 --- a/pdfexport/jsonl_test.go +++ b/pdfexport/jsonl_test.go @@ -47,6 +47,39 @@ func TestParseJSONLFile(t *testing.T) { } } +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) + } + if doc.Puzzles[0].Nonogram == 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 { @@ -282,6 +315,71 @@ func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) { } } +func TestParseJSONLFileIgnoresMalformedSnippetWhenSaveHydrated(t *testing.T) { + path := filepath.Join(t.TempDir(), "sudoku-malformed-snippet.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: "sage-briar", + Game: "Sudoku", + Mode: "Easy", + Save: json.RawMessage(`{"provided":[{"x":0,"y":0,"v":5}]}`), + Snippet: "| bad |\n", + }, + } + writeSingleJSONLRecord(t, path, record) + + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatalf("expected lenient parse when save hydration succeeds, got: %v", err) + } + if got, want := len(doc.Puzzles), 1; got != want { + t.Fatalf("puzzles = %d, want %d", got, want) + } + if doc.Puzzles[0].Sudoku == nil { + t.Fatal("expected sudoku print payload from save hydration") + } +} + +func TestParseJSONLFileFailsMalformedSnippetWithoutRenderablePayload(t *testing.T) { + path := filepath.Join(t.TempDir(), "lights-malformed-snippet.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}`), + Snippet: "| bad |\n", + }, + } + writeSingleJSONLRecord(t, path, record) + + _, err := ParseJSONLFile(path) + if err == nil { + t.Fatal("expected parse error when neither save nor snippet produces renderable payload") + } + if !strings.Contains(err.Error(), "parse printable snippet") { + t.Fatalf("unexpected error: %v", err) + } +} + func writeSingleJSONLRecord(t *testing.T, path string, record JSONLRecord) { t.Helper() data, err := json.Marshal(record) diff --git a/pdfexport/printdata.go b/pdfexport/printdata.go index b5156aa..1ef39b1 100644 --- a/pdfexport/printdata.go +++ b/pdfexport/printdata.go @@ -15,6 +15,14 @@ type nurikabeSave struct { 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"` @@ -71,6 +79,47 @@ 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 @@ -356,6 +405,14 @@ func hydratePuzzlePrintData(p *Puzzle) { } switch normalizePrintableCategory(p.Category) { + case "nonogram": + if p.Nonogram != nil { + return + } + nonogram, err := ParseNonogramPrintData(p.SaveData) + if err == nil { + p.Nonogram = nonogram + } case "hashiwokakero": if p.Hashi != nil { return @@ -517,3 +574,76 @@ func parseNurikabeClues(raw string, width, height int) ([][]int, error) { 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 index 2b80dd4..dd45383 100644 --- a/pdfexport/printdata_test.go +++ b/pdfexport/printdata_test.go @@ -2,6 +2,39 @@ 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???"}`) @@ -167,6 +200,24 @@ func TestHydratePuzzlePrintDataForTakuzu(t *testing.T) { } } +func TestHydratePuzzlePrintDataForNonogram(t *testing.T) { + p := Puzzle{ + Category: "Nonogram", + SaveData: []byte(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`), + } + + hydratePuzzlePrintData(&p) + if p.Nonogram == nil { + t.Fatal("expected nonogram payload from save hydration") + } + if got, want := p.Nonogram.Width, 2; got != want { + t.Fatalf("width = %d, want %d", got, want) + } + if got, want := p.Nonogram.Height, 2; got != want { + t.Fatalf("height = %d, want %d", got, want) + } +} + func TestHydratePuzzlePrintDataForNurikabeAndShikaku(t *testing.T) { nurikabePuzzle := Puzzle{ Category: "Nurikabe", diff --git a/pdfexport/render.go b/pdfexport/render.go index 249a709..06002a5 100644 --- a/pdfexport/render.go +++ b/pdfexport/render.go @@ -116,6 +116,7 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg pdf.AddPage() pageW, pageH := pdf.GetPageSize() margin := 12.0 + contentWidth := pageW - 2*margin pdf.SetTextColor(20, 20, 20) pdf.SetFont(sansFontFamily, "B", 22) @@ -132,56 +133,109 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg 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(pageW-2*margin, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format("January 2, 2006")), "", 0, "L", false, 0, "") + pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format("January 2, 2006")), "", 0, "L", false, 0, "") metaY += 6 - versionLine := fmt.Sprintf("PuzzleTea Version: %s", strings.Join(summarizeVersions(docs), ", ")) - wrappedVersions := pdf.SplitLines([]byte(versionLine), pageW-2*margin) - if len(wrappedVersions) == 0 { - wrappedVersions = [][]byte{[]byte(versionLine)} - } for _, line := range wrappedVersions { pdf.SetXY(margin, metaY) - pdf.CellFormat(pageW-2*margin, 5.2, string(line), "", 0, "L", false, 0, "") + pdf.CellFormat(contentWidth, 5.2, string(line), "", 0, "L", false, 0, "") metaY += 5.2 } metaY += 0.8 - categories := summarizeCategories(puzzles) pdf.SetXY(margin, metaY) - pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Puzzles: %d", len(puzzles)), "", 0, "L", false, 0, "") + pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Puzzles: %d", len(puzzles)), "", 0, "L", false, 0, "") metaY += 6 - categoryLine := fmt.Sprintf("Categories: %s", strings.Join(categories, ", ")) - wrappedCategories := pdf.SplitLines([]byte(categoryLine), pageW-2*margin) - if len(wrappedCategories) == 0 { - wrappedCategories = [][]byte{[]byte(categoryLine)} - } - for _, line := range wrappedCategories { - pdf.SetXY(margin, metaY) - pdf.CellFormat(pageW-2*margin, 5.2, string(line), "", 0, "L", false, 0, "") - metaY += 5.2 - } - metaY += 2.8 + metaY += 1.8 pdf.SetFont(sansFontFamily, "B", 10) pdf.SetTextColor(45, 45, 45) pdf.SetXY(margin, metaY) - pdf.CellFormat(pageW-2*margin, 6, "Source Exports", "", 0, "L", false, 0, "") + pdf.CellFormat(contentWidth, 6, "Source Exports", "", 0, "L", false, 0, "") metaY += 7 - renderSourceExportsTable(pdf, docs, margin, metaY, pageW-2*margin, pageH-45) + 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(pageW-2*margin, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "") + pdf.CellFormat(contentWidth, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "") pdf.SetFont(sansFontFamily, "", 10) pdf.SetXY(margin, pageH-22) - pdf.CellFormat(pageW-2*margin, 6, cfg.AdvertText, "", 0, "C", false, 0, "") + 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 renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) { @@ -961,27 +1015,6 @@ func drawNonogramMajorLines( } } -func summarizeCategories(puzzles []Puzzle) []string { - set := map[string]struct{}{} - for _, p := range puzzles { - category := strings.TrimSpace(p.Category) - if category == "" { - continue - } - set[category] = struct{}{} - } - - categories := make([]string, 0, len(set)) - for category := range set { - categories = append(categories, category) - } - sort.Strings(categories) - if len(categories) == 0 { - return []string{"Unknown"} - } - return categories -} - func summarizeVersions(docs []PackDocument) []string { set := map[string]struct{}{} for _, doc := range docs { diff --git a/pdfexport/render_cover.go b/pdfexport/render_cover.go index a19a942..19b6778 100644 --- a/pdfexport/render_cover.go +++ b/pdfexport/render_cover.go @@ -3,7 +3,6 @@ package pdfexport import ( "fmt" "hash/fnv" - "math" "math/rand" "strings" @@ -82,7 +81,7 @@ func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig) { 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} - drawWoodcutScene(pdf, scene, coverColor, ink, cfg.ShuffleSeed) + drawCoverArtwork(pdf, scene, cfg.ShuffleSeed, coverColor, ink) subtitle := strings.TrimSpace(cfg.CoverSubtitle) if subtitle == "" { @@ -129,197 +128,16 @@ func drawCoverFrame(pdf *fpdf.Fpdf, inset, pageW, pageH float64, ink RGB) { pdf.Rect(inner, inner, pageW-2*inner, pageH-2*inner, "D") } -func drawWoodcutScene(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB, seed string) { - drawStippleTexture(pdf, scene, ink, seed) - drawHatchingBands(pdf, scene, ink) - drawPineForestMass(pdf, scene, ink) - drawIsometricRuins(pdf, scene, bg, ink) - drawSkullMotifs(pdf, scene, bg, ink) -} - -func drawStippleTexture(pdf *fpdf.Fpdf, scene rectMM, ink RGB, seed string) { - h := fnv.New64a() - h.Write([]byte(strings.TrimSpace(seed) + "-stipple")) - rng := rand.New(rand.NewSource(int64(h.Sum64()))) - - pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) - for x := scene.x + 1.0; x < scene.x+scene.w-1.0; x += 3.1 { - for y := scene.y + 2.0; y < scene.y+scene.h*0.58; y += 3.2 { - if rng.Float64() < 0.22 { - jitterX := (rng.Float64() - 0.5) * 0.9 - jitterY := (rng.Float64() - 0.5) * 0.9 - pdf.Circle(x+jitterX, y+jitterY, 0.11, "F") - } - } - } -} - -func drawHatchingBands(pdf *fpdf.Fpdf, scene rectMM, ink RGB) { - pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetLineWidth(0.28) - - baseY := scene.y + scene.h*0.64 - for i := -8; i < 34; i++ { - x0 := scene.x + float64(i)*4.4 - y0 := baseY - x1 := x0 + scene.h*0.55 - y1 := baseY + scene.h*0.30 - pdf.Line(x0, y0, x1, y1) - } - - for i := -8; i < 30; i++ { - x0 := scene.x + float64(i)*5.0 - y0 := baseY + 7.5 - x1 := x0 + scene.h*0.48 - y1 := baseY + scene.h*0.33 - pdf.Line(x0, y0, x1, y1) - } -} +func drawCoverArtwork(pdf *fpdf.Fpdf, scene rectMM, seed string, bg, ink RGB) { + drawCoverArtworkImage(pdf, scene, seed, "front", bg) -func drawPineForestMass(pdf *fpdf.Fpdf, scene rectMM, ink RGB) { - pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) 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") - layers := []struct { - y float64 - count int - w float64 - h float64 - offset float64 - }{ - {y: scene.y + scene.h*0.45, count: 22, w: 4.4, h: 13.0, offset: 0.8}, - {y: scene.y + scene.h*0.53, count: 19, w: 5.4, h: 16.0, offset: 1.4}, - {y: scene.y + scene.h*0.62, count: 16, w: 6.2, h: 18.4, offset: 2.0}, - } - - for _, layer := range layers { - step := scene.w / float64(layer.count) - for i := 0; i < layer.count; i++ { - cx := scene.x + float64(i)*step + layer.offset - pts := []fpdf.PointType{ - {X: cx, Y: layer.y - layer.h}, - {X: cx - layer.w, Y: layer.y}, - {X: cx + layer.w, Y: layer.y}, - } - pdf.Polygon(pts, "F") - pdf.SetLineWidth(0.22) - pdf.Line(cx, layer.y, cx, layer.y+2.4) - } - } -} - -func drawIsometricRuins(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB) { - pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) - - centerX := scene.x + scene.w*0.52 - baseY := scene.y + scene.h*0.80 - drawIsometricGrid(pdf, centerX-44, baseY-17, 88, 34, ink) - - drawStoneArch(pdf, centerX-22, baseY-35, 25, 27, 8, bg, ink) - drawStoneArch(pdf, centerX+8, baseY-48, 20, 23, 7, bg, ink) - - drawRubbleBlock(pdf, centerX-39, baseY-6, 9, 5, 5, ink) - drawRubbleBlock(pdf, centerX+31, baseY-3, 10, 5, 6, ink) - drawRubbleBlock(pdf, centerX+12, baseY+5, 8, 4, 4, ink) -} - -func drawIsometricGrid(pdf *fpdf.Fpdf, x, y, w, h float64, ink RGB) { - pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetLineWidth(0.26) - - for i := -8; i < 14; i++ { - sx := x + float64(i)*4 - pdf.Line(sx, y, sx+w*0.5, y+h*0.5) - } - for i := -8; i < 14; i++ { - sx := x + w + float64(i)*4 - pdf.Line(sx, y, sx-w*0.5, y+h*0.5) - } -} - -func drawStoneArch(pdf *fpdf.Fpdf, x, y, w, h, depth float64, bg, ink RGB) { - frontLeft := []fpdf.PointType{{X: x, Y: y}, {X: x + w*0.18, Y: y - depth}, {X: x + w*0.18, Y: y + h - depth}, {X: x, Y: y + h}} - frontRight := []fpdf.PointType{{X: x + w*0.82, Y: y - depth}, {X: x + w, Y: y}, {X: x + w, Y: y + h}, {X: x + w*0.82, Y: y + h - depth}} - - pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.Polygon(frontLeft, "F") - pdf.Polygon(frontRight, "F") - - outerR := w * 0.50 - innerR := w * 0.33 - cx := x + w*0.5 - cy := y + h*0.42 - drawArcPolyline(pdf, cx, cy, outerR, math.Pi, 2*math.Pi, 24) - - pdf.SetDrawColor(int(bg.R), int(bg.G), int(bg.B)) - pdf.SetLineWidth(2.0) - drawArcPolyline(pdf, cx, cy, innerR, math.Pi, 2*math.Pi, 24) - pdf.Line(x+w*0.33, y+h, x+w*0.33, cy) - pdf.Line(x+w*0.67, y+h-depth*0.2, x+w*0.67, cy) - - pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetLineWidth(0.28) - for i := 0; i < 6; i++ { - sy := y + 3 + float64(i)*4.2 - pdf.Line(x+1.5, sy, x+w*0.16, sy-0.6) - pdf.Line(x+w*0.84, sy-0.5, x+w-1.5, sy+0.4) - } -} - -func drawArcPolyline(pdf *fpdf.Fpdf, cx, cy, r, start, end float64, segments int) { - if segments < 2 { - segments = 2 - } - step := (end - start) / float64(segments) - px := cx + math.Cos(start)*r - py := cy + math.Sin(start)*r - for i := 1; i <= segments; i++ { - a := start + float64(i)*step - x := cx + math.Cos(a)*r - y := cy + math.Sin(a)*r - pdf.Line(px, py, x, y) - px, py = x, y - } -} - -func drawRubbleBlock(pdf *fpdf.Fpdf, x, y, w, h, skew float64, ink RGB) { - pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) - pts := []fpdf.PointType{ - {X: x, Y: y}, - {X: x + w, Y: y - skew*0.2}, - {X: x + w + skew, Y: y + h}, - {X: x + skew, Y: y + h + skew*0.2}, - } - pdf.Polygon(pts, "F") -} - -func drawSkullMotifs(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB) { - centers := []struct { - x float64 - y float64 - r float64 - }{ - {x: scene.x + scene.w*0.30, y: scene.y + scene.h*0.84, r: 2.9}, - {x: scene.x + scene.w*0.69, y: scene.y + scene.h*0.87, r: 2.4}, - } - - for _, c := range centers { - pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.Circle(c.x, c.y, c.r, "F") - - pdf.SetFillColor(int(bg.R), int(bg.G), int(bg.B)) - pdf.Circle(c.x-c.r*0.35, c.y-c.r*0.1, c.r*0.22, "F") - pdf.Circle(c.x+c.r*0.35, c.y-c.r*0.08, c.r*0.20, "F") - jaw := []fpdf.PointType{{X: c.x - c.r*0.48, Y: c.y + c.r*0.2}, {X: c.x + c.r*0.48, Y: c.y + c.r*0.2}, {X: c.x, Y: c.y + c.r*0.72}} - pdf.Polygon(jaw, "F") - - pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetLineWidth(0.22) - pdf.Line(c.x-c.r*0.6, c.y-c.r*0.85, c.x-c.r*1.15, c.y-c.r*1.45) - pdf.Line(c.x+c.r*0.5, c.y-c.r*0.7, c.x+c.r*1.0, c.y-c.r*1.3) - } + 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) { @@ -334,7 +152,7 @@ func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) { 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, coverColor, ink) + drawBackMotif(pdf, motif, cfg.ShuffleSeed, coverColor, ink) labelW := pageW - 2*(frameInset+6.0) pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B)) @@ -351,10 +169,10 @@ func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) { pdf.CellFormat(labelW, 4.2, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "") } -func drawBackMotif(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB) { - drawStippleTexture(pdf, scene, ink, "back-cover") - drawHatchingBands(pdf, scene, ink) - drawPineForestMass(pdf, scene, ink) - drawStoneArch(pdf, scene.x+scene.w*0.58, scene.y+scene.h*0.56, 22, 24, 7, bg, ink) - drawRubbleBlock(pdf, scene.x+scene.w*0.28, scene.y+scene.h*0.72, 8, 4, 4, ink) +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/types.go b/pdfexport/types.go index d5d379b..c1bc194 100644 --- a/pdfexport/types.go +++ b/pdfexport/types.go @@ -115,6 +115,7 @@ type RGB struct{ R, G, B uint8 } type RenderConfig struct { Title string CoverSubtitle string + HeaderText string VolumeNumber int AdvertText string GeneratedAt time.Time From 7e06247f1bdcbf8f9c68999925c108e17543593d Mon Sep 17 00:00:00 2001 From: Dami Date: Mon, 23 Feb 2026 13:52:22 -0700 Subject: [PATCH 08/14] refactor: migrate print adapters and remove markdown sharing --- README.md | 1 - app/keys.go | 18 +- app/update.go | 5 - app/update_test.go | 101 -------- app/yank.go | 42 --- cmd/export_pdf.go | 2 +- cmd/export_pdf_test.go | 132 ++++++++++ cmd/new_export.go | 25 +- cmd/new_export_test.go | 19 +- game/print_adapter.go | 65 +++++ game/print_adapter_test.go | 66 +++++ game/print_markdown.go | 192 ++++++++++++++ go.mod | 2 +- hashiwokakero/PrintAdapter.go | 78 ++++++ hitori/PrintAdapter.go | 97 +++++++ markdownexport/detect.go | 41 --- markdownexport/document.go | 71 ------ markdownexport/render.go | 467 ---------------------------------- markdownexport/render_test.go | 188 -------------- markdownexport/support.go | 26 -- nonogram/PrintAdapter.go | 87 +++++++ nurikabe/PrintAdapter.go | 74 ++++++ pdfexport/jsonl.go | 39 +-- pdfexport/jsonl_test.go | 152 +++++++++-- pdfexport/parse.go | 4 +- pdfexport/parse_test.go | 25 +- pdfexport/printdata.go | 80 ------ pdfexport/printdata_test.go | 80 ------ pdfexport/render.go | 145 +++-------- pdfexport/render_public.go | 39 +++ pdfexport/types.go | 10 +- shikaku/PrintAdapter.go | 75 ++++++ sudoku/PrintAdapter.go | 74 ++++++ takuzu/PrintAdapter.go | 83 ++++++ wordsearch/PrintAdapter.go | 99 +++++++ 35 files changed, 1382 insertions(+), 1322 deletions(-) delete mode 100644 app/yank.go create mode 100644 game/print_adapter.go create mode 100644 game/print_adapter_test.go create mode 100644 game/print_markdown.go create mode 100644 hashiwokakero/PrintAdapter.go create mode 100644 hitori/PrintAdapter.go delete mode 100644 markdownexport/detect.go delete mode 100644 markdownexport/document.go delete mode 100644 markdownexport/render.go delete mode 100644 markdownexport/render_test.go delete mode 100644 markdownexport/support.go create mode 100644 nonogram/PrintAdapter.go create mode 100644 nurikabe/PrintAdapter.go create mode 100644 pdfexport/render_public.go create mode 100644 shikaku/PrintAdapter.go create mode 100644 sudoku/PrintAdapter.go create mode 100644 takuzu/PrintAdapter.go create mode 100644 wordsearch/PrintAdapter.go diff --git a/README.md b/README.md index 1eef26d..4202b23 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,6 @@ Several shorthand names are accepted for games: `hashi`/`bridges` for Hashiwokak | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | | `Ctrl+E` | Toggle debug overlay | -| `Ctrl+Y` | Yank puzzle markdown snippet | | `Ctrl+C` | Quit | ### Navigation diff --git a/app/keys.go b/app/keys.go index 4820a4f..8192e04 100644 --- a/app/keys.go +++ b/app/keys.go @@ -3,14 +3,13 @@ package app import "charm.land/bubbles/v2/key" type rootKeyMap struct { - Quit key.Binding - MainMenu key.Binding - Enter key.Binding - Escape key.Binding - Debug key.Binding - FullHelp key.Binding - ResetGame key.Binding - YankPuzzle key.Binding + Quit key.Binding + MainMenu key.Binding + Enter key.Binding + Escape key.Binding + Debug key.Binding + FullHelp key.Binding + ResetGame key.Binding } var rootKeys = rootKeyMap{ @@ -35,7 +34,4 @@ var rootKeys = rootKeyMap{ ResetGame: key.NewBinding( key.WithKeys("ctrl+r"), ), - YankPuzzle: key.NewBinding( - key.WithKeys("ctrl+y"), - ), } diff --git a/app/update.go b/app/update.go index 4b7e11a..3ef54c6 100644 --- a/app/update.go +++ b/app/update.go @@ -175,11 +175,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.state == gameView && m.game != nil { m.game = m.game.Reset() } - case key.Matches(msg, rootKeys.YankPuzzle): - if m.state == gameView && m.game != nil { - return m, m.yankPuzzleCmd() - } - return m, nil } } diff --git a/app/update_test.go b/app/update_test.go index cc6d881..cd66c37 100644 --- a/app/update_test.go +++ b/app/update_test.go @@ -1,9 +1,7 @@ package app import ( - "errors" "path/filepath" - "strings" "testing" "time" @@ -12,11 +10,9 @@ import ( tea "charm.land/bubbletea/v2" "github.com/FelineStateMachine/puzzletea/daily" "github.com/FelineStateMachine/puzzletea/game" - "github.com/FelineStateMachine/puzzletea/lightsout" "github.com/FelineStateMachine/puzzletea/namegen" "github.com/FelineStateMachine/puzzletea/resolve" "github.com/FelineStateMachine/puzzletea/store" - "github.com/FelineStateMachine/puzzletea/sudoku" ) type escapeTrackingGame struct { @@ -242,100 +238,3 @@ func openAppTestStore(t *testing.T) *store.Store { t.Cleanup(func() { _ = s.Close() }) return s } - -func TestCtrlYYanksSupportedPuzzle(t *testing.T) { - previousCopy := copyToClipboard - t.Cleanup(func() { copyToClipboard = previousCopy }) - - var copied string - copyToClipboard = func(s string) error { - copied = s - return nil - } - - m := model{ - state: gameView, - game: sudoku.Model{}, - } - - _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) - if cmd != nil { - t.Fatal("expected nil command when native clipboard copy succeeds") - } - if copied == "" { - t.Fatal("expected markdown snippet to be copied") - } - if !strings.Contains(copied, "Given Grid") { - t.Fatalf("expected sudoku markdown snippet, got:\n%s", copied) - } -} - -func TestCtrlYYankUnsupportedGameIsNoOp(t *testing.T) { - previousCopy := copyToClipboard - t.Cleanup(func() { copyToClipboard = previousCopy }) - - calls := 0 - copyToClipboard = func(string) error { - calls++ - return nil - } - - m := model{ - state: gameView, - game: lightsout.Model{}, - } - - _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) - if cmd != nil { - t.Fatal("expected nil command for unsupported game yank") - } - if calls != 0 { - t.Fatalf("clipboard should not be called, got %d calls", calls) - } -} - -func TestCtrlYYankOutsideGameViewIsNoOp(t *testing.T) { - previousCopy := copyToClipboard - t.Cleanup(func() { copyToClipboard = previousCopy }) - - calls := 0 - copyToClipboard = func(string) error { - calls++ - return nil - } - - m := model{ - state: mainMenuView, - game: sudoku.Model{}, - } - - _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) - if cmd != nil { - t.Fatal("expected nil command outside game view") - } - if calls != 0 { - t.Fatalf("clipboard should not be called outside game view, got %d calls", calls) - } -} - -func TestCtrlYYankFallsBackToOSC52(t *testing.T) { - previousCopy := copyToClipboard - t.Cleanup(func() { copyToClipboard = previousCopy }) - - copyToClipboard = func(string) error { - return errors.New("clipboard unavailable") - } - - m := model{ - state: gameView, - game: sudoku.Model{}, - } - - _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) - if cmd == nil { - t.Fatal("expected fallback clipboard command") - } - if msg := cmd(); msg == nil { - t.Fatal("expected fallback clipboard message") - } -} diff --git a/app/yank.go b/app/yank.go deleted file mode 100644 index 94711d9..0000000 --- a/app/yank.go +++ /dev/null @@ -1,42 +0,0 @@ -package app - -import ( - "errors" - - "github.com/FelineStateMachine/puzzletea/markdownexport" - - tea "charm.land/bubbletea/v2" - "github.com/atotto/clipboard" -) - -var copyToClipboard = clipboard.WriteAll - -func (m model) yankPuzzleCmd() tea.Cmd { - if m.state != gameView || m.game == nil { - return nil - } - - gameType, err := markdownexport.DetectGameType(m.game) - if err != nil || !markdownexport.SupportsGameType(gameType) { - return nil - } - - save, err := m.game.GetSave() - if err != nil { - return nil - } - - snippet, err := markdownexport.RenderPuzzleSnippet(gameType, "", save) - if err != nil { - if errors.Is(err, markdownexport.ErrUnsupportedGame) { - return nil - } - return nil - } - - if err := copyToClipboard(snippet); err == nil { - return nil - } - - return tea.SetClipboard(snippet) -} diff --git a/cmd/export_pdf.go b/cmd/export_pdf.go index cc17520..8ee66d5 100644 --- a/cmd/export_pdf.go +++ b/cmd/export_pdf.go @@ -50,7 +50,7 @@ func runExportPDF(cmd *cobra.Command, args []string) error { puzzles := flattenPuzzles(docs) if len(puzzles) == 0 { - return fmt.Errorf("no puzzles found in input jsonl files") + return nil } lookup := buildModeDifficultyLookup(app.Categories) diff --git a/cmd/export_pdf_test.go b/cmd/export_pdf_test.go index 843527e..f9ed73c 100644 --- a/cmd/export_pdf_test.go +++ b/cmd/export_pdf_test.go @@ -1,11 +1,17 @@ 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) { @@ -100,6 +106,132 @@ func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) { } } +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 diff --git a/cmd/new_export.go b/cmd/new_export.go index 674e331..5ae86d1 100644 --- a/cmd/new_export.go +++ b/cmd/new_export.go @@ -2,7 +2,6 @@ package cmd import ( "encoding/json" - "errors" "fmt" "io" "math/rand/v2" @@ -13,7 +12,6 @@ import ( "github.com/FelineStateMachine/puzzletea/app" "github.com/FelineStateMachine/puzzletea/game" - "github.com/FelineStateMachine/puzzletea/markdownexport" "github.com/FelineStateMachine/puzzletea/namegen" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/FelineStateMachine/puzzletea/resolve" @@ -37,8 +35,8 @@ func runNewExport(cmd *cobra.Command, args []string) error { if err != nil { return err } - if !markdownexport.SupportsGameType(cat.Name) { - return fmt.Errorf("game %q does not support export", cat.Name) + if !game.HasPrintAdapter(cat.Name) { + return nil } modeArg := "" @@ -158,14 +156,6 @@ func buildExportRecords( return nil, fmt.Errorf("serialize puzzle %d: save payload is not valid JSON", i+1) } - snippet, err := markdownexport.RenderPuzzleSnippet(gameType, entry.mode, save) - if err != nil { - if errors.Is(err, markdownexport.ErrUnsupportedGame) { - return nil, fmt.Errorf("game %q does not support export", gameType) - } - return nil, fmt.Errorf("render puzzle %d: %w", i+1, err) - } - records = append(records, pdfexport.JSONLRecord{ Schema: pdfexport.ExportSchemaV1, Pack: pdfexport.JSONLPackMeta{ @@ -177,12 +167,11 @@ func buildExportRecords( Seed: seed, }, Puzzle: pdfexport.JSONLPuzzle{ - Index: i + 1, - Name: namegen.GenerateSeeded(nameRNG), - Game: gameType, - Mode: entry.mode, - Save: json.RawMessage(save), - Snippet: snippet, + Index: i + 1, + Name: namegen.GenerateSeeded(nameRNG), + Game: gameType, + Mode: entry.mode, + Save: json.RawMessage(save), }, }) } diff --git a/cmd/new_export_test.go b/cmd/new_export_test.go index 1a7cd63..43fc832 100644 --- a/cmd/new_export_test.go +++ b/cmd/new_export_test.go @@ -17,15 +17,16 @@ import ( func TestRunNewExportRejectsUnsupportedGame(t *testing.T) { withExportFlagReset(t) - flagOutput = filepath.Join(t.TempDir(), "lights.jsonl") + output := filepath.Join(t.TempDir(), "lights.jsonl") + flagOutput = output cmd, _ := newExportTestCmd(t, false) err := runNewExport(cmd, []string{"lights-out"}) - if err == nil { - t.Fatal("expected unsupported game error") + if err != nil { + t.Fatalf("expected silent no-op for unsupported game, got: %v", err) } - if !strings.Contains(err.Error(), "does not support export") { - t.Fatalf("unexpected error: %v", err) + if _, statErr := os.Stat(output); !os.IsNotExist(statErr) { + t.Fatalf("expected no output file, stat err = %v", statErr) } } @@ -173,6 +174,14 @@ func TestRunNewExportOmitsPrintPayload(t *testing.T) { 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) { diff --git a/game/print_adapter.go b/game/print_adapter.go new file mode 100644 index 0000000..4820db4 --- /dev/null +++ b/game/print_adapter.go @@ -0,0 +1,65 @@ +package game + +import ( + "reflect" + "strings" + + "github.com/go-pdf/fpdf" +) + +type PrintAdapter interface { + CanonicalGameType() string + Aliases() []string + RenderMarkdownSnippet(save []byte) (string, error) + BuildPDFPayload(save []byte, snippet string) (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..e6c1874 --- /dev/null +++ b/game/print_adapter_test.go @@ -0,0 +1,66 @@ +package game + +import ( + "testing" + + "github.com/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) RenderMarkdownSnippet([]byte) (string, error) { + return "", nil +} +func (a testPrintAdapter) BuildPDFPayload([]byte, string) (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/game/print_markdown.go b/game/print_markdown.go new file mode 100644 index 0000000..18b95cd --- /dev/null +++ b/game/print_markdown.go @@ -0,0 +1,192 @@ +package game + +import ( + "fmt" + "strconv" + "strings" +) + +func SplitLines(s string) []string { + if strings.TrimSpace(s) == "" { + return []string{} + } + return strings.Split(s, "\n") +} + +func EscapeMarkdownCell(s string) string { + return strings.ReplaceAll(s, "|", "\\|") +} + +func MakeStringGrid(width, height int, fill string) [][]string { + if width <= 0 || height <= 0 { + return [][]string{} + } + + grid := make([][]string, height) + for y := range height { + grid[y] = make([]string, width) + for x := range width { + grid[y][x] = fill + } + } + return grid +} + +func RenderGridTable(cells [][]string) string { + if len(cells) == 0 { + return "_(empty grid)_" + } + + width := 0 + for _, row := range cells { + if len(row) > width { + width = len(row) + } + } + if width <= 0 { + return "_(empty grid)_" + } + + var b strings.Builder + b.WriteString("| |") + for x := range width { + fmt.Fprintf(&b, " %d |", x+1) + } + b.WriteString("\n| --- |") + for range width { + b.WriteString(" --- |") + } + b.WriteString("\n") + + for y := range len(cells) { + fmt.Fprintf(&b, "| %d |", y+1) + for x := range width { + cell := "." + if x < len(cells[y]) && strings.TrimSpace(cells[y][x]) != "" { + cell = cells[y][x] + } + fmt.Fprintf(&b, " %s |", EscapeMarkdownCell(cell)) + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} + +func ParseCSVIntGrid(raw string, width, height int) [][]int { + if width <= 0 || height <= 0 { + return [][]int{} + } + + clues := make([][]int, height) + for y := range height { + clues[y] = make([]int, width) + } + + rows := SplitLines(raw) + for y := range min(len(rows), len(clues)) { + parts := strings.Split(rows[y], ",") + for x := range min(len(parts), len(clues[y])) { + val, err := strconv.Atoi(strings.TrimSpace(parts[x])) + if err != nil { + continue + } + clues[y][x] = val + } + } + return clues +} + +func NormalizeNonogramHints(src [][]int, size int) [][]int { + if size <= 0 { + return [][]int{} + } + + hints := make([][]int, size) + for i := range len(hints) { + if i >= len(src) || len(src[i]) == 0 { + hints[i] = []int{0} + continue + } + hints[i] = append([]int(nil), src[i]...) + } + return hints +} + +func MaxNonogramHintLen(hints [][]int) int { + maxLen := 0 + for _, hint := range hints { + if len(hint) > maxLen { + maxLen = len(hint) + } + } + return maxLen +} + +func RenderNonogramTable( + rowHints, colHints [][]int, + width, height, rowHintCols, colHintRows int, +) string { + var b strings.Builder + + b.WriteString("|") + for i := range rowHintCols { + fmt.Fprintf(&b, " R%d |", i+1) + } + for x := range width { + fmt.Fprintf(&b, " C%d |", x+1) + } + b.WriteString("\n|") + for range rowHintCols + width { + b.WriteString(" --- |") + } + b.WriteString("\n") + + for hintRow := range colHintRows { + b.WriteString("|") + for range rowHintCols { + b.WriteString(" . |") + } + for x := range width { + b.WriteString(" ") + b.WriteString(renderColumnHintCell(colHints[x], colHintRows, hintRow)) + b.WriteString(" |") + } + b.WriteString("\n") + } + + for y := range height { + rowHint := rowHints[y] + hintStart := rowHintCols - len(rowHint) + + b.WriteString("|") + for hintCol := range rowHintCols { + if hintCol < hintStart { + b.WriteString(" . |") + continue + } + fmt.Fprintf(&b, " %d |", rowHint[hintCol-hintStart]) + } + for range width { + b.WriteString(" . |") + } + b.WriteString("\n") + } + + return strings.TrimRight(b.String(), "\n") +} + +func renderColumnHintCell(hint []int, depth, row int) string { + start := depth - len(hint) + if row < start { + return "." + } + return strconv.Itoa(hint[row-start]) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/go.mod b/go.mod index 9b52670..cb9ed1a 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ 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 - github.com/atotto/clipboard v0.1.4 github.com/go-pdf/fpdf v0.9.0 github.com/spf13/cobra v1.10.2 modernc.org/sqlite v1.44.3 @@ -14,6 +13,7 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect diff --git a/hashiwokakero/PrintAdapter.go b/hashiwokakero/PrintAdapter.go new file mode 100644 index 0000000..b511443 --- /dev/null +++ b/hashiwokakero/PrintAdapter.go @@ -0,0 +1,78 @@ +package hashiwokakero + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/go-pdf/fpdf" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Hashiwokakero" } +func (printAdapter) Aliases() []string { + return []string{"hashi", "hashiwokakero", "hashi wokakero"} +} + +func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { + var data Save + if err := json.Unmarshal(save, &data); err != nil { + return "", fmt.Errorf("decode hashiwokakero save: %w", err) + } + + cells := game.MakeStringGrid(data.Width, data.Height, ".") + for _, island := range data.Islands { + if island.Y < 0 || island.Y >= len(cells) { + continue + } + if island.X < 0 || island.X >= len(cells[island.Y]) { + continue + } + cells[island.Y][island.X] = strconv.Itoa(island.Required) + } + + var b strings.Builder + b.WriteString("### Grid\n\n") + b.WriteString(game.RenderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Rules: connect numbered islands with horizontal/vertical bridges. ") + b.WriteString("Use up to two bridges per connection and never cross bridges.") + return b.String(), nil +} + +func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { + payload, err := pdfexport.ParseHashiPrintData(save) + if err != nil { + return nil, err + } + if !game.IsNilPrintPayload(payload) { + return payload, nil + } + if strings.TrimSpace(snippet) == "" { + return nil, nil + } + + _, table, err := pdfexport.ParsePrintableFromSnippet("Hashiwokakero", snippet) + if err != nil { + return nil, err + } + return table, nil +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.HashiData: + pdfexport.RenderHashiPage(pdf, data) + case *pdfexport.GridTable: + pdfexport.RenderGridTablePage(pdf, data) + } + return nil +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/hitori/PrintAdapter.go b/hitori/PrintAdapter.go new file mode 100644 index 0000000..8be5451 --- /dev/null +++ b/hitori/PrintAdapter.go @@ -0,0 +1,97 @@ +package hitori + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/go-pdf/fpdf" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Hitori" } +func (printAdapter) Aliases() []string { return []string{"hitori"} } + +func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { + var data Save + if err := json.Unmarshal(save, &data); err != nil { + return "", fmt.Errorf("decode hitori save: %w", err) + } + + rows := game.SplitLines(data.Numbers) + targetHeight := data.Size + if targetHeight < len(rows) { + targetHeight = len(rows) + } + if targetHeight < 1 { + targetHeight = 1 + } + + cells := make([][]string, 0, targetHeight) + for y := range targetHeight { + row := []rune{} + if y < len(rows) { + row = []rune(rows[y]) + } + targetWidth := data.Size + if targetWidth < len(row) { + targetWidth = len(row) + } + if targetWidth < 1 { + targetWidth = 1 + } + + cellsRow := make([]string, targetWidth) + for x := range len(cellsRow) { + cellsRow[x] = "." + if x < len(row) && row[x] != ' ' { + cellsRow[x] = string(row[x]) + } + } + cells = append(cells, cellsRow) + } + + var b strings.Builder + b.WriteString("### Grid\n\n") + b.WriteString(game.RenderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Goal: shade cells so no row or column has duplicate unshaded values, ") + b.WriteString("shaded cells do not touch orthogonally, and all unshaded cells stay connected.") + return b.String(), nil +} + +func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { + payload, err := pdfexport.ParseHitoriPrintData(save) + if err != nil { + return nil, err + } + if !game.IsNilPrintPayload(payload) { + return payload, nil + } + if strings.TrimSpace(snippet) == "" { + return nil, nil + } + + _, table, err := pdfexport.ParsePrintableFromSnippet("Hitori", snippet) + if err != nil { + return nil, err + } + return table, nil +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.HitoriData: + pdfexport.RenderHitoriPage(pdf, data) + case *pdfexport.GridTable: + pdfexport.RenderGridTablePage(pdf, data) + } + return nil +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/markdownexport/detect.go b/markdownexport/detect.go deleted file mode 100644 index 6fa6822..0000000 --- a/markdownexport/detect.go +++ /dev/null @@ -1,41 +0,0 @@ -package markdownexport - -import ( - "fmt" - - "github.com/FelineStateMachine/puzzletea/game" - "github.com/FelineStateMachine/puzzletea/hashiwokakero" - "github.com/FelineStateMachine/puzzletea/hitori" - "github.com/FelineStateMachine/puzzletea/lightsout" - "github.com/FelineStateMachine/puzzletea/nonogram" - "github.com/FelineStateMachine/puzzletea/nurikabe" - "github.com/FelineStateMachine/puzzletea/shikaku" - "github.com/FelineStateMachine/puzzletea/sudoku" - "github.com/FelineStateMachine/puzzletea/takuzu" - "github.com/FelineStateMachine/puzzletea/wordsearch" -) - -func DetectGameType(g game.Gamer) (string, error) { - switch g.(type) { - case hashiwokakero.Model, *hashiwokakero.Model: - return "Hashiwokakero", nil - case hitori.Model, *hitori.Model: - return "Hitori", nil - case lightsout.Model, *lightsout.Model: - return "Lights Out", nil - case nonogram.Model, *nonogram.Model: - return "Nonogram", nil - case nurikabe.Model, *nurikabe.Model: - return "Nurikabe", nil - case shikaku.Model, *shikaku.Model: - return "Shikaku", nil - case sudoku.Model, *sudoku.Model: - return "Sudoku", nil - case takuzu.Model, *takuzu.Model: - return "Takuzu", nil - case wordsearch.Model, *wordsearch.Model: - return "Word Search", nil - default: - return "", fmt.Errorf("cannot detect game type for %T", g) - } -} diff --git a/markdownexport/document.go b/markdownexport/document.go deleted file mode 100644 index 417bd08..0000000 --- a/markdownexport/document.go +++ /dev/null @@ -1,71 +0,0 @@ -package markdownexport - -import ( - "fmt" - "hash/fnv" - "math/rand/v2" - "strings" - "time" - - "github.com/FelineStateMachine/puzzletea/namegen" -) - -type DocumentConfig struct { - Version string - Category string - ModeSelection string - Count int - Seed string - GeneratedAt time.Time -} - -type PuzzleSection struct { - Index int - GameType string - Mode string - Body string -} - -func BuildDocument(cfg DocumentConfig, puzzles []PuzzleSection) string { - var b strings.Builder - - seed := cfg.Seed - if strings.TrimSpace(seed) == "" { - seed = "none" - } - nameRNG := exportNameRNG(cfg) - - fmt.Fprintf(&b, "# PuzzleTea Export\n\n") - fmt.Fprintf(&b, "- Generated: %s\n", cfg.GeneratedAt.Format(time.RFC3339)) - fmt.Fprintf(&b, "- Version: %s\n", cfg.Version) - fmt.Fprintf(&b, "- Category: %s\n", cfg.Category) - fmt.Fprintf(&b, "- Mode Selection: %s\n", cfg.ModeSelection) - fmt.Fprintf(&b, "- Count: %d\n", cfg.Count) - fmt.Fprintf(&b, "- Seed: %s\n\n", seed) - - for i, puzzle := range puzzles { - if i > 0 { - b.WriteString("\n---\n\n") - } - fmt.Fprintf(&b, "## %s - %d\n\n", namegen.GenerateSeeded(nameRNG), puzzle.Index) - b.WriteString(strings.TrimSpace(puzzle.Body)) - b.WriteString("\n") - } - - return b.String() -} - -func exportNameRNG(cfg DocumentConfig) *rand.Rand { - seed := cfg.Seed - if strings.TrimSpace(seed) == "" { - seed = cfg.GeneratedAt.Format(time.RFC3339Nano) - } - return rngFromString("export-names:" + seed) -} - -func rngFromString(seed string) *rand.Rand { - h := fnv.New64a() - h.Write([]byte(seed)) - s := h.Sum64() - return rand.New(rand.NewPCG(s, ^s)) -} diff --git a/markdownexport/render.go b/markdownexport/render.go deleted file mode 100644 index 02530ce..0000000 --- a/markdownexport/render.go +++ /dev/null @@ -1,467 +0,0 @@ -package markdownexport - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/FelineStateMachine/puzzletea/hashiwokakero" - "github.com/FelineStateMachine/puzzletea/hitori" - "github.com/FelineStateMachine/puzzletea/nonogram" - "github.com/FelineStateMachine/puzzletea/nurikabe" - "github.com/FelineStateMachine/puzzletea/shikaku" - "github.com/FelineStateMachine/puzzletea/sudoku" - "github.com/FelineStateMachine/puzzletea/takuzu" - "github.com/FelineStateMachine/puzzletea/wordsearch" -) - -func RenderPuzzleSnippet(gameType, _ string, save []byte) (string, error) { - switch normalizeGameType(gameType) { - case "hashiwokakero": - return renderHashi(save) - case "hitori": - return renderHitori(save) - case "nonogram": - return renderNonogram(save) - case "nurikabe": - return renderNurikabe(save) - case "shikaku": - return renderShikaku(save) - case "sudoku": - return renderSudoku(save) - case "takuzu": - return renderTakuzu(save) - case "word search", "wordsearch": - return renderWordSearch(save) - case "lights out", "lightsout": - return "", ErrUnsupportedGame - default: - return "", ErrUnsupportedGame - } -} - -func renderHashi(data []byte) (string, error) { - var save hashiwokakero.Save - if err := json.Unmarshal(data, &save); err != nil { - return "", fmt.Errorf("decode hashiwokakero save: %w", err) - } - - cells := makeGrid(save.Width, save.Height, ".") - for _, island := range save.Islands { - if island.Y >= 0 && island.Y < len(cells) && island.X >= 0 && island.X < len(cells[island.Y]) { - cells[island.Y][island.X] = strconv.Itoa(island.Required) - } - } - - var b strings.Builder - b.WriteString("### Grid\n\n") - b.WriteString(renderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Rules: connect numbered islands with horizontal/vertical bridges. ") - b.WriteString("Use up to two bridges per connection and never cross bridges.") - return b.String(), nil -} - -func renderHitori(data []byte) (string, error) { - var save hitori.Save - if err := json.Unmarshal(data, &save); err != nil { - return "", fmt.Errorf("decode hitori save: %w", err) - } - - rows := splitLines(save.Numbers) - cells := make([][]string, 0, max(len(rows), save.Size)) - targetHeight := max(len(rows), save.Size) - for y := range targetHeight { - row := []rune{} - if y < len(rows) { - row = []rune(rows[y]) - } - cellsRow := make([]string, max(len(row), save.Size)) - if len(cellsRow) == 0 { - cellsRow = []string{"."} - } - for x := range len(cellsRow) { - if x < len(row) { - cellsRow[x] = string(row[x]) - } else { - cellsRow[x] = "." - } - } - cells = append(cells, cellsRow) - } - if len(cells) == 0 { - cells = [][]string{{"."}} - } - - var b strings.Builder - b.WriteString("### Grid\n\n") - b.WriteString(renderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Goal: shade cells so no row or column has duplicate unshaded values, ") - b.WriteString("shaded cells do not touch orthogonally, and all unshaded cells stay connected.") - return b.String(), nil -} - -func renderNonogram(data []byte) (string, error) { - var save nonogram.Save - if err := json.Unmarshal(data, &save); err != nil { - return "", fmt.Errorf("decode nonogram save: %w", err) - } - - width := save.Width - height := save.Height - if width <= 0 { - width = len(save.ColHints) - } - if height <= 0 { - height = len(save.RowHints) - } - if width <= 0 || height <= 0 { - return "### Puzzle Grid with Integrated Hints\n\n_(empty grid)_", nil - } - - rowHints := normalizeNonogramHints(save.RowHints, height) - colHints := normalizeNonogramHints(save.ColHints, width) - - rowHintCols := maxNonogramHintLen(rowHints) - colHintRows := maxNonogramHintLen(colHints) - if rowHintCols < 1 { - rowHintCols = 1 - } - if colHintRows < 1 { - colHintRows = 1 - } - - var b strings.Builder - b.WriteString("### Puzzle Grid with Integrated Hints\n\n") - b.WriteString(renderNonogramTable(rowHints, colHints, width, height, rowHintCols, colHintRows)) - b.WriteString("\n\n") - b.WriteString("Row hints are right-aligned beside each row. ") - b.WriteString("Column hints are stacked above each column and bottom-aligned to the grid.") - return b.String(), nil -} - -func renderNurikabe(data []byte) (string, error) { - var save nurikabe.Save - if err := json.Unmarshal(data, &save); err != nil { - return "", fmt.Errorf("decode nurikabe save: %w", err) - } - - clues := parseNurikabeClues(save.Clues, save.Width, save.Height) - cells := makeGrid(save.Width, save.Height, ".") - for y := range len(cells) { - for x := range len(cells[y]) { - if y < len(clues) && x < len(clues[y]) && clues[y][x] > 0 { - cells[y][x] = strconv.Itoa(clues[y][x]) - } - } - } - - var b strings.Builder - b.WriteString("### Clue Grid\n\n") - b.WriteString(renderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Goal: build one connected sea while each numbered island has the exact size of its clue.") - return b.String(), nil -} - -func renderShikaku(data []byte) (string, error) { - var save shikaku.Save - if err := json.Unmarshal(data, &save); err != nil { - return "", fmt.Errorf("decode shikaku save: %w", err) - } - - cells := makeGrid(save.Width, save.Height, ".") - for _, clue := range save.Clues { - if clue.Y >= 0 && clue.Y < len(cells) && clue.X >= 0 && clue.X < len(cells[clue.Y]) { - cells[clue.Y][clue.X] = strconv.Itoa(clue.Value) - } - } - - var b strings.Builder - b.WriteString("### Clue Grid\n\n") - b.WriteString(renderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Goal: partition the grid into rectangles so each rectangle contains one clue and its area matches that clue.") - return b.String(), nil -} - -func renderSudoku(data []byte) (string, error) { - var save sudoku.Save - if err := json.Unmarshal(data, &save); err != nil { - return "", fmt.Errorf("decode sudoku save: %w", err) - } - - cells := makeGrid(9, 9, ".") - for _, provided := range save.Provided { - if provided.Y >= 0 && provided.Y < len(cells) && provided.X >= 0 && provided.X < len(cells[provided.Y]) { - cells[provided.Y][provided.X] = strconv.Itoa(provided.V) - } - } - - var b strings.Builder - b.WriteString("### Given Grid\n\n") - b.WriteString(renderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Goal: fill each row, column, and 3x3 box with digits 1-9 exactly once.") - return b.String(), nil -} - -func renderTakuzu(data []byte) (string, error) { - var save takuzu.Save - if err := json.Unmarshal(data, &save); err != nil { - return "", fmt.Errorf("decode takuzu save: %w", err) - } - - cells := makeGrid(save.Size, save.Size, ".") - stateRows := splitLines(save.State) - providedRows := splitLines(save.Provided) - - for y := range len(cells) { - for x := range len(cells[y]) { - provided := y < len(providedRows) && x < len(providedRows[y]) && providedRows[y][x] == '#' - if !provided { - continue - } - - if y < len(stateRows) && x < len(stateRows[y]) { - switch stateRows[y][x] { - case '0', '1': - cells[y][x] = string(stateRows[y][x]) - default: - cells[y][x] = "." - } - } - } - } - - var b strings.Builder - b.WriteString("### Given Grid\n\n") - b.WriteString(renderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Goal: fill with 0/1 so no three equal adjacent cells appear, each row/column has equal 0 and 1 counts, and rows/columns are unique.") - return b.String(), nil -} - -func renderWordSearch(data []byte) (string, error) { - var save wordsearch.Save - if err := json.Unmarshal(data, &save); err != nil { - return "", fmt.Errorf("decode word search save: %w", err) - } - - rows := splitLines(save.Grid) - height := max(save.Height, len(rows)) - width := save.Width - if width <= 0 { - for _, row := range rows { - if len([]rune(row)) > width { - width = len([]rune(row)) - } - } - } - - cells := makeGrid(width, height, ".") - for y := range len(cells) { - if y >= len(rows) { - continue - } - row := []rune(rows[y]) - for x := range len(cells[y]) { - if x < len(row) { - cells[y][x] = string(row[x]) - } - } - } - - var b strings.Builder - b.WriteString("### Grid\n\n") - b.WriteString(renderGridTable(cells)) - b.WriteString("\n\n") - - b.WriteString("### Word List\n\n") - b.WriteString("| # | Word |\n") - b.WriteString("| --- | --- |\n") - for i, word := range save.Words { - fmt.Fprintf(&b, "| %d | %s |\n", i+1, escapeCell(word.Text)) - } - if len(save.Words) == 0 { - b.WriteString("| 1 | (none) |\n") - } - - b.WriteString("\nGoal: find all listed words in the grid.") - return b.String(), nil -} - -func makeGrid(width, height int, fill string) [][]string { - if width <= 0 || height <= 0 { - return [][]string{} - } - grid := make([][]string, height) - for y := range height { - grid[y] = make([]string, width) - for x := range width { - grid[y][x] = fill - } - } - return grid -} - -func renderGridTable(cells [][]string) string { - if len(cells) == 0 { - return "_(empty grid)_" - } - width := 0 - for _, row := range cells { - if len(row) > width { - width = len(row) - } - } - if width == 0 { - return "_(empty grid)_" - } - - var b strings.Builder - b.WriteString("| |") - for x := range width { - fmt.Fprintf(&b, " %d |", x+1) - } - b.WriteString("\n| --- |") - for range width { - b.WriteString(" --- |") - } - b.WriteString("\n") - - for y := range len(cells) { - fmt.Fprintf(&b, "| %d |", y+1) - for x := range width { - cell := "." - if x < len(cells[y]) && strings.TrimSpace(cells[y][x]) != "" { - cell = cells[y][x] - } - fmt.Fprintf(&b, " %s |", escapeCell(cell)) - } - b.WriteString("\n") - } - - return strings.TrimRight(b.String(), "\n") -} - -func parseNurikabeClues(raw string, width, height int) [][]int { - clues := make([][]int, max(height, 0)) - for y := range len(clues) { - clues[y] = make([]int, max(width, 0)) - } - - rows := splitLines(raw) - for y := range min(len(rows), len(clues)) { - parts := strings.Split(rows[y], ",") - for x := range min(len(parts), len(clues[y])) { - val, err := strconv.Atoi(strings.TrimSpace(parts[x])) - if err != nil { - continue - } - clues[y][x] = val - } - } - - return clues -} - -func splitLines(s string) []string { - if strings.TrimSpace(s) == "" { - return []string{} - } - return strings.Split(s, "\n") -} - -func escapeCell(s string) string { - return strings.ReplaceAll(s, "|", "\\|") -} - -func normalizeNonogramHints(src nonogram.TomographyDefinition, size int) [][]int { - hints := make([][]int, max(size, 0)) - for i := range len(hints) { - if i >= len(src) { - hints[i] = []int{0} - continue - } - if len(src[i]) == 0 { - hints[i] = []int{0} - continue - } - hints[i] = append([]int(nil), src[i]...) - } - return hints -} - -func maxNonogramHintLen(hints [][]int) int { - maxLen := 0 - for _, hint := range hints { - if len(hint) > maxLen { - maxLen = len(hint) - } - } - return maxLen -} - -func renderNonogramTable( - rowHints, colHints [][]int, - width, height, rowHintCols, colHintRows int, -) string { - var b strings.Builder - - b.WriteString("|") - for i := range rowHintCols { - fmt.Fprintf(&b, " R%d |", i+1) - } - for x := range width { - fmt.Fprintf(&b, " C%d |", x+1) - } - b.WriteString("\n|") - for range rowHintCols + width { - b.WriteString(" --- |") - } - b.WriteString("\n") - - for hintRow := range colHintRows { - b.WriteString("|") - for range rowHintCols { - b.WriteString(" . |") - } - for x := range width { - b.WriteString(" ") - b.WriteString(renderColumnHintCell(colHints[x], colHintRows, hintRow)) - b.WriteString(" |") - } - b.WriteString("\n") - } - - for y := range height { - rowHint := rowHints[y] - hintStart := rowHintCols - len(rowHint) - - b.WriteString("|") - for hintCol := range rowHintCols { - if hintCol < hintStart { - b.WriteString(" . |") - continue - } - fmt.Fprintf(&b, " %d |", rowHint[hintCol-hintStart]) - } - for range width { - b.WriteString(" . |") - } - b.WriteString("\n") - } - - return strings.TrimRight(b.String(), "\n") -} - -func renderColumnHintCell(hint []int, depth, row int) string { - start := depth - len(hint) - if row < start { - return "." - } - return strconv.Itoa(hint[row-start]) -} diff --git a/markdownexport/render_test.go b/markdownexport/render_test.go deleted file mode 100644 index d403585..0000000 --- a/markdownexport/render_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package markdownexport - -import ( - "errors" - "regexp" - "strings" - "testing" - "time" - - "github.com/FelineStateMachine/puzzletea/game" - "github.com/FelineStateMachine/puzzletea/sudoku" - "github.com/FelineStateMachine/puzzletea/wordsearch" - - "charm.land/bubbles/v2/key" - tea "charm.land/bubbletea/v2" -) - -func TestSupportsGameType(t *testing.T) { - if !SupportsGameType("Sudoku") { - t.Fatal("expected sudoku to be supported") - } - if SupportsGameType("Lights Out") { - t.Fatal("expected lights out to be unsupported") - } -} - -func TestRenderPuzzleSnippetUnsupported(t *testing.T) { - _, err := RenderPuzzleSnippet("Lights Out", "", []byte(`{}`)) - if !errors.Is(err, ErrUnsupportedGame) { - t.Fatalf("expected ErrUnsupportedGame, got %v", err) - } -} - -func TestRenderPuzzleSnippetSudokuUsesProvidedOnly(t *testing.T) { - data := []byte(`{ - "grid":"500000000\n600000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000", - "provided":[{"x":0,"y":0,"v":5}] - }`) - - snippet, err := RenderPuzzleSnippet("Sudoku", "", data) - if err != nil { - t.Fatal(err) - } - - if !strings.Contains(snippet, "| 1 | 5 | . | . | . | . | . | . | . | . |") { - t.Fatalf("expected given row in snippet, got:\n%s", snippet) - } - if !strings.Contains(snippet, "| 2 | . | . | . | . | . | . | . | . | . |") { - t.Fatalf("expected user-entered row to be blank in snippet, got:\n%s", snippet) - } -} - -func TestRenderPuzzleSnippetTakuzuUsesProvidedOnly(t *testing.T) { - data := []byte(`{ - "size":2, - "state":"01\n10", - "provided":"#.\n.#", - "mode_title":"Test" - }`) - - snippet, err := RenderPuzzleSnippet("Takuzu", "", data) - if err != nil { - t.Fatal(err) - } - - if !strings.Contains(snippet, "| 1 | 0 | . |") { - t.Fatalf("expected first row to keep only provided value, got:\n%s", snippet) - } - if !strings.Contains(snippet, "| 2 | . | 0 |") { - t.Fatalf("expected second row to keep only provided value, got:\n%s", snippet) - } -} - -func TestRenderPuzzleSnippetNonogramIntegratedHintsLayout(t *testing.T) { - data := []byte(`{ - "width":3, - "height":2, - "row-hints":[[3],[1,1]], - "col-hints":[[1],[2],[1]] - }`) - - snippet, err := RenderPuzzleSnippet("Nonogram", "", data) - if err != nil { - t.Fatal(err) - } - - if !strings.Contains(snippet, "### Puzzle Grid with Integrated Hints") { - t.Fatalf("expected integrated nonogram heading, got:\n%s", snippet) - } - if strings.Contains(snippet, "### Row Hints") || strings.Contains(snippet, "### Column Hints") { - t.Fatalf("expected legacy split hint sections to be removed, got:\n%s", snippet) - } - if !strings.Contains(snippet, "| . | . | 1 | 2 | 1 |") { - t.Fatalf("expected column hints row aligned above puzzle grid, got:\n%s", snippet) - } - if !strings.Contains(snippet, "| . | 3 | . | . | . |") { - t.Fatalf("expected single row hint to be right-aligned near grid, got:\n%s", snippet) - } - if !strings.Contains(snippet, "| 1 | 1 | . | . | . |") { - t.Fatalf("expected multi-value row hint to render beside puzzle row, got:\n%s", snippet) - } -} - -func TestRenderPuzzleSnippetNonogramColumnHintsBottomAligned(t *testing.T) { - data := []byte(`{ - "width":2, - "height":1, - "row-hints":[[1]], - "col-hints":[[2,1],[3]] - }`) - - snippet, err := RenderPuzzleSnippet("Nonogram", "", data) - if err != nil { - t.Fatal(err) - } - - if !strings.Contains(snippet, "| . | 2 | . |") { - t.Fatalf("expected top column hint row to pad shorter hints, got:\n%s", snippet) - } - if !strings.Contains(snippet, "| . | 1 | 3 |") { - t.Fatalf("expected lower column hint row to sit closest to grid, got:\n%s", snippet) - } -} - -func TestDetectGameType(t *testing.T) { - gameType, err := DetectGameType(sudoku.Model{}) - if err != nil { - t.Fatal(err) - } - if gameType != "Sudoku" { - t.Fatalf("gameType = %q, want %q", gameType, "Sudoku") - } - - gameType, err = DetectGameType(&wordsearch.Model{}) - if err != nil { - t.Fatal(err) - } - if gameType != "Word Search" { - t.Fatalf("gameType = %q, want %q", gameType, "Word Search") - } - - _, err = DetectGameType(testUnknownGamer{}) - if err == nil { - t.Fatal("expected unknown gamer detection error") - } -} - -func TestBuildDocument(t *testing.T) { - doc := BuildDocument(DocumentConfig{ - Version: "v-test", - Category: "Sudoku", - ModeSelection: "mixed modes", - Count: 2, - Seed: "seed-1", - GeneratedAt: time.Date(2026, 2, 22, 10, 0, 0, 0, time.UTC), - }, []PuzzleSection{ - {Index: 1, GameType: "Sudoku", Mode: "Easy", Body: "body-one"}, - {Index: 2, GameType: "Sudoku", Mode: "Hard", Body: "body-two"}, - }) - - if !strings.Contains(doc, "# PuzzleTea Export") { - t.Fatal("expected export title in markdown document") - } - if !strings.Contains(doc, "Version: v-test") { - t.Fatal("expected version metadata in markdown document") - } - if matched := regexp.MustCompile(`## [a-z]+-[a-z]+ - 1`).MatchString(doc); !matched { - t.Fatal("expected first puzzle heading in adjective-noun pattern") - } - if matched := regexp.MustCompile(`\n---\n\n## [a-z]+-[a-z]+ - 2`).MatchString(doc); !matched { - t.Fatal("expected puzzle separator and second heading in adjective-noun pattern") - } - if strings.Contains(doc, "## Puzzle 1 - Sudoku (Easy)") { - t.Fatal("expected legacy puzzle heading format to be removed") - } -} - -type testUnknownGamer struct{} - -func (testUnknownGamer) GetDebugInfo() string { return "" } -func (testUnknownGamer) GetFullHelp() [][]key.Binding { return nil } -func (testUnknownGamer) GetSave() ([]byte, error) { return nil, nil } -func (testUnknownGamer) IsSolved() bool { return false } -func (testUnknownGamer) Reset() game.Gamer { return testUnknownGamer{} } -func (testUnknownGamer) SetTitle(string) game.Gamer { return testUnknownGamer{} } -func (testUnknownGamer) Init() tea.Cmd { return nil } -func (testUnknownGamer) View() string { return "" } -func (testUnknownGamer) Update(tea.Msg) (game.Gamer, tea.Cmd) { return testUnknownGamer{}, nil } diff --git a/markdownexport/support.go b/markdownexport/support.go deleted file mode 100644 index 355ed01..0000000 --- a/markdownexport/support.go +++ /dev/null @@ -1,26 +0,0 @@ -package markdownexport - -import ( - "errors" - "strings" -) - -var ErrUnsupportedGame = errors.New("game does not support markdown export") - -func SupportsGameType(gameType string) bool { - switch normalizeGameType(gameType) { - case "hashiwokakero", "hitori", "nonogram", "nurikabe", "shikaku", "sudoku", "takuzu", "word search", "wordsearch": - return true - case "lights out", "lights-out", "lightsout", "lights": - return false - default: - return false - } -} - -func normalizeGameType(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/nonogram/PrintAdapter.go b/nonogram/PrintAdapter.go new file mode 100644 index 0000000..9aae2a0 --- /dev/null +++ b/nonogram/PrintAdapter.go @@ -0,0 +1,87 @@ +package nonogram + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/go-pdf/fpdf" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Nonogram" } +func (printAdapter) Aliases() []string { return []string{"nonogram"} } + +func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { + var data Save + if err := json.Unmarshal(save, &data); err != nil { + return "", fmt.Errorf("decode nonogram save: %w", err) + } + + width := data.Width + height := data.Height + if width <= 0 { + width = len(data.ColHints) + } + if height <= 0 { + height = len(data.RowHints) + } + if width <= 0 || height <= 0 { + return "### Puzzle Grid with Integrated Hints\n\n_(empty grid)_", nil + } + + rowHints := game.NormalizeNonogramHints(data.RowHints, height) + colHints := game.NormalizeNonogramHints(data.ColHints, width) + rowHintCols := game.MaxNonogramHintLen(rowHints) + colHintRows := game.MaxNonogramHintLen(colHints) + if rowHintCols < 1 { + rowHintCols = 1 + } + if colHintRows < 1 { + colHintRows = 1 + } + + var b strings.Builder + b.WriteString("### Puzzle Grid with Integrated Hints\n\n") + b.WriteString(game.RenderNonogramTable(rowHints, colHints, width, height, rowHintCols, colHintRows)) + b.WriteString("\n\n") + b.WriteString("Row hints are right-aligned beside each row. ") + b.WriteString("Column hints are stacked above each column and bottom-aligned to the grid.") + return b.String(), nil +} + +func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { + payload, err := pdfexport.ParseNonogramPrintData(save) + if err != nil { + return nil, err + } + if !game.IsNilPrintPayload(payload) { + return payload, nil + } + if strings.TrimSpace(snippet) == "" { + return nil, nil + } + + nonogram, _, err := pdfexport.ParsePrintableFromSnippet("Nonogram", snippet) + if err != nil { + return nil, err + } + return nonogram, nil +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.NonogramData: + pdfexport.RenderNonogramPage(pdf, data) + case *pdfexport.GridTable: + pdfexport.RenderGridTablePage(pdf, data) + } + return nil +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/nurikabe/PrintAdapter.go b/nurikabe/PrintAdapter.go new file mode 100644 index 0000000..a13a5ac --- /dev/null +++ b/nurikabe/PrintAdapter.go @@ -0,0 +1,74 @@ +package nurikabe + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/go-pdf/fpdf" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Nurikabe" } +func (printAdapter) Aliases() []string { return []string{"nurikabe"} } + +func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { + var data Save + if err := json.Unmarshal(save, &data); err != nil { + return "", fmt.Errorf("decode nurikabe save: %w", err) + } + + clues := game.ParseCSVIntGrid(data.Clues, data.Width, data.Height) + cells := game.MakeStringGrid(data.Width, data.Height, ".") + for y := range len(cells) { + for x := range len(cells[y]) { + if y < len(clues) && x < len(clues[y]) && clues[y][x] > 0 { + cells[y][x] = strconv.Itoa(clues[y][x]) + } + } + } + + var b strings.Builder + b.WriteString("### Clue Grid\n\n") + b.WriteString(game.RenderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Goal: build one connected sea while each numbered island has the exact size of its clue.") + return b.String(), nil +} + +func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { + payload, err := pdfexport.ParseNurikabePrintData(save) + if err != nil { + return nil, err + } + if !game.IsNilPrintPayload(payload) { + return payload, nil + } + if strings.TrimSpace(snippet) == "" { + return nil, nil + } + + _, table, err := pdfexport.ParsePrintableFromSnippet("Nurikabe", snippet) + if err != nil { + return nil, err + } + return table, nil +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.NurikabeData: + pdfexport.RenderNurikabePage(pdf, data) + case *pdfexport.GridTable: + pdfexport.RenderGridTablePage(pdf, data) + } + return nil +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/pdfexport/jsonl.go b/pdfexport/jsonl.go index e32b48d..fe06440 100644 --- a/pdfexport/jsonl.go +++ b/pdfexport/jsonl.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/FelineStateMachine/puzzletea/game" ) const ExportSchemaV1 = "puzzletea.export.v1" @@ -112,16 +114,19 @@ func ParseJSONLFile(path string) (PackDocument, error) { Body: record.Puzzle.Snippet, SaveData: append([]byte(nil), record.Puzzle.Save...), } - hydratePuzzlePrintData(&p) - - if !hasRenderablePrintData(p) && strings.TrimSpace(record.Puzzle.Snippet) != "" { - nonogram, table, err := ParsePrintableFromSnippet(category, record.Puzzle.Snippet) - if err != nil { - return PackDocument{}, fmt.Errorf("%s:%d: parse printable snippet: %w", path, lineNo, err) - } - p.Nonogram = nonogram - p.Table = table + + adapter, ok := game.LookupPrintAdapter(category) + if !ok { + continue + } + payload, err := adapter.BuildPDFPayload(p.SaveData, record.Puzzle.Snippet) + 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) } @@ -132,10 +137,6 @@ func ParseJSONLFile(path string) (PackDocument, error) { if !seenAny { return PackDocument{}, fmt.Errorf("%s: input jsonl is empty", path) } - if len(puzzles) == 0 { - return PackDocument{}, fmt.Errorf("%s: no puzzle records found", path) - } - if doc.Metadata.Count == 0 { doc.Metadata.Count = len(puzzles) } @@ -159,15 +160,3 @@ func ParsePrintableFromSnippet(category, snippet string) (*NonogramData, *GridTa } return nil, table, nil } - -func hasRenderablePrintData(p Puzzle) bool { - return p.Nonogram != nil || - p.Nurikabe != nil || - p.Shikaku != nil || - p.Hashi != nil || - p.Hitori != nil || - p.Takuzu != nil || - p.Sudoku != nil || - p.WordSearch != nil || - p.Table != nil -} diff --git a/pdfexport/jsonl_test.go b/pdfexport/jsonl_test.go index a048fc3..6ce2f5f 100644 --- a/pdfexport/jsonl_test.go +++ b/pdfexport/jsonl_test.go @@ -4,8 +4,13 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" + "sync" "testing" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/go-pdf/fpdf" ) func TestParseJSONLFile(t *testing.T) { @@ -42,7 +47,8 @@ func TestParseJSONLFile(t *testing.T) { if got, want := len(doc.Puzzles), 1; got != want { t.Fatalf("puzzles = %d, want %d", got, want) } - if doc.Puzzles[0].Nonogram == nil { + payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData) + if !ok || payload == nil { t.Fatal("expected nonogram print payload from snippet fallback") } } @@ -75,7 +81,8 @@ func TestParseJSONLFileHydratesNonogramFromSaveWithoutSnippet(t *testing.T) { if got, want := len(doc.Puzzles), 1; got != want { t.Fatalf("puzzles = %d, want %d", got, want) } - if doc.Puzzles[0].Nonogram == nil { + payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData) + if !ok || payload == nil { t.Fatal("expected nonogram print payload from save hydration") } } @@ -124,10 +131,11 @@ func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) { if got, want := len(doc.Puzzles), 1; got != want { t.Fatalf("puzzles = %d, want %d", got, want) } - if doc.Puzzles[0].Takuzu == nil { + payload, ok := doc.Puzzles[0].PrintPayload.(*TakuzuData) + if !ok || payload == nil { t.Fatal("expected takuzu print payload from save hydration") } - if got, want := doc.Puzzles[0].Takuzu.Givens[1][1], ""; got != want { + if got, want := payload.Givens[1][1], ""; got != want { t.Fatalf("takuzu row 1 col 1 = %q, want empty", got) } } @@ -160,10 +168,11 @@ func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) { if got, want := len(doc.Puzzles), 1; got != want { t.Fatalf("puzzles = %d, want %d", got, want) } - if doc.Puzzles[0].Sudoku == nil { + payload, ok := doc.Puzzles[0].PrintPayload.(*SudokuData) + if !ok || payload == nil { t.Fatal("expected sudoku print payload from save hydration") } - if got, want := doc.Puzzles[0].Sudoku.Givens[0][0], 5; got != want { + if got, want := payload.Givens[0][0], 5; got != want { t.Fatalf("sudoku givens[0][0] = %d, want %d", got, want) } } @@ -196,13 +205,14 @@ func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) { if got, want := len(doc.Puzzles), 1; got != want { t.Fatalf("puzzles = %d, want %d", got, want) } - if doc.Puzzles[0].WordSearch == nil { + 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(doc.Puzzles[0].WordSearch.Words), 2; got != want { + if got, want := len(payload.Words), 2; got != want { t.Fatalf("word count = %d, want %d", got, want) } - if got, want := doc.Puzzles[0].WordSearch.Words[0], "ACE"; got != want { + if got, want := payload.Words[0], "ACE"; got != want { t.Fatalf("first word = %q, want %q", got, want) } } @@ -235,10 +245,11 @@ func TestParseJSONLFileHydratesNurikabeFromSave(t *testing.T) { if got, want := len(doc.Puzzles), 1; got != want { t.Fatalf("puzzles = %d, want %d", got, want) } - if doc.Puzzles[0].Nurikabe == nil { + payload, ok := doc.Puzzles[0].PrintPayload.(*NurikabeData) + if !ok || payload == nil { t.Fatal("expected nurikabe print payload from save hydration") } - if got, want := doc.Puzzles[0].Nurikabe.Clues[1][1], 2; got != want { + if got, want := payload.Clues[1][1], 2; got != want { t.Fatalf("nurikabe clues[1][1] = %d, want %d", got, want) } } @@ -271,10 +282,11 @@ func TestParseJSONLFileHydratesShikakuFromSave(t *testing.T) { if got, want := len(doc.Puzzles), 1; got != want { t.Fatalf("puzzles = %d, want %d", got, want) } - if doc.Puzzles[0].Shikaku == nil { + payload, ok := doc.Puzzles[0].PrintPayload.(*ShikakuData) + if !ok || payload == nil { t.Fatal("expected shikaku print payload from save hydration") } - if got, want := doc.Puzzles[0].Shikaku.Clues[1][1], 4; got != want { + if got, want := payload.Clues[1][1], 4; got != want { t.Fatalf("shikaku clues[1][1] = %d, want %d", got, want) } } @@ -307,10 +319,11 @@ func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) { if got, want := len(doc.Puzzles), 1; got != want { t.Fatalf("puzzles = %d, want %d", got, want) } - if doc.Puzzles[0].Hashi == nil { + payload, ok := doc.Puzzles[0].PrintPayload.(*HashiData) + if !ok || payload == nil { t.Fatal("expected hashi print payload from save hydration") } - if got, want := len(doc.Puzzles[0].Hashi.Islands), 2; got != want { + if got, want := len(payload.Islands), 2; got != want { t.Fatalf("hashi island count = %d, want %d", got, want) } } @@ -344,12 +357,13 @@ func TestParseJSONLFileIgnoresMalformedSnippetWhenSaveHydrated(t *testing.T) { if got, want := len(doc.Puzzles), 1; got != want { t.Fatalf("puzzles = %d, want %d", got, want) } - if doc.Puzzles[0].Sudoku == nil { + payload, ok := doc.Puzzles[0].PrintPayload.(*SudokuData) + if !ok || payload == nil { t.Fatal("expected sudoku print payload from save hydration") } } -func TestParseJSONLFileFailsMalformedSnippetWithoutRenderablePayload(t *testing.T) { +func TestParseJSONLFileSilentlySkipsUnsupportedGame(t *testing.T) { path := filepath.Join(t.TempDir(), "lights-malformed-snippet.jsonl") record := JSONLRecord{ Schema: ExportSchemaV1, @@ -371,12 +385,12 @@ func TestParseJSONLFileFailsMalformedSnippetWithoutRenderablePayload(t *testing. } writeSingleJSONLRecord(t, path, record) - _, err := ParseJSONLFile(path) - if err == nil { - t.Fatal("expected parse error when neither save nor snippet produces renderable payload") + doc, err := ParseJSONLFile(path) + if err != nil { + t.Fatalf("expected silent no-op for unsupported game, got: %v", err) } - if !strings.Contains(err.Error(), "parse printable snippet") { - t.Fatalf("unexpected error: %v", err) + if got := len(doc.Puzzles); got != 0 { + t.Fatalf("puzzles = %d, want 0", got) } } @@ -390,3 +404,97 @@ func writeSingleJSONLRecord(t *testing.T, path string, record JSONLRecord) { t.Fatal(err) } } + +var registerJSONLAdaptersOnce sync.Once + +func init() { + ensureJSONLTestAdapters() +} + +func ensureJSONLTestAdapters() { + registerJSONLAdaptersOnce.Do(func() { + register := func(category string, build func(save []byte, snippet string) (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(category string, parse func(save []byte) (any, error)) func([]byte, string) (any, error) { + return func(save []byte, snippet string) (any, error) { + payload, err := parse(save) + if err != nil { + return nil, err + } + if !isNilAny(payload) { + return payload, nil + } + if strings.TrimSpace(snippet) == "" { + return nil, nil + } + + nonogram, table, err := ParsePrintableFromSnippet(category, snippet) + if err != nil { + return nil, err + } + if nonogram != nil { + return nonogram, nil + } + return table, nil + } +} + +func isNilAny(v any) bool { + if v == nil { + return true + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return rv.IsNil() + default: + return false + } +} + +type jsonlTestAdapter struct { + category string + aliases []string + build func(save []byte, snippet string) (any, error) +} + +func (a jsonlTestAdapter) CanonicalGameType() string { return a.category } +func (a jsonlTestAdapter) Aliases() []string { return a.aliases } +func (a jsonlTestAdapter) RenderMarkdownSnippet([]byte) (string, error) { + return "", nil +} + +func (a jsonlTestAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { + return a.build(save, snippet) +} +func (a jsonlTestAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil } diff --git a/pdfexport/parse.go b/pdfexport/parse.go index 0550b17..eb99384 100644 --- a/pdfexport/parse.go +++ b/pdfexport/parse.go @@ -178,7 +178,7 @@ func parsePuzzleSection(section []string, path string, startLine int, meta PackM if err != nil { return Puzzle{}, err } - p.Nonogram = nonogram + p.PrintPayload = nonogram return p, nil } @@ -186,7 +186,7 @@ func parsePuzzleSection(section []string, path string, startLine int, meta PackM if err != nil { return Puzzle{}, err } - p.Table = table + p.PrintPayload = table return p, nil } diff --git a/pdfexport/parse_test.go b/pdfexport/parse_test.go index 72146cb..b1451fa 100644 --- a/pdfexport/parse_test.go +++ b/pdfexport/parse_test.go @@ -21,21 +21,22 @@ func TestParseMarkdownNonogram(t *testing.T) { } p := doc.Puzzles[0] - if p.Nonogram == nil { + nonogram, ok := p.PrintPayload.(*NonogramData) + if !ok || nonogram == nil { t.Fatal("expected parsed nonogram data") } - if p.Nonogram.Width != 2 || p.Nonogram.Height != 2 { - t.Fatalf("nonogram size = %dx%d, want 2x2", p.Nonogram.Width, p.Nonogram.Height) + if nonogram.Width != 2 || nonogram.Height != 2 { + t.Fatalf("nonogram size = %dx%d, want 2x2", nonogram.Width, nonogram.Height) } - if got, want := p.Nonogram.RowHints[0][0], 1; got != want { + if got, want := nonogram.RowHints[0][0], 1; got != want { t.Fatalf("first row first hint = %d, want %d", got, want) } - if got, want := p.Nonogram.ColHints[0][0], 1; 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 := p.Nonogram.Grid[0][0], " "; got != want { + if got, want := nonogram.Grid[0][0], " "; got != want { t.Fatalf("grid dot replacement = %q, want %q", got, want) } } @@ -80,20 +81,18 @@ func TestParseMarkdownTakuzuTable(t *testing.T) { } p := doc.Puzzles[0] - if p.Nonogram != nil { - t.Fatal("expected nonogram data to be nil for takuzu") - } - if p.Table == nil { + table, ok := p.PrintPayload.(*GridTable) + if !ok || table == nil { t.Fatal("expected parsed grid table for takuzu") } - if !p.Table.HasHeaderRow { + if !table.HasHeaderRow { t.Fatal("expected takuzu table to detect a header row") } - if !p.Table.HasHeaderCol { + if !table.HasHeaderCol { t.Fatal("expected takuzu table to detect a header column") } - if got, want := p.Table.Rows[1][1], "."; got != want { + if got, want := table.Rows[1][1], "."; got != want { t.Fatalf("table cell = %q, want %q", got, want) } } diff --git a/pdfexport/printdata.go b/pdfexport/printdata.go index 1ef39b1..91a12e1 100644 --- a/pdfexport/printdata.go +++ b/pdfexport/printdata.go @@ -399,86 +399,6 @@ func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) { }, nil } -func hydratePuzzlePrintData(p *Puzzle) { - if p == nil { - return - } - - switch normalizePrintableCategory(p.Category) { - case "nonogram": - if p.Nonogram != nil { - return - } - nonogram, err := ParseNonogramPrintData(p.SaveData) - if err == nil { - p.Nonogram = nonogram - } - case "hashiwokakero": - if p.Hashi != nil { - return - } - hashi, err := ParseHashiPrintData(p.SaveData) - if err == nil { - p.Hashi = hashi - } - case "nurikabe": - if p.Nurikabe != nil { - return - } - nurikabe, err := ParseNurikabePrintData(p.SaveData) - if err == nil { - p.Nurikabe = nurikabe - } - case "shikaku": - if p.Shikaku != nil { - return - } - shikaku, err := ParseShikakuPrintData(p.SaveData) - if err == nil { - p.Shikaku = shikaku - } - case "hitori": - if p.Hitori != nil { - return - } - hitori, err := ParseHitoriPrintData(p.SaveData) - if err == nil { - p.Hitori = hitori - } - case "takuzu": - if p.Takuzu != nil { - return - } - takuzu, err := ParseTakuzuPrintData(p.SaveData) - if err == nil { - p.Takuzu = takuzu - } - case "sudoku": - if p.Sudoku != nil { - return - } - sudoku, err := ParseSudokuPrintData(p.SaveData) - if err == nil { - p.Sudoku = sudoku - } - case "wordsearch": - if p.WordSearch != nil { - return - } - wordSearch, err := ParseWordSearchPrintData(p.SaveData) - if err == nil { - p.WordSearch = wordSearch - } - } -} - -func normalizePrintableCategory(category string) string { - category = strings.ToLower(strings.TrimSpace(category)) - category = strings.ReplaceAll(category, "-", "") - category = strings.Join(strings.Fields(category), "") - return category -} - func isSudokuCellInBounds(x, y int) bool { return x >= 0 && x < 9 && y >= 0 && y < 9 } diff --git a/pdfexport/printdata_test.go b/pdfexport/printdata_test.go index dd45383..f6caf81 100644 --- a/pdfexport/printdata_test.go +++ b/pdfexport/printdata_test.go @@ -181,83 +181,3 @@ func TestParseTakuzuPrintData(t *testing.T) { t.Fatalf("row 3 col 1 = %q, want empty", got) } } - -func TestHydratePuzzlePrintDataForTakuzu(t *testing.T) { - p := Puzzle{ - Category: "Takuzu", - SaveData: []byte(`{"size":2,"state":"01\n10","provided":"##\n#."}`), - } - - hydratePuzzlePrintData(&p) - if p.Takuzu == nil { - t.Fatal("expected takuzu payload from save hydration") - } - if got, want := p.Takuzu.Givens[0][1], "1"; got != want { - t.Fatalf("row 0 col 1 = %q, want %q", got, want) - } - if got, want := p.Takuzu.Givens[1][1], ""; got != want { - t.Fatalf("row 1 col 1 = %q, want empty", got) - } -} - -func TestHydratePuzzlePrintDataForNonogram(t *testing.T) { - p := Puzzle{ - Category: "Nonogram", - SaveData: []byte(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`), - } - - hydratePuzzlePrintData(&p) - if p.Nonogram == nil { - t.Fatal("expected nonogram payload from save hydration") - } - if got, want := p.Nonogram.Width, 2; got != want { - t.Fatalf("width = %d, want %d", got, want) - } - if got, want := p.Nonogram.Height, 2; got != want { - t.Fatalf("height = %d, want %d", got, want) - } -} - -func TestHydratePuzzlePrintDataForNurikabeAndShikaku(t *testing.T) { - nurikabePuzzle := Puzzle{ - Category: "Nurikabe", - SaveData: []byte(`{"width":2,"height":2,"clues":"1,0\n0,2","marks":"??\n??"}`), - } - hydratePuzzlePrintData(&nurikabePuzzle) - if nurikabePuzzle.Nurikabe == nil { - t.Fatal("expected nurikabe payload from save hydration") - } - if got, want := nurikabePuzzle.Nurikabe.Clues[1][1], 2; got != want { - t.Fatalf("nurikabe row 1 col 1 = %d, want %d", got, want) - } - - shikakuPuzzle := Puzzle{ - Category: "Shikaku", - SaveData: []byte(`{"width":2,"height":2,"clues":[{"x":0,"y":0,"value":1},{"x":1,"y":1,"value":3}]}`), - } - hydratePuzzlePrintData(&shikakuPuzzle) - if shikakuPuzzle.Shikaku == nil { - t.Fatal("expected shikaku payload from save hydration") - } - if got, want := shikakuPuzzle.Shikaku.Clues[1][1], 3; got != want { - t.Fatalf("shikaku row 1 col 1 = %d, want %d", got, want) - } -} - -func TestHydratePuzzlePrintDataForHashi(t *testing.T) { - puzzle := Puzzle{ - Category: "Hashiwokakero", - SaveData: []byte(`{"width":5,"height":5,"islands":[{"x":0,"y":0,"required":2},{"x":4,"y":4,"required":3}],"bridges":[]}`), - } - - hydratePuzzlePrintData(&puzzle) - if puzzle.Hashi == nil { - t.Fatal("expected hashi payload from save hydration") - } - if got, want := len(puzzle.Hashi.Islands), 2; got != want { - t.Fatalf("island count = %d, want %d", got, want) - } - if got, want := puzzle.Hashi.Islands[1].Required, 3; got != want { - t.Fatalf("island[1].required = %d, want %d", got, want) - } -} diff --git a/pdfexport/render.go b/pdfexport/render.go index 06002a5..3a1f889 100644 --- a/pdfexport/render.go +++ b/pdfexport/render.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/FelineStateMachine/puzzletea/game" "github.com/go-pdf/fpdf" ) @@ -17,8 +18,10 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend if strings.TrimSpace(outputPath) == "" { return fmt.Errorf("output path is required") } - if len(puzzles) == 0 { - return fmt.Errorf("no puzzles to render") + + printablePuzzles := filterPrintablePuzzles(puzzles) + if len(printablePuzzles) == 0 { + return nil } if cfg.GeneratedAt.IsZero() { @@ -68,9 +71,9 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend }) coverColor := resolveCoverColor(cfg) - renderCoverPage(pdf, puzzles, cfg) - renderTitlePage(pdf, docs, puzzles, cfg) - for _, puzzle := range puzzles { + renderCoverPage(pdf, printablePuzzles, cfg) + renderTitlePage(pdf, docs, printablePuzzles, cfg) + for _, puzzle := range printablePuzzles { renderPuzzlePage(pdf, puzzle) } @@ -108,6 +111,20 @@ func saddleStitchPadCount(totalPages int) int { 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() } @@ -239,9 +256,16 @@ func titlePageSourceTableWhitespace(maxY, sourceStartY float64, docCount int) fl } func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) { + if game.IsNilPrintPayload(puzzle.PrintPayload) { + return + } + adapter, ok := game.LookupPrintAdapter(puzzle.Category) + if !ok { + return + } + pdf.AddPage() - pageW, pageH := pdf.GetPageSize() - hydratePuzzlePrintData(&puzzle) + pageW, _ := pdf.GetPageSize() setPuzzleTitleStyle(pdf) pdf.SetXY(0, 10) @@ -256,44 +280,7 @@ func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) { } subtitle := strings.Join(subtitleParts, " | ") pdf.CellFormat(pageW, 5, subtitle, "", 0, "C", false, 0, "") - - if puzzle.Nonogram != nil { - renderNonogramPage(pdf, puzzle.Nonogram) - return - } - if puzzle.Nurikabe != nil { - renderNurikabePage(pdf, puzzle.Nurikabe) - return - } - if puzzle.Shikaku != nil { - renderShikakuPage(pdf, puzzle.Shikaku) - return - } - if puzzle.Hashi != nil { - renderHashiPage(pdf, puzzle.Hashi) - return - } - if puzzle.Hitori != nil { - renderHitoriPage(pdf, puzzle.Hitori) - return - } - if puzzle.Takuzu != nil { - renderTakuzuPage(pdf, puzzle.Takuzu) - return - } - if puzzle.Sudoku != nil { - renderSudokuPage(pdf, puzzle.Sudoku) - return - } - if puzzle.WordSearch != nil { - renderWordSearchPage(pdf, puzzle.WordSearch) - return - } - if puzzle.Table != nil { - renderGridTablePage(pdf, puzzle.Table) - return - } - renderFallbackPage(pdf, puzzle, pageH) + _ = adapter.RenderPDFBody(pdf, puzzle.PrintPayload) } func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { @@ -856,74 +843,6 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { pdf.Rect(startX, startY, blockW, blockH, "D") } -func renderFallbackPage(pdf *fpdf.Fpdf, puzzle Puzzle, pageH float64) { - pageW, _ := pdf.GetPageSize() - area := puzzleBoardRect(pageW, pageH, pdf.PageNo(), 0) - availW := area.w - availH := area.h - - lines := sanitizeBody(puzzle.Body) - fontSize := 9.2 - lineHeight := 4.8 - - pdf.SetFont("Courier", "", fontSize) - wrapped := make([]string, 0, len(lines)) - for _, line := range lines { - chunks := pdf.SplitLines([]byte(line), availW) - if len(chunks) == 0 { - wrapped = append(wrapped, "") - continue - } - for _, raw := range chunks { - wrapped = append(wrapped, string(raw)) - } - } - - if total := float64(len(wrapped)) * lineHeight; total > availH && len(wrapped) > 0 { - maxLines := int(availH / lineHeight) - if maxLines < len(wrapped) { - wrapped = append(wrapped[:max(0, maxLines-1)], "...") - } - } - - blockH := float64(len(wrapped)) * lineHeight - startY := area.y + (availH-blockH)/2 - - pdf.SetTextColor(50, 50, 50) - y := startY - for _, line := range wrapped { - w := pdf.GetStringWidth(line) - x := (pageW - w) / 2 - if x < area.x { - x = area.x - } - pdf.SetXY(x, y) - pdf.CellFormat(availW, lineHeight, line, "", 0, "L", false, 0, "") - y += lineHeight - } -} - -func sanitizeBody(body string) []string { - lines := strings.Split(body, "\n") - cleaned := make([]string, 0, len(lines)) - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" || trimmed == "---" { - cleaned = append(cleaned, "") - continue - } - if strings.HasPrefix(trimmed, "### ") { - cleaned = append(cleaned, strings.TrimSpace(strings.TrimPrefix(trimmed, "### "))) - continue - } - if strings.HasPrefix(trimmed, "|") { - line = strings.ReplaceAll(line, ".", " ") - } - cleaned = append(cleaned, line) - } - return cleaned -} - func drawCellText(pdf *fpdf.Fpdf, x, y, w, h float64, text string, dim bool) { if strings.TrimSpace(text) == "" { return diff --git a/pdfexport/render_public.go b/pdfexport/render_public.go new file mode 100644 index 0000000..17075ee --- /dev/null +++ b/pdfexport/render_public.go @@ -0,0 +1,39 @@ +package pdfexport + +import "github.com/go-pdf/fpdf" + +func RenderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { + renderNonogramPage(pdf, data) +} + +func RenderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) { + renderNurikabePage(pdf, data) +} + +func RenderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) { + renderShikakuPage(pdf, data) +} + +func RenderHashiPage(pdf *fpdf.Fpdf, data *HashiData) { + renderHashiPage(pdf, data) +} + +func RenderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) { + renderHitoriPage(pdf, data) +} + +func RenderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) { + renderTakuzuPage(pdf, data) +} + +func RenderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) { + renderSudokuPage(pdf, data) +} + +func RenderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) { + renderWordSearchPage(pdf, data) +} + +func RenderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { + renderGridTablePage(pdf, table) +} diff --git a/pdfexport/types.go b/pdfexport/types.go index c1bc194..cae2c15 100644 --- a/pdfexport/types.go +++ b/pdfexport/types.go @@ -36,15 +36,7 @@ type Puzzle struct { Index int Body string SaveData []byte - Nonogram *NonogramData - Nurikabe *NurikabeData - Shikaku *ShikakuData - Hashi *HashiData - Hitori *HitoriData - Takuzu *TakuzuData - Sudoku *SudokuData - WordSearch *WordSearchData - Table *GridTable + PrintPayload any DifficultyScore float64 DifficultyConfidence DifficultyConfidence DifficultySource string diff --git a/shikaku/PrintAdapter.go b/shikaku/PrintAdapter.go new file mode 100644 index 0000000..be649a6 --- /dev/null +++ b/shikaku/PrintAdapter.go @@ -0,0 +1,75 @@ +package shikaku + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/go-pdf/fpdf" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Shikaku" } +func (printAdapter) Aliases() []string { return []string{"shikaku"} } + +func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { + var data Save + if err := json.Unmarshal(save, &data); err != nil { + return "", fmt.Errorf("decode shikaku save: %w", err) + } + + cells := game.MakeStringGrid(data.Width, data.Height, ".") + for _, clue := range data.Clues { + if clue.Y < 0 || clue.Y >= len(cells) { + continue + } + if clue.X < 0 || clue.X >= len(cells[clue.Y]) { + continue + } + cells[clue.Y][clue.X] = strconv.Itoa(clue.Value) + } + + var b strings.Builder + b.WriteString("### Clue Grid\n\n") + b.WriteString(game.RenderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Goal: partition the grid into rectangles so each rectangle contains one clue and its area matches that clue.") + return b.String(), nil +} + +func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { + payload, err := pdfexport.ParseShikakuPrintData(save) + if err != nil { + return nil, err + } + if !game.IsNilPrintPayload(payload) { + return payload, nil + } + if strings.TrimSpace(snippet) == "" { + return nil, nil + } + + _, table, err := pdfexport.ParsePrintableFromSnippet("Shikaku", snippet) + if err != nil { + return nil, err + } + return table, nil +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.ShikakuData: + pdfexport.RenderShikakuPage(pdf, data) + case *pdfexport.GridTable: + pdfexport.RenderGridTablePage(pdf, data) + } + return nil +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/sudoku/PrintAdapter.go b/sudoku/PrintAdapter.go new file mode 100644 index 0000000..df1a174 --- /dev/null +++ b/sudoku/PrintAdapter.go @@ -0,0 +1,74 @@ +package sudoku + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/go-pdf/fpdf" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Sudoku" } +func (printAdapter) Aliases() []string { return []string{"sudoku"} } + +func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { + var data Save + if err := json.Unmarshal(save, &data); err != nil { + return "", fmt.Errorf("decode sudoku save: %w", err) + } + + cells := game.MakeStringGrid(9, 9, ".") + for _, provided := range data.Provided { + if provided.Y < 0 || provided.Y >= len(cells) { + continue + } + if provided.X < 0 || provided.X >= len(cells[provided.Y]) { + continue + } + cells[provided.Y][provided.X] = fmt.Sprintf("%d", provided.V) + } + + var b strings.Builder + b.WriteString("### Given Grid\n\n") + b.WriteString(game.RenderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Goal: fill each row, column, and 3x3 box with digits 1-9 exactly once.") + return b.String(), nil +} + +func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { + payload, err := pdfexport.ParseSudokuPrintData(save) + if err != nil { + return nil, err + } + if !game.IsNilPrintPayload(payload) { + return payload, nil + } + if strings.TrimSpace(snippet) == "" { + return nil, nil + } + + _, table, err := pdfexport.ParsePrintableFromSnippet("Sudoku", snippet) + if err != nil { + return nil, err + } + return table, nil +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.SudokuData: + pdfexport.RenderSudokuPage(pdf, data) + case *pdfexport.GridTable: + pdfexport.RenderGridTablePage(pdf, data) + } + return nil +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/takuzu/PrintAdapter.go b/takuzu/PrintAdapter.go new file mode 100644 index 0000000..a97c235 --- /dev/null +++ b/takuzu/PrintAdapter.go @@ -0,0 +1,83 @@ +package takuzu + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/go-pdf/fpdf" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Takuzu" } +func (printAdapter) Aliases() []string { return []string{"takuzu"} } + +func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { + var data Save + if err := json.Unmarshal(save, &data); err != nil { + return "", fmt.Errorf("decode takuzu save: %w", err) + } + + cells := game.MakeStringGrid(data.Size, data.Size, ".") + stateRows := game.SplitLines(data.State) + providedRows := game.SplitLines(data.Provided) + for y := range len(cells) { + for x := range len(cells[y]) { + provided := y < len(providedRows) && x < len(providedRows[y]) && providedRows[y][x] == '#' + if !provided { + continue + } + if y >= len(stateRows) || x >= len(stateRows[y]) { + continue + } + switch stateRows[y][x] { + case '0', '1': + cells[y][x] = string(stateRows[y][x]) + } + } + } + + var b strings.Builder + b.WriteString("### Given Grid\n\n") + b.WriteString(game.RenderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("Goal: fill with 0/1 so no three equal adjacent cells appear, ") + b.WriteString("each row/column has equal 0 and 1 counts, and rows/columns are unique.") + return b.String(), nil +} + +func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { + payload, err := pdfexport.ParseTakuzuPrintData(save) + if err != nil { + return nil, err + } + if !game.IsNilPrintPayload(payload) { + return payload, nil + } + if strings.TrimSpace(snippet) == "" { + return nil, nil + } + + _, table, err := pdfexport.ParsePrintableFromSnippet("Takuzu", snippet) + if err != nil { + return nil, err + } + return table, nil +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.TakuzuData: + pdfexport.RenderTakuzuPage(pdf, data) + case *pdfexport.GridTable: + pdfexport.RenderGridTablePage(pdf, data) + } + return nil +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} diff --git a/wordsearch/PrintAdapter.go b/wordsearch/PrintAdapter.go new file mode 100644 index 0000000..fa0884c --- /dev/null +++ b/wordsearch/PrintAdapter.go @@ -0,0 +1,99 @@ +package wordsearch + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/go-pdf/fpdf" +) + +type printAdapter struct{} + +func (printAdapter) CanonicalGameType() string { return "Word Search" } +func (printAdapter) Aliases() []string { + return []string{"word search", "wordsearch"} +} + +func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { + var data Save + if err := json.Unmarshal(save, &data); err != nil { + return "", fmt.Errorf("decode word search save: %w", err) + } + + rows := game.SplitLines(data.Grid) + height := data.Height + if height < len(rows) { + height = len(rows) + } + width := data.Width + for _, row := range rows { + if n := len([]rune(row)); n > width { + width = n + } + } + + cells := game.MakeStringGrid(width, height, ".") + for y := range len(cells) { + if y >= len(rows) { + continue + } + rowRunes := []rune(rows[y]) + for x := range len(cells[y]) { + if x < len(rowRunes) { + cells[y][x] = string(rowRunes[x]) + } + } + } + + var b strings.Builder + b.WriteString("### Grid\n\n") + b.WriteString(game.RenderGridTable(cells)) + b.WriteString("\n\n") + b.WriteString("### Word List\n\n") + b.WriteString("| # | Word |\n") + b.WriteString("| --- | --- |\n") + for i, word := range data.Words { + fmt.Fprintf(&b, "| %d | %s |\n", i+1, game.EscapeMarkdownCell(word.Text)) + } + if len(data.Words) == 0 { + b.WriteString("| 1 | (none) |\n") + } + b.WriteString("\nGoal: find all listed words in the grid.") + return b.String(), nil +} + +func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { + payload, err := pdfexport.ParseWordSearchPrintData(save) + if err != nil { + return nil, err + } + if !game.IsNilPrintPayload(payload) { + return payload, nil + } + if strings.TrimSpace(snippet) == "" { + return nil, nil + } + + _, table, err := pdfexport.ParsePrintableFromSnippet("Word Search", snippet) + if err != nil { + return nil, err + } + return table, nil +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.WordSearchData: + pdfexport.RenderWordSearchPage(pdf, data) + case *pdfexport.GridTable: + pdfexport.RenderGridTablePage(pdf, data) + } + return nil +} + +func init() { + game.RegisterPrintAdapter(printAdapter{}) +} From 514cb079e95ad8265685b49838290e9adf64285e Mon Sep 17 00:00:00 2001 From: Dami Date: Mon, 23 Feb 2026 13:58:07 -0700 Subject: [PATCH 09/14] fix: surface pdf adapter render errors --- pdfexport/render.go | 15 ++++++---- pdfexport/render_error_test.go | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 pdfexport/render_error_test.go diff --git a/pdfexport/render.go b/pdfexport/render.go index 3a1f889..37449de 100644 --- a/pdfexport/render.go +++ b/pdfexport/render.go @@ -74,7 +74,9 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend renderCoverPage(pdf, printablePuzzles, cfg) renderTitlePage(pdf, docs, printablePuzzles, cfg) for _, puzzle := range printablePuzzles { - renderPuzzlePage(pdf, puzzle) + 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() + 1 // include upcoming back cover @@ -255,13 +257,13 @@ func titlePageSourceTableWhitespace(maxY, sourceStartY float64, docCount int) fl return 0 } -func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) { +func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error { if game.IsNilPrintPayload(puzzle.PrintPayload) { - return + return nil } adapter, ok := game.LookupPrintAdapter(puzzle.Category) if !ok { - return + return nil } pdf.AddPage() @@ -280,7 +282,10 @@ func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) { } subtitle := strings.Join(subtitleParts, " | ") pdf.CellFormat(pageW, 5, subtitle, "", 0, "C", false, 0, "") - _ = adapter.RenderPDFBody(pdf, puzzle.PrintPayload) + if err := adapter.RenderPDFBody(pdf, puzzle.PrintPayload); err != nil { + return err + } + return nil } func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { diff --git a/pdfexport/render_error_test.go b/pdfexport/render_error_test.go new file mode 100644 index 0000000..3dd6916 --- /dev/null +++ b/pdfexport/render_error_test.go @@ -0,0 +1,51 @@ +package pdfexport + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/go-pdf/fpdf" +) + +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) RenderMarkdownSnippet([]byte) (string, error) { + return "", nil +} +func (a renderErrorTestAdapter) BuildPDFPayload([]byte, string) (any, error) { return nil, nil } +func (a renderErrorTestAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { + return errors.New("failed to render body") +} From c81e5f467a56c016f951e7588dc3d5e26abc83e5 Mon Sep 17 00:00:00 2001 From: Dami Date: Mon, 23 Feb 2026 14:29:16 -0700 Subject: [PATCH 10/14] refactoring --- game/print_adapter.go | 3 +- game/print_adapter_test.go | 11 +- game/print_markdown.go | 192 --------------------------------- hashiwokakero/PrintAdapter.go | 52 +-------- hitori/PrintAdapter.go | 73 +------------ nonogram/PrintAdapter.go | 63 +---------- nurikabe/PrintAdapter.go | 50 +-------- pdfexport/jsonl.go | 31 ++---- pdfexport/jsonl_test.go | 107 +++--------------- pdfexport/render_error_test.go | 9 +- shikaku/PrintAdapter.go | 51 +-------- sudoku/PrintAdapter.go | 50 +-------- takuzu/PrintAdapter.go | 59 +--------- wordsearch/PrintAdapter.go | 73 +------------ 14 files changed, 44 insertions(+), 780 deletions(-) delete mode 100644 game/print_markdown.go diff --git a/game/print_adapter.go b/game/print_adapter.go index 4820db4..edebaf0 100644 --- a/game/print_adapter.go +++ b/game/print_adapter.go @@ -10,8 +10,7 @@ import ( type PrintAdapter interface { CanonicalGameType() string Aliases() []string - RenderMarkdownSnippet(save []byte) (string, error) - BuildPDFPayload(save []byte, snippet string) (any, error) + BuildPDFPayload(save []byte) (any, error) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error } diff --git a/game/print_adapter_test.go b/game/print_adapter_test.go index e6c1874..749dac0 100644 --- a/game/print_adapter_test.go +++ b/game/print_adapter_test.go @@ -11,13 +11,10 @@ type testPrintAdapter struct { aliases []string } -func (a testPrintAdapter) CanonicalGameType() string { return a.canonical } -func (a testPrintAdapter) Aliases() []string { return a.aliases } -func (a testPrintAdapter) RenderMarkdownSnippet([]byte) (string, error) { - return "", nil -} -func (a testPrintAdapter) BuildPDFPayload([]byte, string) (any, error) { return nil, nil } -func (a testPrintAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil } +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 diff --git a/game/print_markdown.go b/game/print_markdown.go deleted file mode 100644 index 18b95cd..0000000 --- a/game/print_markdown.go +++ /dev/null @@ -1,192 +0,0 @@ -package game - -import ( - "fmt" - "strconv" - "strings" -) - -func SplitLines(s string) []string { - if strings.TrimSpace(s) == "" { - return []string{} - } - return strings.Split(s, "\n") -} - -func EscapeMarkdownCell(s string) string { - return strings.ReplaceAll(s, "|", "\\|") -} - -func MakeStringGrid(width, height int, fill string) [][]string { - if width <= 0 || height <= 0 { - return [][]string{} - } - - grid := make([][]string, height) - for y := range height { - grid[y] = make([]string, width) - for x := range width { - grid[y][x] = fill - } - } - return grid -} - -func RenderGridTable(cells [][]string) string { - if len(cells) == 0 { - return "_(empty grid)_" - } - - width := 0 - for _, row := range cells { - if len(row) > width { - width = len(row) - } - } - if width <= 0 { - return "_(empty grid)_" - } - - var b strings.Builder - b.WriteString("| |") - for x := range width { - fmt.Fprintf(&b, " %d |", x+1) - } - b.WriteString("\n| --- |") - for range width { - b.WriteString(" --- |") - } - b.WriteString("\n") - - for y := range len(cells) { - fmt.Fprintf(&b, "| %d |", y+1) - for x := range width { - cell := "." - if x < len(cells[y]) && strings.TrimSpace(cells[y][x]) != "" { - cell = cells[y][x] - } - fmt.Fprintf(&b, " %s |", EscapeMarkdownCell(cell)) - } - b.WriteString("\n") - } - - return strings.TrimRight(b.String(), "\n") -} - -func ParseCSVIntGrid(raw string, width, height int) [][]int { - if width <= 0 || height <= 0 { - return [][]int{} - } - - clues := make([][]int, height) - for y := range height { - clues[y] = make([]int, width) - } - - rows := SplitLines(raw) - for y := range min(len(rows), len(clues)) { - parts := strings.Split(rows[y], ",") - for x := range min(len(parts), len(clues[y])) { - val, err := strconv.Atoi(strings.TrimSpace(parts[x])) - if err != nil { - continue - } - clues[y][x] = val - } - } - return clues -} - -func NormalizeNonogramHints(src [][]int, size int) [][]int { - if size <= 0 { - return [][]int{} - } - - hints := make([][]int, size) - for i := range len(hints) { - if i >= len(src) || len(src[i]) == 0 { - hints[i] = []int{0} - continue - } - hints[i] = append([]int(nil), src[i]...) - } - return hints -} - -func MaxNonogramHintLen(hints [][]int) int { - maxLen := 0 - for _, hint := range hints { - if len(hint) > maxLen { - maxLen = len(hint) - } - } - return maxLen -} - -func RenderNonogramTable( - rowHints, colHints [][]int, - width, height, rowHintCols, colHintRows int, -) string { - var b strings.Builder - - b.WriteString("|") - for i := range rowHintCols { - fmt.Fprintf(&b, " R%d |", i+1) - } - for x := range width { - fmt.Fprintf(&b, " C%d |", x+1) - } - b.WriteString("\n|") - for range rowHintCols + width { - b.WriteString(" --- |") - } - b.WriteString("\n") - - for hintRow := range colHintRows { - b.WriteString("|") - for range rowHintCols { - b.WriteString(" . |") - } - for x := range width { - b.WriteString(" ") - b.WriteString(renderColumnHintCell(colHints[x], colHintRows, hintRow)) - b.WriteString(" |") - } - b.WriteString("\n") - } - - for y := range height { - rowHint := rowHints[y] - hintStart := rowHintCols - len(rowHint) - - b.WriteString("|") - for hintCol := range rowHintCols { - if hintCol < hintStart { - b.WriteString(" . |") - continue - } - fmt.Fprintf(&b, " %d |", rowHint[hintCol-hintStart]) - } - for range width { - b.WriteString(" . |") - } - b.WriteString("\n") - } - - return strings.TrimRight(b.String(), "\n") -} - -func renderColumnHintCell(hint []int, depth, row int) string { - start := depth - len(hint) - if row < start { - return "." - } - return strconv.Itoa(hint[row-start]) -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/hashiwokakero/PrintAdapter.go b/hashiwokakero/PrintAdapter.go index b511443..20fbdba 100644 --- a/hashiwokakero/PrintAdapter.go +++ b/hashiwokakero/PrintAdapter.go @@ -1,11 +1,6 @@ package hashiwokakero import ( - "encoding/json" - "fmt" - "strconv" - "strings" - "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/go-pdf/fpdf" @@ -18,57 +13,14 @@ func (printAdapter) Aliases() []string { return []string{"hashi", "hashiwokakero", "hashi wokakero"} } -func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { - var data Save - if err := json.Unmarshal(save, &data); err != nil { - return "", fmt.Errorf("decode hashiwokakero save: %w", err) - } - - cells := game.MakeStringGrid(data.Width, data.Height, ".") - for _, island := range data.Islands { - if island.Y < 0 || island.Y >= len(cells) { - continue - } - if island.X < 0 || island.X >= len(cells[island.Y]) { - continue - } - cells[island.Y][island.X] = strconv.Itoa(island.Required) - } - - var b strings.Builder - b.WriteString("### Grid\n\n") - b.WriteString(game.RenderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Rules: connect numbered islands with horizontal/vertical bridges. ") - b.WriteString("Use up to two bridges per connection and never cross bridges.") - return b.String(), nil -} - -func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { - payload, err := pdfexport.ParseHashiPrintData(save) - if err != nil { - return nil, err - } - if !game.IsNilPrintPayload(payload) { - return payload, nil - } - if strings.TrimSpace(snippet) == "" { - return nil, nil - } - - _, table, err := pdfexport.ParsePrintableFromSnippet("Hashiwokakero", snippet) - if err != nil { - return nil, err - } - return table, nil +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: pdfexport.RenderHashiPage(pdf, data) - case *pdfexport.GridTable: - pdfexport.RenderGridTablePage(pdf, data) } return nil } diff --git a/hitori/PrintAdapter.go b/hitori/PrintAdapter.go index 8be5451..f1faf3c 100644 --- a/hitori/PrintAdapter.go +++ b/hitori/PrintAdapter.go @@ -1,10 +1,6 @@ package hitori import ( - "encoding/json" - "fmt" - "strings" - "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/go-pdf/fpdf" @@ -15,79 +11,14 @@ type printAdapter struct{} func (printAdapter) CanonicalGameType() string { return "Hitori" } func (printAdapter) Aliases() []string { return []string{"hitori"} } -func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { - var data Save - if err := json.Unmarshal(save, &data); err != nil { - return "", fmt.Errorf("decode hitori save: %w", err) - } - - rows := game.SplitLines(data.Numbers) - targetHeight := data.Size - if targetHeight < len(rows) { - targetHeight = len(rows) - } - if targetHeight < 1 { - targetHeight = 1 - } - - cells := make([][]string, 0, targetHeight) - for y := range targetHeight { - row := []rune{} - if y < len(rows) { - row = []rune(rows[y]) - } - targetWidth := data.Size - if targetWidth < len(row) { - targetWidth = len(row) - } - if targetWidth < 1 { - targetWidth = 1 - } - - cellsRow := make([]string, targetWidth) - for x := range len(cellsRow) { - cellsRow[x] = "." - if x < len(row) && row[x] != ' ' { - cellsRow[x] = string(row[x]) - } - } - cells = append(cells, cellsRow) - } - - var b strings.Builder - b.WriteString("### Grid\n\n") - b.WriteString(game.RenderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Goal: shade cells so no row or column has duplicate unshaded values, ") - b.WriteString("shaded cells do not touch orthogonally, and all unshaded cells stay connected.") - return b.String(), nil -} - -func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { - payload, err := pdfexport.ParseHitoriPrintData(save) - if err != nil { - return nil, err - } - if !game.IsNilPrintPayload(payload) { - return payload, nil - } - if strings.TrimSpace(snippet) == "" { - return nil, nil - } - - _, table, err := pdfexport.ParsePrintableFromSnippet("Hitori", snippet) - if err != nil { - return nil, err - } - return table, nil +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: pdfexport.RenderHitoriPage(pdf, data) - case *pdfexport.GridTable: - pdfexport.RenderGridTablePage(pdf, data) } return nil } diff --git a/nonogram/PrintAdapter.go b/nonogram/PrintAdapter.go index 9aae2a0..fa76ef6 100644 --- a/nonogram/PrintAdapter.go +++ b/nonogram/PrintAdapter.go @@ -1,10 +1,6 @@ package nonogram import ( - "encoding/json" - "fmt" - "strings" - "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/go-pdf/fpdf" @@ -15,69 +11,14 @@ type printAdapter struct{} func (printAdapter) CanonicalGameType() string { return "Nonogram" } func (printAdapter) Aliases() []string { return []string{"nonogram"} } -func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { - var data Save - if err := json.Unmarshal(save, &data); err != nil { - return "", fmt.Errorf("decode nonogram save: %w", err) - } - - width := data.Width - height := data.Height - if width <= 0 { - width = len(data.ColHints) - } - if height <= 0 { - height = len(data.RowHints) - } - if width <= 0 || height <= 0 { - return "### Puzzle Grid with Integrated Hints\n\n_(empty grid)_", nil - } - - rowHints := game.NormalizeNonogramHints(data.RowHints, height) - colHints := game.NormalizeNonogramHints(data.ColHints, width) - rowHintCols := game.MaxNonogramHintLen(rowHints) - colHintRows := game.MaxNonogramHintLen(colHints) - if rowHintCols < 1 { - rowHintCols = 1 - } - if colHintRows < 1 { - colHintRows = 1 - } - - var b strings.Builder - b.WriteString("### Puzzle Grid with Integrated Hints\n\n") - b.WriteString(game.RenderNonogramTable(rowHints, colHints, width, height, rowHintCols, colHintRows)) - b.WriteString("\n\n") - b.WriteString("Row hints are right-aligned beside each row. ") - b.WriteString("Column hints are stacked above each column and bottom-aligned to the grid.") - return b.String(), nil -} - -func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { - payload, err := pdfexport.ParseNonogramPrintData(save) - if err != nil { - return nil, err - } - if !game.IsNilPrintPayload(payload) { - return payload, nil - } - if strings.TrimSpace(snippet) == "" { - return nil, nil - } - - nonogram, _, err := pdfexport.ParsePrintableFromSnippet("Nonogram", snippet) - if err != nil { - return nil, err - } - return nonogram, nil +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: pdfexport.RenderNonogramPage(pdf, data) - case *pdfexport.GridTable: - pdfexport.RenderGridTablePage(pdf, data) } return nil } diff --git a/nurikabe/PrintAdapter.go b/nurikabe/PrintAdapter.go index a13a5ac..418fc8c 100644 --- a/nurikabe/PrintAdapter.go +++ b/nurikabe/PrintAdapter.go @@ -1,11 +1,6 @@ package nurikabe import ( - "encoding/json" - "fmt" - "strconv" - "strings" - "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/go-pdf/fpdf" @@ -16,55 +11,14 @@ type printAdapter struct{} func (printAdapter) CanonicalGameType() string { return "Nurikabe" } func (printAdapter) Aliases() []string { return []string{"nurikabe"} } -func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { - var data Save - if err := json.Unmarshal(save, &data); err != nil { - return "", fmt.Errorf("decode nurikabe save: %w", err) - } - - clues := game.ParseCSVIntGrid(data.Clues, data.Width, data.Height) - cells := game.MakeStringGrid(data.Width, data.Height, ".") - for y := range len(cells) { - for x := range len(cells[y]) { - if y < len(clues) && x < len(clues[y]) && clues[y][x] > 0 { - cells[y][x] = strconv.Itoa(clues[y][x]) - } - } - } - - var b strings.Builder - b.WriteString("### Clue Grid\n\n") - b.WriteString(game.RenderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Goal: build one connected sea while each numbered island has the exact size of its clue.") - return b.String(), nil -} - -func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { - payload, err := pdfexport.ParseNurikabePrintData(save) - if err != nil { - return nil, err - } - if !game.IsNilPrintPayload(payload) { - return payload, nil - } - if strings.TrimSpace(snippet) == "" { - return nil, nil - } - - _, table, err := pdfexport.ParsePrintableFromSnippet("Nurikabe", snippet) - if err != nil { - return nil, err - } - return table, nil +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: pdfexport.RenderNurikabePage(pdf, data) - case *pdfexport.GridTable: - pdfexport.RenderGridTablePage(pdf, data) } return nil } diff --git a/pdfexport/jsonl.go b/pdfexport/jsonl.go index fe06440..4066bb9 100644 --- a/pdfexport/jsonl.go +++ b/pdfexport/jsonl.go @@ -29,12 +29,11 @@ type JSONLPackMeta struct { } type JSONLPuzzle struct { - Index int `json:"index"` - Name string `json:"name"` - Game string `json:"game"` - Mode string `json:"mode"` - Save json.RawMessage `json:"save"` - Snippet string `json:"snippet,omitempty"` + 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) { @@ -111,7 +110,6 @@ func ParseJSONLFile(path string) (PackDocument, error) { ModeSelection: mode, Name: record.Puzzle.Name, Index: record.Puzzle.Index, - Body: record.Puzzle.Snippet, SaveData: append([]byte(nil), record.Puzzle.Save...), } @@ -119,7 +117,7 @@ func ParseJSONLFile(path string) (PackDocument, error) { if !ok { continue } - payload, err := adapter.BuildPDFPayload(p.SaveData, record.Puzzle.Snippet) + payload, err := adapter.BuildPDFPayload(p.SaveData) if err != nil { return PackDocument{}, fmt.Errorf("%s:%d: build print payload: %w", path, lineNo, err) } @@ -143,20 +141,3 @@ func ParseJSONLFile(path string) (PackDocument, error) { doc.Puzzles = puzzles return doc, nil } - -func ParsePrintableFromSnippet(category, snippet string) (*NonogramData, *GridTable, error) { - lines := strings.Split(strings.ReplaceAll(strings.ReplaceAll(snippet, "\r\n", "\n"), "\r", "\n"), "\n") - if strings.EqualFold(strings.TrimSpace(category), "nonogram") { - nonogram, err := parseNonogramBody(lines, "snippet", 1) - if err != nil { - return nil, nil, err - } - return nonogram, nil, nil - } - - table, err := parseGridTableBody(lines, "snippet", 1) - if err != nil { - return nil, nil, err - } - return nil, table, nil -} diff --git a/pdfexport/jsonl_test.go b/pdfexport/jsonl_test.go index 6ce2f5f..f0ac270 100644 --- a/pdfexport/jsonl_test.go +++ b/pdfexport/jsonl_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "os" "path/filepath" - "reflect" "strings" "sync" "testing" @@ -30,12 +29,7 @@ func TestParseJSONLFile(t *testing.T) { Name: "ember-newt", Game: "Nonogram", Mode: "Mini", - Save: json.RawMessage(`{"width":2}`), - Snippet: "### Puzzle Grid with Integrated Hints\n\n" + - "| R1 | C1 | C2 |\n" + - "| --- | --- | --- |\n" + - "| . | 1 | 2 |\n" + - "| 1 | . | . |\n", + Save: json.RawMessage(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`), }, } writeSingleJSONLRecord(t, path, record) @@ -49,7 +43,7 @@ func TestParseJSONLFile(t *testing.T) { } payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData) if !ok || payload == nil { - t.Fatal("expected nonogram print payload from snippet fallback") + t.Fatal("expected nonogram print payload from save hydration") } } @@ -328,43 +322,8 @@ func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) { } } -func TestParseJSONLFileIgnoresMalformedSnippetWhenSaveHydrated(t *testing.T) { - path := filepath.Join(t.TempDir(), "sudoku-malformed-snippet.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: "sage-briar", - Game: "Sudoku", - Mode: "Easy", - Save: json.RawMessage(`{"provided":[{"x":0,"y":0,"v":5}]}`), - Snippet: "| bad |\n", - }, - } - writeSingleJSONLRecord(t, path, record) - - doc, err := ParseJSONLFile(path) - if err != nil { - t.Fatalf("expected lenient parse when save hydration succeeds, got: %v", 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") - } -} - func TestParseJSONLFileSilentlySkipsUnsupportedGame(t *testing.T) { - path := filepath.Join(t.TempDir(), "lights-malformed-snippet.jsonl") + path := filepath.Join(t.TempDir(), "lights.jsonl") record := JSONLRecord{ Schema: ExportSchemaV1, Pack: JSONLPackMeta{ @@ -375,12 +334,11 @@ func TestParseJSONLFileSilentlySkipsUnsupportedGame(t *testing.T) { Count: 1, }, Puzzle: JSONLPuzzle{ - Index: 1, - Name: "glow-shore", - Game: "Lights Out", - Mode: "Standard", - Save: json.RawMessage(`{"size":5}`), - Snippet: "| bad |\n", + Index: 1, + Name: "glow-shore", + Game: "Lights Out", + Mode: "Standard", + Save: json.RawMessage(`{"size":5}`), }, } writeSingleJSONLRecord(t, path, record) @@ -413,7 +371,7 @@ func init() { func ensureJSONLTestAdapters() { registerJSONLAdaptersOnce.Do(func() { - register := func(category string, build func(save []byte, snippet string) (any, error), aliases ...string) { + register := func(category string, build func(save []byte) (any, error), aliases ...string) { game.RegisterPrintAdapter(jsonlTestAdapter{ category: category, aliases: aliases, @@ -445,56 +403,19 @@ func ensureJSONLTestAdapters() { }) } -func buildPayloadAdapter(category string, parse func(save []byte) (any, error)) func([]byte, string) (any, error) { - return func(save []byte, snippet string) (any, error) { - payload, err := parse(save) - if err != nil { - return nil, err - } - if !isNilAny(payload) { - return payload, nil - } - if strings.TrimSpace(snippet) == "" { - return nil, nil - } - - nonogram, table, err := ParsePrintableFromSnippet(category, snippet) - if err != nil { - return nil, err - } - if nonogram != nil { - return nonogram, nil - } - return table, nil - } -} - -func isNilAny(v any) bool { - if v == nil { - return true - } - rv := reflect.ValueOf(v) - switch rv.Kind() { - case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: - return rv.IsNil() - default: - return false - } +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, snippet string) (any, error) + 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) RenderMarkdownSnippet([]byte) (string, error) { - return "", nil -} - -func (a jsonlTestAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { - return a.build(save, snippet) +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/render_error_test.go b/pdfexport/render_error_test.go index 3dd6916..d1ad344 100644 --- a/pdfexport/render_error_test.go +++ b/pdfexport/render_error_test.go @@ -40,12 +40,9 @@ type renderErrorTestAdapter struct { category string } -func (a renderErrorTestAdapter) CanonicalGameType() string { return a.category } -func (a renderErrorTestAdapter) Aliases() []string { return nil } -func (a renderErrorTestAdapter) RenderMarkdownSnippet([]byte) (string, error) { - return "", nil -} -func (a renderErrorTestAdapter) BuildPDFPayload([]byte, string) (any, error) { return nil, nil } +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/shikaku/PrintAdapter.go b/shikaku/PrintAdapter.go index be649a6..55ce499 100644 --- a/shikaku/PrintAdapter.go +++ b/shikaku/PrintAdapter.go @@ -1,11 +1,6 @@ package shikaku import ( - "encoding/json" - "fmt" - "strconv" - "strings" - "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/go-pdf/fpdf" @@ -16,56 +11,14 @@ type printAdapter struct{} func (printAdapter) CanonicalGameType() string { return "Shikaku" } func (printAdapter) Aliases() []string { return []string{"shikaku"} } -func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { - var data Save - if err := json.Unmarshal(save, &data); err != nil { - return "", fmt.Errorf("decode shikaku save: %w", err) - } - - cells := game.MakeStringGrid(data.Width, data.Height, ".") - for _, clue := range data.Clues { - if clue.Y < 0 || clue.Y >= len(cells) { - continue - } - if clue.X < 0 || clue.X >= len(cells[clue.Y]) { - continue - } - cells[clue.Y][clue.X] = strconv.Itoa(clue.Value) - } - - var b strings.Builder - b.WriteString("### Clue Grid\n\n") - b.WriteString(game.RenderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Goal: partition the grid into rectangles so each rectangle contains one clue and its area matches that clue.") - return b.String(), nil -} - -func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { - payload, err := pdfexport.ParseShikakuPrintData(save) - if err != nil { - return nil, err - } - if !game.IsNilPrintPayload(payload) { - return payload, nil - } - if strings.TrimSpace(snippet) == "" { - return nil, nil - } - - _, table, err := pdfexport.ParsePrintableFromSnippet("Shikaku", snippet) - if err != nil { - return nil, err - } - return table, nil +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: pdfexport.RenderShikakuPage(pdf, data) - case *pdfexport.GridTable: - pdfexport.RenderGridTablePage(pdf, data) } return nil } diff --git a/sudoku/PrintAdapter.go b/sudoku/PrintAdapter.go index df1a174..17dc1e3 100644 --- a/sudoku/PrintAdapter.go +++ b/sudoku/PrintAdapter.go @@ -1,10 +1,6 @@ package sudoku import ( - "encoding/json" - "fmt" - "strings" - "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/go-pdf/fpdf" @@ -15,56 +11,14 @@ type printAdapter struct{} func (printAdapter) CanonicalGameType() string { return "Sudoku" } func (printAdapter) Aliases() []string { return []string{"sudoku"} } -func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { - var data Save - if err := json.Unmarshal(save, &data); err != nil { - return "", fmt.Errorf("decode sudoku save: %w", err) - } - - cells := game.MakeStringGrid(9, 9, ".") - for _, provided := range data.Provided { - if provided.Y < 0 || provided.Y >= len(cells) { - continue - } - if provided.X < 0 || provided.X >= len(cells[provided.Y]) { - continue - } - cells[provided.Y][provided.X] = fmt.Sprintf("%d", provided.V) - } - - var b strings.Builder - b.WriteString("### Given Grid\n\n") - b.WriteString(game.RenderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Goal: fill each row, column, and 3x3 box with digits 1-9 exactly once.") - return b.String(), nil -} - -func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { - payload, err := pdfexport.ParseSudokuPrintData(save) - if err != nil { - return nil, err - } - if !game.IsNilPrintPayload(payload) { - return payload, nil - } - if strings.TrimSpace(snippet) == "" { - return nil, nil - } - - _, table, err := pdfexport.ParsePrintableFromSnippet("Sudoku", snippet) - if err != nil { - return nil, err - } - return table, nil +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: pdfexport.RenderSudokuPage(pdf, data) - case *pdfexport.GridTable: - pdfexport.RenderGridTablePage(pdf, data) } return nil } diff --git a/takuzu/PrintAdapter.go b/takuzu/PrintAdapter.go index a97c235..7ede1b2 100644 --- a/takuzu/PrintAdapter.go +++ b/takuzu/PrintAdapter.go @@ -1,10 +1,6 @@ package takuzu import ( - "encoding/json" - "fmt" - "strings" - "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/go-pdf/fpdf" @@ -15,65 +11,14 @@ type printAdapter struct{} func (printAdapter) CanonicalGameType() string { return "Takuzu" } func (printAdapter) Aliases() []string { return []string{"takuzu"} } -func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { - var data Save - if err := json.Unmarshal(save, &data); err != nil { - return "", fmt.Errorf("decode takuzu save: %w", err) - } - - cells := game.MakeStringGrid(data.Size, data.Size, ".") - stateRows := game.SplitLines(data.State) - providedRows := game.SplitLines(data.Provided) - for y := range len(cells) { - for x := range len(cells[y]) { - provided := y < len(providedRows) && x < len(providedRows[y]) && providedRows[y][x] == '#' - if !provided { - continue - } - if y >= len(stateRows) || x >= len(stateRows[y]) { - continue - } - switch stateRows[y][x] { - case '0', '1': - cells[y][x] = string(stateRows[y][x]) - } - } - } - - var b strings.Builder - b.WriteString("### Given Grid\n\n") - b.WriteString(game.RenderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("Goal: fill with 0/1 so no three equal adjacent cells appear, ") - b.WriteString("each row/column has equal 0 and 1 counts, and rows/columns are unique.") - return b.String(), nil -} - -func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { - payload, err := pdfexport.ParseTakuzuPrintData(save) - if err != nil { - return nil, err - } - if !game.IsNilPrintPayload(payload) { - return payload, nil - } - if strings.TrimSpace(snippet) == "" { - return nil, nil - } - - _, table, err := pdfexport.ParsePrintableFromSnippet("Takuzu", snippet) - if err != nil { - return nil, err - } - return table, nil +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: pdfexport.RenderTakuzuPage(pdf, data) - case *pdfexport.GridTable: - pdfexport.RenderGridTablePage(pdf, data) } return nil } diff --git a/wordsearch/PrintAdapter.go b/wordsearch/PrintAdapter.go index fa0884c..9a18c2f 100644 --- a/wordsearch/PrintAdapter.go +++ b/wordsearch/PrintAdapter.go @@ -1,10 +1,6 @@ package wordsearch import ( - "encoding/json" - "fmt" - "strings" - "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/go-pdf/fpdf" @@ -17,79 +13,14 @@ func (printAdapter) Aliases() []string { return []string{"word search", "wordsearch"} } -func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) { - var data Save - if err := json.Unmarshal(save, &data); err != nil { - return "", fmt.Errorf("decode word search save: %w", err) - } - - rows := game.SplitLines(data.Grid) - height := data.Height - if height < len(rows) { - height = len(rows) - } - width := data.Width - for _, row := range rows { - if n := len([]rune(row)); n > width { - width = n - } - } - - cells := game.MakeStringGrid(width, height, ".") - for y := range len(cells) { - if y >= len(rows) { - continue - } - rowRunes := []rune(rows[y]) - for x := range len(cells[y]) { - if x < len(rowRunes) { - cells[y][x] = string(rowRunes[x]) - } - } - } - - var b strings.Builder - b.WriteString("### Grid\n\n") - b.WriteString(game.RenderGridTable(cells)) - b.WriteString("\n\n") - b.WriteString("### Word List\n\n") - b.WriteString("| # | Word |\n") - b.WriteString("| --- | --- |\n") - for i, word := range data.Words { - fmt.Fprintf(&b, "| %d | %s |\n", i+1, game.EscapeMarkdownCell(word.Text)) - } - if len(data.Words) == 0 { - b.WriteString("| 1 | (none) |\n") - } - b.WriteString("\nGoal: find all listed words in the grid.") - return b.String(), nil -} - -func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) { - payload, err := pdfexport.ParseWordSearchPrintData(save) - if err != nil { - return nil, err - } - if !game.IsNilPrintPayload(payload) { - return payload, nil - } - if strings.TrimSpace(snippet) == "" { - return nil, nil - } - - _, table, err := pdfexport.ParsePrintableFromSnippet("Word Search", snippet) - if err != nil { - return nil, err - } - return table, nil +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: pdfexport.RenderWordSearchPage(pdf, data) - case *pdfexport.GridTable: - pdfexport.RenderGridTablePage(pdf, data) } return nil } From 523a8cdf4146e5ede2fba75bf944ccbca2c830df Mon Sep 17 00:00:00 2001 From: Dami Date: Mon, 23 Feb 2026 14:57:32 -0700 Subject: [PATCH 11/14] new agents.md --- AGENTS.md | 188 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 97 insertions(+), 91 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 35bb1b1..8b74938 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md - PuzzleTea Development Guide -## Build, Test, and Quality Commands +## Commands ### Build & Run ```bash @@ -35,51 +35,86 @@ just fmt # gofumpt -w . just tidy # go mod tidy ``` -**Always run `just fmt` and `just lint` before committing.** +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: +## Architecture + +PuzzleTea is a terminal puzzle game collection built with Go using the **Bubble Tea TUI framework** (Elm architecture: Model-Update-View). + +### Technology Stack +- **TUI**: Bubble Tea v2 (`charm.land/bubbletea/v2`, always aliased as `tea`) + Bubbles + Lip Gloss +- **CLI**: Cobra +- **PDF generation**: go-pdf/fpdf (half-letter size: 139.7mm × 215.9mm) +- **Persistence**: SQLite (`~/.puzzletea/history.db`) + +### Control Flow +``` +main() → cmd.RootCmd (Cobra) + ├─ Default: Launch TUI (app.InitialModel → Elm loop) + ├─ --new / --continue / --set-seed: direct game launch + └─ Subcommands: new, continue, list, export-pdf +``` + +### Key Packages + +| Package | Role | +|---------|------| +| `app/` | Root TUI model; 9 puzzle categories wired at startup | +| `cmd/` | Cobra CLI commands including `export-pdf` | +| `game/` | Plugin interfaces (`Gamer`, `Mode`, `Spawner`, `PrintAdapter`), registry | +| `pdfexport/` | PDF pipeline: JSONL parsing → per-game rendering → cover art | +| `store/` | SQLite persistence | +| `theme/` | 365 WCAG-compliant color themes | +| `stats/` | XP/level/streak system | +| `config/` | Persistent JSON config (`~/.puzzletea/config.json`) | +| `resolve/` | CLI argument matching for game/mode names | +| `daily/` | Deterministic daily puzzle seeding | +| `ui/` | Shared TUI components (menus, tables, panels) | + +### Puzzle Packages +Eight printable games: `nonogram`, `sudoku`, `nurikabe`, `shikaku`, `wordsearch`, `hashiwokakero`, `hitori`, `takuzu`. One game without PDF export: `lightsout`. + +Each puzzle package exposes: `Modes`, `DailyModes`, `HelpContent`, `NewMode(...)`, `New(...)`, `ImportModel([]byte)`, `DefaultKeyMap`, and registers itself via `init()` in `Gamemode.go`. + +### Plugin Registration +```go +// In Gamemode.go init(): +game.Register("Nonogram", func(data []byte) (game.Gamer, error) { + return ImportModel(data) +}) +// Optional PDF export registration: +game.RegisterPrintAdapter(adapter) +``` + +### PDF Export Pipeline +``` +export-pdf command + → ParseJSONLFiles() # parse schema puzzletea.export.v1 + → adapter.BuildPDFPayload() # game-specific save → typed struct + → pdfexport.OrderPuzzlesForPrint() # difficulty-based ordering + → pdfexport.WritePDF() # cover + title pages + puzzle bodies + back +``` + +### File Layout per Puzzle Package - **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 +## Code Style ### Formatting -- Use `gofumpt` (stricter than gofmt, extra-rules enabled) -- run `just fmt` -- No comments required unless explaining non-obvious logic +- Use `gofumpt` (stricter than gofmt, extra-rules enabled) — run `just fmt` - 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. +Two groups: stdlib, then everything else (internal + external sorted together). Three groups acceptable when there are many internal imports. ```go import ( "errors" @@ -91,9 +126,8 @@ import ( "charm.land/lipgloss/v2" ) ``` -Note: always alias bubbletea as `tea`. -### Naming Conventions +### Naming - **Types**: PascalCase (`Model`, `NonogramMode`, `Entry`) - **Unexported types**: camelCase or lowercase (`grid`, `state`, `menuItem`) - **Variables/Fields**: camelCase (`rowHints`, `currentHints`) @@ -103,8 +137,7 @@ Note: always alias bubbletea as `tea`. ### Type Declarations Prefer grouped type blocks: `type ( grid [][]rune; state string )` -### Interface Compliance -Use compile-time checks in grouped var blocks: +### Interface Compliance (compile-time checks) ```go var ( _ game.Mode = NonogramMode{} @@ -113,56 +146,11 @@ var ( ) ``` -### 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) -} -``` +Use `theme.Current().Accent` etc. from the `theme` package, and `game/style.go` shared accessors (`CursorFG()`, `CursorBG()`, `ConflictFG()`). Use `compat.AdaptiveColor` from `charm.land/lipgloss/v2/compat` for adaptive colors. -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) - }) -} -``` +### Error Handling +Return descriptive errors; use `fmt.Errorf` with `%w` only when wrapping adds context. No assertion libraries in tests — use `t.Errorf`/`t.Fatalf` only. --- @@ -170,10 +158,10 @@ func init() { ### 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 +// --- 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 @@ -188,12 +176,19 @@ func TestGenerateTomography(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // test logic using t.Errorf / t.Fatalf only (no assertion libraries) + // test logic using t.Errorf / t.Fatalf only }) } } ``` +### Slow Test Gating +```go +if testing.Short() { + t.Skip("skipping slow generator test in short mode") +} +``` + ### Save/Load Round-Trip Pattern ```go data, err := m.GetSave() @@ -203,14 +198,25 @@ if err != nil { t.Fatal(err) } // verify state preserved ``` -### Slow Test Gating +--- + +## Gamer Interface +Every puzzle `Model` must implement (from `game/gamer.go`): ```go -if testing.Short() { - t.Skip("skipping slow generator test in short mode") +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 } ``` ---- +Every mode type embeds `game.BaseMode` via `game.NewBaseMode(title, description)`. ## 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 +`Ctrl+N` Main menu | `Ctrl+C` Quit | `Ctrl+E` Debug overlay | `Ctrl+H` Full help | `Ctrl+R` Reset puzzle | `Enter` Select | `Escape` Back From b4400fe5327129253ee49b07d76abdfefc2e3c31 Mon Sep 17 00:00:00 2001 From: Dami Date: Mon, 23 Feb 2026 15:14:38 -0700 Subject: [PATCH 12/14] migrate to new host for fpdf. --- AGENTS.md | 33 ++++++++++++++++++++++++++-- game/print_adapter.go | 2 +- game/print_adapter_test.go | 2 +- go.mod | 2 +- hashiwokakero/PrintAdapter.go | 2 +- hitori/PrintAdapter.go | 2 +- nonogram/PrintAdapter.go | 2 +- nurikabe/PrintAdapter.go | 2 +- pdfexport/cover_art.go | 2 +- pdfexport/fonts.go | 2 +- pdfexport/jsonl_test.go | 2 +- pdfexport/render.go | 2 +- pdfexport/render_cover.go | 2 +- pdfexport/render_cover_test.go | 2 +- pdfexport/render_error_test.go | 2 +- pdfexport/render_hashi.go | 2 +- pdfexport/render_hitori_takuzu.go | 2 +- pdfexport/render_nurikabe_shikaku.go | 2 +- pdfexport/render_public.go | 2 +- pdfexport/render_tokens.go | 2 +- shikaku/PrintAdapter.go | 2 +- sudoku/PrintAdapter.go | 2 +- takuzu/PrintAdapter.go | 2 +- wordsearch/PrintAdapter.go | 2 +- 24 files changed, 54 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8b74938..95399bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,20 +4,49 @@ ### Build & Run ```bash -just # build with version from git tags +just # default: build with version from git tags +just build # explicit build recipe just run # build and run just install # install to $GOPATH/bin just clean # remove binary and dist/ +just vhs # regenerate all GIF previews under vhs/ ``` Without `just`: `go build -ldflags "-X github.com/FelineStateMachine/puzzletea/cmd.Version=$(git describe --tags --always --dirty)" -o puzzletea` -### CLI Seed Flags +### CLI Play Workflows +```bash +puzzletea # launch interactive menu +puzzletea new nonogram medium # start game directly +puzzletea continue amber-falcon # resume by save name +puzzletea list # list non-abandoned saves +puzzletea list --all # include abandoned saves +``` + +Root flag shortcuts are supported: +```bash +puzzletea --new nonogram:medium +puzzletea --continue amber-falcon +puzzletea --set-seed issue-01 +puzzletea --theme "Catppuccin Mocha" +``` + +### CLI Seed & Export Workflows ```bash puzzletea new --set-seed myseed # deterministic game/mode/puzzle selection puzzletea new nonogram epic --with-seed s1 # deterministic puzzle in selected game/mode +puzzletea new nonogram mini --export 6 -o nonogram-mini.jsonl +puzzletea new sudoku --export 10 --with-seed z1 -o sudoku-pack.jsonl +puzzletea export-pdf nonogram-mini.jsonl -o issue-01.pdf --shuffle-seed issue-01 ``` - `--set-seed` cannot be combined with positional game/mode arguments. +- `--set-seed` cannot be combined with root `--new`/`--continue`. +- `--set-seed` cannot be combined with export flags (`--export` / `--output`). - `--with-seed` is used with explicit game/mode arguments for mode-local reproducibility. +- `new --export` requires a game arg; `--output` must end with `.jsonl` (stdout if omitted). +- `export-pdf` accepts one or more JSONL files; default output is `-print.pdf`. +- `export-pdf --output` must end with `.pdf`; `--volume` must be `>= 1`. +- `export-pdf` supports `--title`, `--header`, `--advert`, `--shuffle-seed`, and `--cover-color`. +- `lightsout` has no print adapter, so `new lightsout --export ...` currently produces no records. ### Testing ```bash diff --git a/game/print_adapter.go b/game/print_adapter.go index edebaf0..363ab79 100644 --- a/game/print_adapter.go +++ b/game/print_adapter.go @@ -4,7 +4,7 @@ import ( "reflect" "strings" - "github.com/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf" ) type PrintAdapter interface { diff --git a/game/print_adapter_test.go b/game/print_adapter_test.go index 749dac0..ba5028a 100644 --- a/game/print_adapter_test.go +++ b/game/print_adapter_test.go @@ -3,7 +3,7 @@ package game import ( "testing" - "github.com/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf" ) type testPrintAdapter struct { diff --git a/go.mod b/go.mod index cb9ed1a..b8107f7 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +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 - github.com/go-pdf/fpdf v0.9.0 + codeberg.org/go-pdf/fpdf v0.11.1 github.com/spf13/cobra v1.10.2 modernc.org/sqlite v1.44.3 ) diff --git a/hashiwokakero/PrintAdapter.go b/hashiwokakero/PrintAdapter.go index 20fbdba..d1e3f40 100644 --- a/hashiwokakero/PrintAdapter.go +++ b/hashiwokakero/PrintAdapter.go @@ -1,9 +1,9 @@ package hashiwokakero import ( + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" - "github.com/go-pdf/fpdf" ) type printAdapter struct{} diff --git a/hitori/PrintAdapter.go b/hitori/PrintAdapter.go index f1faf3c..350ce70 100644 --- a/hitori/PrintAdapter.go +++ b/hitori/PrintAdapter.go @@ -1,9 +1,9 @@ package hitori import ( + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" - "github.com/go-pdf/fpdf" ) type printAdapter struct{} diff --git a/nonogram/PrintAdapter.go b/nonogram/PrintAdapter.go index fa76ef6..3008a9f 100644 --- a/nonogram/PrintAdapter.go +++ b/nonogram/PrintAdapter.go @@ -1,9 +1,9 @@ package nonogram import ( + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" - "github.com/go-pdf/fpdf" ) type printAdapter struct{} diff --git a/nurikabe/PrintAdapter.go b/nurikabe/PrintAdapter.go index 418fc8c..9d255de 100644 --- a/nurikabe/PrintAdapter.go +++ b/nurikabe/PrintAdapter.go @@ -1,9 +1,9 @@ package nurikabe import ( + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" - "github.com/go-pdf/fpdf" ) type printAdapter struct{} diff --git a/pdfexport/cover_art.go b/pdfexport/cover_art.go index b3acfe1..61dc92a 100644 --- a/pdfexport/cover_art.go +++ b/pdfexport/cover_art.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "github.com/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf" ) type coverVec2 struct { diff --git a/pdfexport/fonts.go b/pdfexport/fonts.go index f317348..6bdbaa6 100644 --- a/pdfexport/fonts.go +++ b/pdfexport/fonts.go @@ -4,7 +4,7 @@ import ( _ "embed" "fmt" - "github.com/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf" ) const ( diff --git a/pdfexport/jsonl_test.go b/pdfexport/jsonl_test.go index f0ac270..11c89e6 100644 --- a/pdfexport/jsonl_test.go +++ b/pdfexport/jsonl_test.go @@ -8,8 +8,8 @@ import ( "sync" "testing" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" - "github.com/go-pdf/fpdf" ) func TestParseJSONLFile(t *testing.T) { diff --git a/pdfexport/render.go b/pdfexport/render.go index 37449de..e90bc1d 100644 --- a/pdfexport/render.go +++ b/pdfexport/render.go @@ -10,8 +10,8 @@ import ( "strings" "time" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" - "github.com/go-pdf/fpdf" ) func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) error { diff --git a/pdfexport/render_cover.go b/pdfexport/render_cover.go index 19b6778..2f5181a 100644 --- a/pdfexport/render_cover.go +++ b/pdfexport/render_cover.go @@ -6,7 +6,7 @@ import ( "math/rand" "strings" - "github.com/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf" ) var natureTonePalette = []RGB{ diff --git a/pdfexport/render_cover_test.go b/pdfexport/render_cover_test.go index 7643bfd..b49537e 100644 --- a/pdfexport/render_cover_test.go +++ b/pdfexport/render_cover_test.go @@ -4,7 +4,7 @@ import ( "reflect" "testing" - "github.com/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf" ) func TestResolveCoverColorDeterministicWithSeed(t *testing.T) { diff --git a/pdfexport/render_error_test.go b/pdfexport/render_error_test.go index d1ad344..3d6d25d 100644 --- a/pdfexport/render_error_test.go +++ b/pdfexport/render_error_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" - "github.com/go-pdf/fpdf" ) func TestWritePDFReturnsAdapterRenderError(t *testing.T) { diff --git a/pdfexport/render_hashi.go b/pdfexport/render_hashi.go index f02fba7..099b6cd 100644 --- a/pdfexport/render_hashi.go +++ b/pdfexport/render_hashi.go @@ -5,7 +5,7 @@ import ( "strconv" "unicode/utf8" - "github.com/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf" ) func renderHashiPage(pdf *fpdf.Fpdf, data *HashiData) { diff --git a/pdfexport/render_hitori_takuzu.go b/pdfexport/render_hitori_takuzu.go index d89aa49..009fb03 100644 --- a/pdfexport/render_hitori_takuzu.go +++ b/pdfexport/render_hitori_takuzu.go @@ -4,7 +4,7 @@ import ( "strings" "unicode/utf8" - "github.com/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf" ) func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) { diff --git a/pdfexport/render_nurikabe_shikaku.go b/pdfexport/render_nurikabe_shikaku.go index 71770ba..64996cb 100644 --- a/pdfexport/render_nurikabe_shikaku.go +++ b/pdfexport/render_nurikabe_shikaku.go @@ -4,7 +4,7 @@ import ( "strconv" "unicode/utf8" - "github.com/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf" ) func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) { diff --git a/pdfexport/render_public.go b/pdfexport/render_public.go index 17075ee..f0b47e1 100644 --- a/pdfexport/render_public.go +++ b/pdfexport/render_public.go @@ -1,6 +1,6 @@ package pdfexport -import "github.com/go-pdf/fpdf" +import "codeberg.org/go-pdf/fpdf" func RenderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { renderNonogramPage(pdf, data) diff --git a/pdfexport/render_tokens.go b/pdfexport/render_tokens.go index 3613240..704432d 100644 --- a/pdfexport/render_tokens.go +++ b/pdfexport/render_tokens.go @@ -3,7 +3,7 @@ package pdfexport import ( "math" - "github.com/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf" ) const ( diff --git a/shikaku/PrintAdapter.go b/shikaku/PrintAdapter.go index 55ce499..8f644fc 100644 --- a/shikaku/PrintAdapter.go +++ b/shikaku/PrintAdapter.go @@ -1,9 +1,9 @@ package shikaku import ( + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" - "github.com/go-pdf/fpdf" ) type printAdapter struct{} diff --git a/sudoku/PrintAdapter.go b/sudoku/PrintAdapter.go index 17dc1e3..95d9709 100644 --- a/sudoku/PrintAdapter.go +++ b/sudoku/PrintAdapter.go @@ -1,9 +1,9 @@ package sudoku import ( + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" - "github.com/go-pdf/fpdf" ) type printAdapter struct{} diff --git a/takuzu/PrintAdapter.go b/takuzu/PrintAdapter.go index 7ede1b2..efd44a1 100644 --- a/takuzu/PrintAdapter.go +++ b/takuzu/PrintAdapter.go @@ -1,9 +1,9 @@ package takuzu import ( + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" - "github.com/go-pdf/fpdf" ) type printAdapter struct{} diff --git a/wordsearch/PrintAdapter.go b/wordsearch/PrintAdapter.go index 9a18c2f..cfde905 100644 --- a/wordsearch/PrintAdapter.go +++ b/wordsearch/PrintAdapter.go @@ -1,9 +1,9 @@ package wordsearch import ( + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" - "github.com/go-pdf/fpdf" ) type printAdapter struct{} From b8d1623a90f09a600fc2ad5b4376445f6e0d8006 Mon Sep 17 00:00:00 2001 From: Dami Date: Mon, 23 Feb 2026 15:14:55 -0700 Subject: [PATCH 13/14] update go.sum --- go.sum | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.sum b/go.sum index 93fa117..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= @@ -51,8 +53,6 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= -github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= From 5ec53c4185febd7a127ba3fb6b590b016a11af59 Mon Sep 17 00:00:00 2001 From: Dami Date: Mon, 23 Feb 2026 15:42:32 -0700 Subject: [PATCH 14/14] complete domain split to game for printadapter. --- hashiwokakero/PrintAdapter.go | 112 ++- hitori/PrintAdapter.go | 80 +- nonogram/PrintAdapter.go | 258 ++++- nonogram/print_adapter_test.go | 139 +++ nurikabe/PrintAdapter.go | 76 +- pdfexport/render.go | 940 ------------------ pdfexport/render_hashi.go | 109 -- pdfexport/render_hitori_takuzu.go | 191 ---- pdfexport/render_kit.go | 92 ++ pdfexport/render_layout_test.go | 59 -- pdfexport/render_metadata.go | 17 + pdfexport/render_nonogram_test.go | 70 -- pdfexport/render_nurikabe_shikaku.go | 131 --- pdfexport/render_public.go | 39 - pdfexport/render_title.go | 284 ++++++ shikaku/PrintAdapter.go | 76 +- sudoku/PrintAdapter.go | 86 +- takuzu/PrintAdapter.go | 112 ++- .../print_adapter_test.go | 2 +- wordsearch/PrintAdapter.go | 233 ++++- 20 files changed, 1558 insertions(+), 1548 deletions(-) create mode 100644 nonogram/print_adapter_test.go delete mode 100644 pdfexport/render_hashi.go delete mode 100644 pdfexport/render_hitori_takuzu.go create mode 100644 pdfexport/render_kit.go create mode 100644 pdfexport/render_metadata.go delete mode 100644 pdfexport/render_nonogram_test.go delete mode 100644 pdfexport/render_nurikabe_shikaku.go delete mode 100644 pdfexport/render_public.go create mode 100644 pdfexport/render_title.go rename pdfexport/render_takuzu_test.go => takuzu/print_adapter_test.go (97%) diff --git a/hashiwokakero/PrintAdapter.go b/hashiwokakero/PrintAdapter.go index d1e3f40..aac0044 100644 --- a/hashiwokakero/PrintAdapter.go +++ b/hashiwokakero/PrintAdapter.go @@ -1,6 +1,10 @@ package hashiwokakero import ( + "math" + "strconv" + "unicode/utf8" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" @@ -20,11 +24,117 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) { func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { switch data := payload.(type) { case *pdfexport.HashiData: - pdfexport.RenderHashiPage(pdf, data) + 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 index 350ce70..330ef79 100644 --- a/hitori/PrintAdapter.go +++ b/hitori/PrintAdapter.go @@ -1,6 +1,9 @@ package hitori import ( + "strings" + "unicode/utf8" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" @@ -18,11 +21,86 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) { func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { switch data := payload.(type) { case *pdfexport.HitoriData: - pdfexport.RenderHitoriPage(pdf, data) + 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 index 3008a9f..b7983c6 100644 --- a/nonogram/PrintAdapter.go +++ b/nonogram/PrintAdapter.go @@ -1,6 +1,9 @@ package nonogram import ( + "strconv" + "strings" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" @@ -18,11 +21,264 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) { func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { switch data := payload.(type) { case *pdfexport.NonogramData: - pdfexport.RenderNonogramPage(pdf, data) + 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 index 9d255de..de43fc2 100644 --- a/nurikabe/PrintAdapter.go +++ b/nurikabe/PrintAdapter.go @@ -1,6 +1,9 @@ package nurikabe import ( + "strconv" + "unicode/utf8" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" @@ -18,11 +21,82 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) { func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { switch data := payload.(type) { case *pdfexport.NurikabeData: - pdfexport.RenderNurikabePage(pdf, data) + 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/render.go b/pdfexport/render.go index e90bc1d..e29f8b0 100644 --- a/pdfexport/render.go +++ b/pdfexport/render.go @@ -2,10 +2,8 @@ package pdfexport import ( "fmt" - "math" "os" "path/filepath" - "sort" "strconv" "strings" "time" @@ -131,132 +129,6 @@ func renderPadPage(pdf *fpdf.Fpdf) { pdf.AddPage() } -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 renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error { if game.IsNilPrintPayload(puzzle.PrintPayload) { return nil @@ -287,815 +159,3 @@ func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error { } return nil } - -func renderNonogramPage(pdf *fpdf.Fpdf, data *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(outerBorderLineMM) - pdf.Rect(xSep, ySep, gridW, gridH, "D") - - ruleY := ySep + gridH + 3.5 - ruleY = instructionY(ruleY-3.5, pageH, 1) - body := puzzleBodyRect(pageW, pageH, pageNo) - setInstructionStyle(pdf) - pdf.SetXY(body.x, ruleY) - pdf.CellFormat( - body.w, - 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(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 - } - - // Hints are puzzle-critical, so keep them bold and centered. - pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) - fontSize := standardCellFontSize(h, 0.70) - pdf.SetFont(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 := puzzleBoardRect(pageW, pageH, pageNo, 1) - cellSize := fitBoardCellSize(totalCols, totalRows, area, boardFamilyNonogram) - 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 renderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) { - if data == nil { - return - } - - pageW, pageH := pdf.GetPageSize() - pageNo := pdf.PageNo() - area := puzzleBoardRect(pageW, pageH, pageNo, 1) - cellSize := fitBoardCellSize(9, 9, area, boardFamilySudoku) - if cellSize <= 0 { - return - } - - boardH := 9.0 * cellSize - startX, startY := centeredOrigin(area, 9, 9, cellSize) - - drawSudokuGridLines(pdf, startX, startY, cellSize) - drawSudokuGivens(pdf, startX, startY, cellSize, data.Givens) - - ruleY := instructionY(startY+boardH, pageH, 1) - setInstructionStyle(pdf) - pdf.SetXY(area.x, ruleY) - pdf.CellFormat( - area.w, - 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 thinGridLineMM - } -} - -func drawSudokuGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [9][9]int) { - fontSize := standardCellFontSize(cellSize, 0.62) - lineH := fontSize * 0.85 - pdf.SetFont(sansFontFamily, "B", fontSize) - pdf.SetTextColor(primaryTextGray, primaryTextGray, 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 renderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) { - if data == nil || data.Width <= 0 || data.Height <= 0 { - return - } - - pageW, pageH := pdf.GetPageSize() - pageNo := pdf.PageNo() - body := puzzleBodyRect(pageW, pageH, pageNo) - availW := body.w - availH := body.h - - columnCount := wordSearchColumnCount(data.Width, len(data.Words)) - wordFontSize := puzzleWordBankFontSize - wordLineHeight := 4.2 - gridListGap := wordSearchGridListGap - - 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 := rectMM{x: body.x, y: body.y, w: availW, h: gridAreaH} - cellSize := fitBoardCellSize(data.Width, data.Height, gridArea, boardFamilyWordSearch) - 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-puzzleBottomInsetMM-(gridY+gridH+gridListGap), - columnCount, - ) -} - -func drawWordSearchGrid(pdf *fpdf.Fpdf, data *WordSearchData, startX, startY, cellSize float64) { - pdf.SetDrawColor(45, 45, 45) - pdf.SetLineWidth(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 := standardCellFontSize(cellSize, 0.74) - lineH := fontSize * 0.86 - pdf.SetFont(sansFontFamily, "B", fontSize) - pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) - pdf.SetXY(cellX, cellY+(cellSize-lineH)/2) - pdf.CellFormat(cellSize, lineH, cellText, "", 0, "C", false, 0, "") - } - } - - pdf.SetLineWidth(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(sansFontFamily, "B", puzzleWordBankHeadSize) - pdf.SetXY(x, y) - pdf.CellFormat(width, 4.8, "Word Bank", "", 0, "L", false, 0, "") - - pdf.SetFont(sansFontFamily, "", puzzleWordBankFontSize) - pdf.SetTextColor(ruleTextGray, ruleTextGray, 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(sansFontFamily, "", puzzleWordBankHeadSize) - pdf.SetTextColor(secondaryTextGray, secondaryTextGray, 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(primaryTextGray, primaryTextGray, primaryTextGray) - pdf.SetFont(sansFontFamily, "B", 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(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 renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { - if table == nil || len(table.Rows) == 0 { - return - } - - pageW, pageH := pdf.GetPageSize() - pageNo := pdf.PageNo() - - rows := len(table.Rows) - cols := 0 - for _, row := range table.Rows { - if len(row) > cols { - cols = len(row) - } - } - if cols == 0 { - return - } - - area := puzzleBoardRect(pageW, pageH, pageNo, 0) - cellSize := fitBoardCellSize(cols, rows, area, boardFamilyTable) - if cellSize <= 0 { - return - } - - blockW := float64(cols) * cellSize - blockH := float64(rows) * cellSize - startX, startY := centeredOrigin(area, cols, rows, cellSize) - - pdf.SetDrawColor(55, 55, 55) - pdf.SetLineWidth(thinGridLineMM) - for r := 0; r < rows; r++ { - for c := 0; c < cols; c++ { - x := startX + float64(c)*cellSize - y := startY + float64(r)*cellSize - pdf.Rect(x, y, cellSize, cellSize, "D") - - var text string - if c < len(table.Rows[r]) { - text = strings.TrimSpace(table.Rows[r][c]) - } - if text == "." { - text = " " - } - if text == "" { - continue - } - - dim := (table.HasHeaderRow && r == 0) || (table.HasHeaderCol && c == 0) - drawCellText(pdf, x, y, cellSize, cellSize, text, dim) - } - } - - if table.HasHeaderRow { - ySep := startY + cellSize - pdf.SetLineWidth(majorGridLineMM) - pdf.Line(startX, ySep, startX+blockW, ySep) - } - if table.HasHeaderCol { - xSep := startX + cellSize - pdf.SetLineWidth(majorGridLineMM) - pdf.Line(xSep, startY, xSep, startY+blockH) - } - - pdf.SetLineWidth(outerBorderLineMM) - pdf.Rect(startX, startY, blockW, blockH, "D") -} - -func drawCellText(pdf *fpdf.Fpdf, x, y, w, h float64, text string, dim bool) { - if strings.TrimSpace(text) == "" { - return - } - if dim { - pdf.SetTextColor(dimTextGray, dimTextGray, dimTextGray) - } else { - pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) - } - - fontSize := standardCellFontSize(h, 0.63) - pdf.SetFont(sansFontFamily, "B", fontSize) - lineH := fontSize * 0.9 - pdf.SetXY(x, y+(h-lineH)/2) - pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "") -} - -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(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 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 -} - -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_hashi.go b/pdfexport/render_hashi.go deleted file mode 100644 index 099b6cd..0000000 --- a/pdfexport/render_hashi.go +++ /dev/null @@ -1,109 +0,0 @@ -package pdfexport - -import ( - "math" - "strconv" - "unicode/utf8" - - "codeberg.org/go-pdf/fpdf" -) - -func renderHashiPage(pdf *fpdf.Fpdf, data *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 := puzzleBoardRect(pageW, pageH, pageNo, 1) - step := fitBoardCellSize(spanX, spanY, area, boardFamilyHashi) - if step <= 0 { - return - } - - boardW := float64(spanX) * step - boardH := float64(spanY) * step - originX, originY := 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) - - // Add an explicit blank line before the Hashi hint text. - ruleY := instructionY(originY+boardH+instructionLineHMM, pageH, 1) - setInstructionStyle(pdf) - pdf.SetXY(area.x, ruleY) - pdf.CellFormat( - area.w, - 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(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 []HashiIsland) { - pdf.SetDrawColor(20, 20, 20) - pdf.SetFillColor(255, 255, 255) - pdf.SetLineWidth(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 := standardCellFontSize(radius*2.0, 0.95) - runeCount := utf8.RuneCountInString(text) - switch { - case runeCount >= 3: - fontSize *= 0.70 - case runeCount == 2: - fontSize *= 0.82 - } - fontSize = clampStandardCellFontSize(fontSize) - - pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) - pdf.SetFont(sansFontFamily, "B", fontSize) - lineH := fontSize * 0.88 - pdf.SetXY(cx-radius, cy-lineH/2) - pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "") -} diff --git a/pdfexport/render_hitori_takuzu.go b/pdfexport/render_hitori_takuzu.go deleted file mode 100644 index 009fb03..0000000 --- a/pdfexport/render_hitori_takuzu.go +++ /dev/null @@ -1,191 +0,0 @@ -package pdfexport - -import ( - "strings" - "unicode/utf8" - - "codeberg.org/go-pdf/fpdf" -) - -func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) { - if data == nil || data.Size <= 0 { - return - } - - size := data.Size - pageW, pageH := pdf.GetPageSize() - pageNo := pdf.PageNo() - area := puzzleBoardRect(pageW, pageH, pageNo, 1) - cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact) - if cellSize <= 0 { - return - } - - blockW := float64(size) * cellSize - blockH := float64(size) * cellSize - startX, startY := centeredOrigin(area, size, size, cellSize) - - pdf.SetDrawColor(55, 55, 55) - pdf.SetLineWidth(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(outerBorderLineMM) - pdf.Rect(startX, startY, blockW, blockH, "D") - - ruleY := instructionY(startY+blockH, pageH, 1) - setInstructionStyle(pdf) - pdf.SetXY(area.x, ruleY) - pdf.CellFormat( - area.w, - 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 := standardCellFontSize(cellSize, 0.58) - runeCount := utf8.RuneCountInString(text) - switch { - case runeCount >= 3: - fontSize *= 0.7 - case runeCount == 2: - fontSize *= 0.82 - } - fontSize = clampStandardCellFontSize(fontSize) - - pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) - pdf.SetFont(sansFontFamily, "B", fontSize) - lineH := fontSize * 0.92 - pdf.SetXY(x, y+(cellSize-lineH)/2) - pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") -} - -func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) { - if data == nil || data.Size <= 0 { - return - } - - size := data.Size - pageW, pageH := pdf.GetPageSize() - pageNo := pdf.PageNo() - area := puzzleBoardRect(pageW, pageH, pageNo, 2) - cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact) - if cellSize <= 0 { - return - } - - blockW := float64(size) * cellSize - blockH := float64(size) * cellSize - startX, startY := centeredOrigin(area, size, size, cellSize) - - pdf.SetDrawColor(60, 60, 60) - pdf.SetLineWidth(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(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(outerBorderLineMM) - pdf.Rect(startX, startY, blockW, blockH, "D") - - ruleY := instructionY(startY+blockH, pageH, 2) - setInstructionStyle(pdf) - pdf.SetXY(area.x, ruleY) - pdf.CellFormat( - area.w, - instructionLineHMM, - "No three equal adjacent in any row or column.", - "", - 0, - "C", - false, - 0, - "", - ) - pdf.SetXY(area.x, ruleY+instructionLineHMM) - pdf.CellFormat( - area.w, - 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(primaryTextGray, primaryTextGray, primaryTextGray) - pdf.SetFont(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 := standardCellFontSize(cellSize, 0.68) - switch { - case size >= 14: - fontSize *= 0.94 - case size >= 12: - fontSize *= 0.97 - } - return clampStandardCellFontSize(fontSize) -} 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 index a0e9c8c..8750a14 100644 --- a/pdfexport/render_layout_test.go +++ b/pdfexport/render_layout_test.go @@ -46,65 +46,6 @@ func TestCenteredOriginKeepsBoardCentered(t *testing.T) { } } -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 - boardArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, pageNo, 1) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - layout := layoutNonogram( - halfLetterWidthMM, - halfLetterHeightMM, - 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) - } - }) - } -} - func TestPuzzleBodyRectMirrorsGutterByParity(t *testing.T) { even := puzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 2) odd := puzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 3) 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_nonogram_test.go b/pdfexport/render_nonogram_test.go deleted file mode 100644 index 0d910a3..0000000 --- a/pdfexport/render_nonogram_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package pdfexport - -import "testing" - -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]) - } -} diff --git a/pdfexport/render_nurikabe_shikaku.go b/pdfexport/render_nurikabe_shikaku.go deleted file mode 100644 index 64996cb..0000000 --- a/pdfexport/render_nurikabe_shikaku.go +++ /dev/null @@ -1,131 +0,0 @@ -package pdfexport - -import ( - "strconv" - "unicode/utf8" - - "codeberg.org/go-pdf/fpdf" -) - -func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) { - if data == nil || data.Width <= 0 || data.Height <= 0 { - return - } - - pageW, pageH := pdf.GetPageSize() - pageNo := pdf.PageNo() - area := puzzleBoardRect(pageW, pageH, pageNo, 1) - cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact) - if cellSize <= 0 { - return - } - - blockW := float64(data.Width) * cellSize - blockH := float64(data.Height) * cellSize - startX, startY := centeredOrigin(area, data.Width, data.Height, cellSize) - - pdf.SetDrawColor(55, 55, 55) - pdf.SetLineWidth(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 - } - drawRectanglePuzzleClue(pdf, cellX, cellY, cellSize, data.Clues[y][x]) - } - } - - pdf.SetDrawColor(35, 35, 35) - pdf.SetLineWidth(outerBorderLineMM) - pdf.Rect(startX, startY, blockW, blockH, "D") - - ruleY := instructionY(startY+blockH, pageH, 1) - setInstructionStyle(pdf) - pdf.SetXY(area.x, ruleY) - pdf.CellFormat( - area.w, - instructionLineHMM, - "Expand each numbered island to its size; connect all sea cells into one wall.", - "", - 0, - "C", - false, - 0, - "", - ) -} - -func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) { - if data == nil || data.Width <= 0 || data.Height <= 0 { - return - } - - pageW, pageH := pdf.GetPageSize() - pageNo := pdf.PageNo() - area := puzzleBoardRect(pageW, pageH, pageNo, 1) - cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact) - if cellSize <= 0 { - return - } - - blockW := float64(data.Width) * cellSize - blockH := float64(data.Height) * cellSize - startX, startY := centeredOrigin(area, data.Width, data.Height, cellSize) - - pdf.SetDrawColor(55, 55, 55) - pdf.SetLineWidth(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 - } - drawRectanglePuzzleClue(pdf, cellX, cellY, cellSize, data.Clues[y][x]) - } - } - - pdf.SetDrawColor(35, 35, 35) - pdf.SetLineWidth(outerBorderLineMM) - pdf.Rect(startX, startY, blockW, blockH, "D") - - ruleY := instructionY(startY+blockH, pageH, 1) - setInstructionStyle(pdf) - pdf.SetXY(area.x, ruleY) - pdf.CellFormat( - area.w, - instructionLineHMM, - "Partition into rectangles where each clue equals its rectangle area.", - "", - 0, - "C", - false, - 0, - "", - ) -} - -func drawRectanglePuzzleClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) { - text := strconv.Itoa(value) - fontSize := standardCellFontSize(cellSize, 0.58) - runeCount := utf8.RuneCountInString(text) - switch { - case runeCount >= 3: - fontSize *= 0.72 - case runeCount == 2: - fontSize *= 0.84 - } - fontSize = clampStandardCellFontSize(fontSize) - - pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray) - pdf.SetFont(sansFontFamily, "B", fontSize) - lineH := fontSize * 0.92 - pdf.SetXY(x, y+(cellSize-lineH)/2) - pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") -} diff --git a/pdfexport/render_public.go b/pdfexport/render_public.go deleted file mode 100644 index f0b47e1..0000000 --- a/pdfexport/render_public.go +++ /dev/null @@ -1,39 +0,0 @@ -package pdfexport - -import "codeberg.org/go-pdf/fpdf" - -func RenderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) { - renderNonogramPage(pdf, data) -} - -func RenderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) { - renderNurikabePage(pdf, data) -} - -func RenderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) { - renderShikakuPage(pdf, data) -} - -func RenderHashiPage(pdf *fpdf.Fpdf, data *HashiData) { - renderHashiPage(pdf, data) -} - -func RenderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) { - renderHitoriPage(pdf, data) -} - -func RenderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) { - renderTakuzuPage(pdf, data) -} - -func RenderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) { - renderSudokuPage(pdf, data) -} - -func RenderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) { - renderWordSearchPage(pdf, data) -} - -func RenderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) { - renderGridTablePage(pdf, table) -} 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/shikaku/PrintAdapter.go b/shikaku/PrintAdapter.go index 8f644fc..5b92be5 100644 --- a/shikaku/PrintAdapter.go +++ b/shikaku/PrintAdapter.go @@ -1,6 +1,9 @@ package shikaku import ( + "strconv" + "unicode/utf8" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" @@ -18,11 +21,82 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) { func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { switch data := payload.(type) { case *pdfexport.ShikakuData: - pdfexport.RenderShikakuPage(pdf, data) + 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 index 95d9709..2877454 100644 --- a/sudoku/PrintAdapter.go +++ b/sudoku/PrintAdapter.go @@ -1,6 +1,8 @@ package sudoku import ( + "strconv" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" @@ -18,11 +20,93 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) { func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { switch data := payload.(type) { case *pdfexport.SudokuData: - pdfexport.RenderSudokuPage(pdf, data) + 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 index efd44a1..939b2ed 100644 --- a/takuzu/PrintAdapter.go +++ b/takuzu/PrintAdapter.go @@ -1,6 +1,8 @@ package takuzu import ( + "strings" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" @@ -18,11 +20,119 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) { func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { switch data := payload.(type) { case *pdfexport.TakuzuData: - pdfexport.RenderTakuzuPage(pdf, data) + 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/pdfexport/render_takuzu_test.go b/takuzu/print_adapter_test.go similarity index 97% rename from pdfexport/render_takuzu_test.go rename to takuzu/print_adapter_test.go index 5a39c9a..11952cb 100644 --- a/pdfexport/render_takuzu_test.go +++ b/takuzu/print_adapter_test.go @@ -1,4 +1,4 @@ -package pdfexport +package takuzu import "testing" diff --git a/wordsearch/PrintAdapter.go b/wordsearch/PrintAdapter.go index cfde905..cee930e 100644 --- a/wordsearch/PrintAdapter.go +++ b/wordsearch/PrintAdapter.go @@ -1,6 +1,8 @@ package wordsearch import ( + "strings" + "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" @@ -20,11 +22,240 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) { func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { switch data := payload.(type) { case *pdfexport.WordSearchData: - pdfexport.RenderWordSearchPage(pdf, data) + 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{}) }