clone

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

feat: add hashi pdf renderer and print payload pipeline

Dami committed c82d643
commit
c82d6433d7ca18173f959703aa2f43043ac7db28
parent
39167a3fd7abd9973142f5d191914de69e53dfaa
author
Dami <dami.etoile@gmail.com>
15 files changed, +2432 -77
Mcmd/new_export.go55++
Mcmd/new_export_test.go78++++
Apdfexport/font.go20+
Apdfexport/font_test.go73+++
Mpdfexport/jsonl.go30+-
Mpdfexport/jsonl_test.go292+++++++++++++
Apdfexport/printdata.go519+++++++++++++++++++++++
Apdfexport/printdata_test.go212++++++++++
Mpdfexport/render.go665+++++++++++++++++++++++++++---
Apdfexport/render_hashi.go89++++
Apdfexport/render_hitori_takuzu.go184++++++++
Apdfexport/render_nonogram_test.go70+++
Apdfexport/render_nurikabe_shikaku.go124++++++
Apdfexport/render_takuzu_test.go44++
Mpdfexport/types.go54++
M cmd/new_export.go +55 -0
@@ -171,9 +171,52 @@ func buildExportRecords(
171171 return nil, fmt.Errorf("build print payload for puzzle %d: %w", i+1, err)172172 }173173 174+ var nurikabe *pdfexport.NurikabeData175+ var shikaku *pdfexport.ShikakuData176+ var hashi *pdfexport.HashiData177+ var sudoku *pdfexport.SudokuData178+ var wordSearch *pdfexport.WordSearchData179+ switch normalizeExportGameType(gameType) {180+ case "hashiwokakero":181+ hashi, err = pdfexport.ParseHashiPrintData(save)182+ if err != nil {183+ return nil, fmt.Errorf("build hashiwokakero print payload for puzzle %d: %w", i+1, err)184+ }185+ case "nurikabe":186+ nurikabe, err = pdfexport.ParseNurikabePrintData(save)187+ if err != nil {188+ return nil, fmt.Errorf("build nurikabe print payload for puzzle %d: %w", i+1, err)189+ }190+ case "shikaku":191+ shikaku, err = pdfexport.ParseShikakuPrintData(save)192+ if err != nil {193+ return nil, fmt.Errorf("build shikaku print payload for puzzle %d: %w", i+1, err)194+ }195+ case "sudoku":196+ sudoku, err = pdfexport.ParseSudokuPrintData(save)197+ if err != nil {198+ return nil, fmt.Errorf("build sudoku print payload for puzzle %d: %w", i+1, err)199+ }200+ case "wordsearch":201+ wordSearch, err = pdfexport.ParseWordSearchPrintData(save)202+ if err != nil {203+ return nil, fmt.Errorf("build word search print payload for puzzle %d: %w", i+1, err)204+ }205+ }206+174207 printKind := "text"175208 if nonogram != nil {176209 printKind = "nonogram"210+ } else if hashi != nil {211+ printKind = "hashi"212+ } else if nurikabe != nil {213+ printKind = "nurikabe"214+ } else if shikaku != nil {215+ printKind = "shikaku"216+ } else if sudoku != nil {217+ printKind = "sudoku"218+ } else if wordSearch != nil {219+ printKind = "word-search"177220 } else if table != nil {178221 printKind = "grid-table"179222 }
@@ -203,6 +246,11 @@ func buildExportRecords(
203246 EmptyGlyph: " ",204247 HintTone: "dim",205248 Nonogram: nonogram,249+ Hashi: hashi,250+ Nurikabe: nurikabe,251+ Shikaku: shikaku,252+ Sudoku: sudoku,253+ WordSearch: wordSearch,206254 Table: table,207255 },208256 })
@@ -223,6 +271,13 @@ func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error)
223271 return seeded.SpawnSeeded(rng)224272 }225273 274+func normalizeExportGameType(gameType string) string {275+ gameType = strings.ToLower(strings.TrimSpace(gameType))276+ gameType = strings.ReplaceAll(gameType, "-", "")277+ gameType = strings.Join(strings.Fields(gameType), "")278+ return gameType279+}280+226281 func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error {227282 var b strings.Builder228283 for _, record := range records {
M cmd/new_export_test.go +78 -0
@@ -152,6 +152,84 @@ func TestRunNewExportOverwritesOutputFile(t *testing.T) {
152152 }153153 }154154 155+func TestRunNewExportSudokuPrintPayload(t *testing.T) {156+ withExportFlagReset(t)157+ flagExport = 1158+159+ cmd, out := newExportTestCmd(t, true)160+ if err := runNewExport(cmd, []string{"sudoku", "easy"}); err != nil {161+ t.Fatalf("expected sudoku export success, got error: %v", err)162+ }163+164+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")165+ if len(lines) != 1 {166+ t.Fatalf("jsonl lines = %d, want 1", len(lines))167+ }168+169+ var record pdfexport.JSONLRecord170+ if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {171+ t.Fatal(err)172+ }173+ if got, want := record.Print.Kind, "sudoku"; got != want {174+ t.Fatalf("print kind = %q, want %q", got, want)175+ }176+ if record.Print.Sudoku == nil {177+ t.Fatal("expected sudoku print payload")178+ }179+}180+181+func TestRunNewExportWordSearchPrintPayload(t *testing.T) {182+ withExportFlagReset(t)183+ flagExport = 1184+185+ cmd, out := newExportTestCmd(t, true)186+ if err := runNewExport(cmd, []string{"wordsearch", "easy 10x10"}); err != nil {187+ t.Fatalf("expected word search export success, got error: %v", err)188+ }189+190+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")191+ if len(lines) != 1 {192+ t.Fatalf("jsonl lines = %d, want 1", len(lines))193+ }194+195+ var record pdfexport.JSONLRecord196+ if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {197+ t.Fatal(err)198+ }199+ if got, want := record.Print.Kind, "word-search"; got != want {200+ t.Fatalf("print kind = %q, want %q", got, want)201+ }202+ if record.Print.WordSearch == nil {203+ t.Fatal("expected word search print payload")204+ }205+}206+207+func TestRunNewExportHashiPrintPayload(t *testing.T) {208+ withExportFlagReset(t)209+ flagExport = 1210+211+ cmd, out := newExportTestCmd(t, true)212+ if err := runNewExport(cmd, []string{"hashiwokakero", "easy 7x7"}); err != nil {213+ t.Fatalf("expected hashi export success, got error: %v", err)214+ }215+216+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")217+ if len(lines) != 1 {218+ t.Fatalf("jsonl lines = %d, want 1", len(lines))219+ }220+221+ var record pdfexport.JSONLRecord222+ if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {223+ t.Fatal(err)224+ }225+ if got, want := record.Print.Kind, "hashi"; got != want {226+ t.Fatalf("print kind = %q, want %q", got, want)227+ }228+ if record.Print.Hashi == nil {229+ t.Fatal("expected hashi print payload")230+ }231+}232+155233 func withExportFlagReset(t *testing.T) {156234 t.Helper()157235
A pdfexport/font.go +20 -0
A pdfexport/font_test.go +73 -0
M pdfexport/jsonl.go +23 -7
@@ -37,13 +37,20 @@ type JSONLPuzzle struct {
3737 }3838 3939 type JSONLPrintData struct {40- Kind string `json:"kind"`41- Paper string `json:"paper"`42- MarginMM float64 `json:"margin_mm"`43- EmptyGlyph string `json:"empty_glyph"`44- HintTone string `json:"hint_tone"`45- Nonogram *NonogramData `json:"nonogram,omitempty"`46- Table *GridTable `json:"table,omitempty"`40+ Kind string `json:"kind"`41+ Paper string `json:"paper"`42+ MarginMM float64 `json:"margin_mm"`43+ EmptyGlyph string `json:"empty_glyph"`44+ HintTone string `json:"hint_tone"`45+ Nonogram *NonogramData `json:"nonogram,omitempty"`46+ Nurikabe *NurikabeData `json:"nurikabe,omitempty"`47+ Shikaku *ShikakuData `json:"shikaku,omitempty"`48+ Hashi *HashiData `json:"hashi,omitempty"`49+ Hitori *HitoriData `json:"hitori,omitempty"`50+ Takuzu *TakuzuData `json:"takuzu,omitempty"`51+ Sudoku *SudokuData `json:"sudoku,omitempty"`52+ WordSearch *WordSearchData `json:"word_search,omitempty"`53+ Table *GridTable `json:"table,omitempty"`4754 }4855 4956 func ParseJSONLFiles(paths []string) ([]PackDocument, error) {
@@ -121,9 +128,18 @@ func ParseJSONLFile(path string) (PackDocument, error) {
121128 Name: record.Puzzle.Name,122129 Index: record.Puzzle.Index,123130 Body: record.Puzzle.Snippet,131+ SaveData: append([]byte(nil), record.Puzzle.Save...),124132 Nonogram: record.Print.Nonogram,133+ Nurikabe: record.Print.Nurikabe,134+ Shikaku: record.Print.Shikaku,135+ Hashi: record.Print.Hashi,136+ Hitori: record.Print.Hitori,137+ Takuzu: record.Print.Takuzu,138+ Sudoku: record.Print.Sudoku,139+ WordSearch: record.Print.WordSearch,125140 Table: record.Print.Table,126141 }142+ hydratePuzzlePrintData(&p)127143 128144 if p.Nonogram == nil && p.Table == nil && strings.TrimSpace(record.Puzzle.Snippet) != "" {129145 nonogram, table, err := ParsePrintableFromSnippet(category, record.Puzzle.Snippet)
M pdfexport/jsonl_test.go +292 -0
@@ -78,3 +78,295 @@ func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) {
7878 t.Fatalf("unexpected error: %v", err)7979 }8080 }81+82+func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) {83+ temp := t.TempDir()84+ path := filepath.Join(temp, "takuzu-pack.jsonl")85+86+ record := JSONLRecord{87+ Schema: ExportSchemaV1,88+ Pack: JSONLPackMeta{89+ Generated: "2026-02-22T10:00:00Z",90+ Version: "v-test",91+ Category: "Takuzu",92+ ModeSelection: "Beginner",93+ Count: 1,94+ Seed: "seed-2",95+ },96+ Puzzle: JSONLPuzzle{97+ Index: 1,98+ Name: "binary-wave",99+ Game: "Takuzu",100+ Mode: "Beginner",101+ Save: json.RawMessage(`{"size":2,"state":"01\n10","provided":"##\n#."}`),102+ },103+ Print: JSONLPrintData{104+ Kind: "grid-table",105+ },106+ }107+108+ data, err := json.Marshal(record)109+ if err != nil {110+ t.Fatal(err)111+ }112+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {113+ t.Fatal(err)114+ }115+116+ doc, err := ParseJSONLFile(path)117+ if err != nil {118+ t.Fatal(err)119+ }120+ if got, want := len(doc.Puzzles), 1; got != want {121+ t.Fatalf("puzzles = %d, want %d", got, want)122+ }123+ if doc.Puzzles[0].Takuzu == nil {124+ t.Fatal("expected takuzu print payload from save hydration")125+ }126+ if got, want := doc.Puzzles[0].Takuzu.Givens[1][1], ""; got != want {127+ t.Fatalf("takuzu row 1 col 1 = %q, want empty", got)128+ }129+}130+131+func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) {132+ temp := t.TempDir()133+ path := filepath.Join(temp, "sudoku.jsonl")134+135+ record := JSONLRecord{136+ Schema: ExportSchemaV1,137+ Pack: JSONLPackMeta{138+ Generated: "2026-02-22T10:00:00Z",139+ Version: "v-test",140+ Category: "Sudoku",141+ ModeSelection: "Easy",142+ Count: 1,143+ },144+ Puzzle: JSONLPuzzle{145+ Index: 1,146+ Name: "moss-pine",147+ Game: "Sudoku",148+ Mode: "Easy",149+ Save: json.RawMessage(`{"grid":"500000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":5}]}`),150+ },151+ Print: JSONLPrintData{152+ Kind: "sudoku",153+ },154+ }155+156+ data, err := json.Marshal(record)157+ if err != nil {158+ t.Fatal(err)159+ }160+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {161+ t.Fatal(err)162+ }163+164+ doc, err := ParseJSONLFile(path)165+ if err != nil {166+ t.Fatal(err)167+ }168+ if got, want := len(doc.Puzzles), 1; got != want {169+ t.Fatalf("puzzles = %d, want %d", got, want)170+ }171+ if doc.Puzzles[0].Sudoku == nil {172+ t.Fatal("expected sudoku print payload from save hydration")173+ }174+ if got, want := doc.Puzzles[0].Sudoku.Givens[0][0], 5; got != want {175+ t.Fatalf("sudoku givens[0][0] = %d, want %d", got, want)176+ }177+}178+179+func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) {180+ temp := t.TempDir()181+ path := filepath.Join(temp, "wordsearch.jsonl")182+183+ record := JSONLRecord{184+ Schema: ExportSchemaV1,185+ Pack: JSONLPackMeta{186+ Generated: "2026-02-22T10:00:00Z",187+ Version: "v-test",188+ Category: "Word Search",189+ ModeSelection: "Standard",190+ Count: 1,191+ },192+ Puzzle: JSONLPuzzle{193+ Index: 1,194+ Name: "sage-sky",195+ Game: "Word Search",196+ Mode: "Standard",197+ Save: json.RawMessage(`{"width":3,"height":3,"grid":"abc\ndef\nghi","words":[{"text":"ace"},{"text":"fig"}]}`),198+ },199+ Print: JSONLPrintData{200+ Kind: "word-search",201+ },202+ }203+204+ data, err := json.Marshal(record)205+ if err != nil {206+ t.Fatal(err)207+ }208+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {209+ t.Fatal(err)210+ }211+212+ doc, err := ParseJSONLFile(path)213+ if err != nil {214+ t.Fatal(err)215+ }216+ if got, want := len(doc.Puzzles), 1; got != want {217+ t.Fatalf("puzzles = %d, want %d", got, want)218+ }219+ if doc.Puzzles[0].WordSearch == nil {220+ t.Fatal("expected word search print payload from save hydration")221+ }222+ if got, want := len(doc.Puzzles[0].WordSearch.Words), 2; got != want {223+ t.Fatalf("word count = %d, want %d", got, want)224+ }225+ if got, want := doc.Puzzles[0].WordSearch.Words[0], "ACE"; got != want {226+ t.Fatalf("first word = %q, want %q", got, want)227+ }228+}229+230+func TestParseJSONLFileHydratesNurikabeFromSave(t *testing.T) {231+ temp := t.TempDir()232+ path := filepath.Join(temp, "nurikabe.jsonl")233+234+ record := JSONLRecord{235+ Schema: ExportSchemaV1,236+ Pack: JSONLPackMeta{237+ Generated: "2026-02-22T10:00:00Z",238+ Version: "v-test",239+ Category: "Nurikabe",240+ ModeSelection: "Mini",241+ Count: 1,242+ },243+ Puzzle: JSONLPuzzle{244+ Index: 1,245+ Name: "mist-pond",246+ Game: "Nurikabe",247+ Mode: "Mini",248+ Save: json.RawMessage(`{"width":2,"height":2,"clues":"1,0\n0,2","marks":"??\n??"}`),249+ },250+ Print: JSONLPrintData{251+ Kind: "nurikabe",252+ },253+ }254+255+ data, err := json.Marshal(record)256+ if err != nil {257+ t.Fatal(err)258+ }259+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {260+ t.Fatal(err)261+ }262+263+ doc, err := ParseJSONLFile(path)264+ if err != nil {265+ t.Fatal(err)266+ }267+ if got, want := len(doc.Puzzles), 1; got != want {268+ t.Fatalf("puzzles = %d, want %d", got, want)269+ }270+ if doc.Puzzles[0].Nurikabe == nil {271+ t.Fatal("expected nurikabe print payload from save hydration")272+ }273+ if got, want := doc.Puzzles[0].Nurikabe.Clues[1][1], 2; got != want {274+ t.Fatalf("nurikabe clues[1][1] = %d, want %d", got, want)275+ }276+}277+278+func TestParseJSONLFileHydratesShikakuFromSave(t *testing.T) {279+ temp := t.TempDir()280+ path := filepath.Join(temp, "shikaku.jsonl")281+282+ record := JSONLRecord{283+ Schema: ExportSchemaV1,284+ Pack: JSONLPackMeta{285+ Generated: "2026-02-22T10:00:00Z",286+ Version: "v-test",287+ Category: "Shikaku",288+ ModeSelection: "Mini",289+ Count: 1,290+ },291+ Puzzle: JSONLPuzzle{292+ Index: 1,293+ Name: "ridge-glen",294+ Game: "Shikaku",295+ Mode: "Mini",296+ Save: json.RawMessage(`{"width":2,"height":2,"clues":[{"x":0,"y":0,"value":1},{"x":1,"y":1,"value":4}]}`),297+ },298+ Print: JSONLPrintData{299+ Kind: "shikaku",300+ },301+ }302+303+ data, err := json.Marshal(record)304+ if err != nil {305+ t.Fatal(err)306+ }307+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {308+ t.Fatal(err)309+ }310+311+ doc, err := ParseJSONLFile(path)312+ if err != nil {313+ t.Fatal(err)314+ }315+ if got, want := len(doc.Puzzles), 1; got != want {316+ t.Fatalf("puzzles = %d, want %d", got, want)317+ }318+ if doc.Puzzles[0].Shikaku == nil {319+ t.Fatal("expected shikaku print payload from save hydration")320+ }321+ if got, want := doc.Puzzles[0].Shikaku.Clues[1][1], 4; got != want {322+ t.Fatalf("shikaku clues[1][1] = %d, want %d", got, want)323+ }324+}325+326+func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) {327+ temp := t.TempDir()328+ path := filepath.Join(temp, "hashi.jsonl")329+330+ record := JSONLRecord{331+ Schema: ExportSchemaV1,332+ Pack: JSONLPackMeta{333+ Generated: "2026-02-22T10:00:00Z",334+ Version: "v-test",335+ Category: "Hashiwokakero",336+ ModeSelection: "Standard",337+ Count: 1,338+ },339+ Puzzle: JSONLPuzzle{340+ Index: 1,341+ Name: "reef-palm",342+ Game: "Hashiwokakero",343+ Mode: "Standard",344+ Save: json.RawMessage(`{"width":7,"height":7,"islands":[{"x":0,"y":0,"required":3},{"x":6,"y":6,"required":2}],"bridges":[]}`),345+ },346+ Print: JSONLPrintData{347+ Kind: "hashi",348+ },349+ }350+351+ data, err := json.Marshal(record)352+ if err != nil {353+ t.Fatal(err)354+ }355+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {356+ t.Fatal(err)357+ }358+359+ doc, err := ParseJSONLFile(path)360+ if err != nil {361+ t.Fatal(err)362+ }363+ if got, want := len(doc.Puzzles), 1; got != want {364+ t.Fatalf("puzzles = %d, want %d", got, want)365+ }366+ if doc.Puzzles[0].Hashi == nil {367+ t.Fatal("expected hashi print payload from save hydration")368+ }369+ if got, want := len(doc.Puzzles[0].Hashi.Islands), 2; got != want {370+ t.Fatalf("hashi island count = %d, want %d", got, want)371+ }372+}
A pdfexport/printdata.go +519 -0
@@ -0,0 +1,519 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "fmt"6+ "strconv"7+ "strings"8+ "unicode"9+ "unicode/utf8"10+)11+12+type nurikabeSave struct {13+ Width int `json:"width"`14+ Height int `json:"height"`15+ Clues string `json:"clues"`16+}17+18+type hashiSave struct {19+ Width int `json:"width"`20+ Height int `json:"height"`21+ Islands []hashiIsland `json:"islands"`22+}23+24+type hashiIsland struct {25+ X int `json:"x"`26+ Y int `json:"y"`27+ Required int `json:"required"`28+}29+30+type shikakuSave struct {31+ Width int `json:"width"`32+ Height int `json:"height"`33+ Clues []shikakuClue `json:"clues"`34+}35+36+type shikakuClue struct {37+ X int `json:"x"`38+ Y int `json:"y"`39+ Value int `json:"value"`40+}41+42+type hitoriSave struct {43+ Size int `json:"size"`44+ Numbers string `json:"numbers"`45+}46+47+type takuzuSave struct {48+ Size int `json:"size"`49+ State string `json:"state"`50+ Provided string `json:"provided"`51+}52+53+type sudokuSave struct {54+ Provided []sudokuCell `json:"provided"`55+}56+57+type sudokuCell struct {58+ X int `json:"x"`59+ Y int `json:"y"`60+ V int `json:"v"`61+}62+63+type wordSearchSave struct {64+ Width int `json:"width"`65+ Height int `json:"height"`66+ Grid string `json:"grid"`67+ Words []wordSearchWord `json:"words"`68+}69+70+type wordSearchWord struct {71+ Text string `json:"text"`72+}73+74+func ParseNurikabePrintData(saveData []byte) (*NurikabeData, error) {75+ if len(strings.TrimSpace(string(saveData))) == 0 {76+ return nil, nil77+ }78+79+ var save nurikabeSave80+ if err := json.Unmarshal(saveData, &save); err != nil {81+ return nil, fmt.Errorf("decode nurikabe save: %w", err)82+ }83+84+ width := save.Width85+ height := save.Height86+ if width <= 0 || height <= 0 {87+ return nil, nil88+ }89+90+ clues, err := parseNurikabeClues(save.Clues, width, height)91+ if err != nil {92+ return nil, err93+ }94+95+ return &NurikabeData{96+ Width: width,97+ Height: height,98+ Clues: clues,99+ }, nil100+}101+102+func ParseShikakuPrintData(saveData []byte) (*ShikakuData, error) {103+ if len(strings.TrimSpace(string(saveData))) == 0 {104+ return nil, nil105+ }106+107+ var save shikakuSave108+ if err := json.Unmarshal(saveData, &save); err != nil {109+ return nil, fmt.Errorf("decode shikaku save: %w", err)110+ }111+ if save.Width <= 0 || save.Height <= 0 {112+ return nil, nil113+ }114+115+ clues := make([][]int, save.Height)116+ for y := 0; y < save.Height; y++ {117+ clues[y] = make([]int, save.Width)118+ }119+120+ for _, clue := range save.Clues {121+ if clue.X < 0 || clue.X >= save.Width || clue.Y < 0 || clue.Y >= save.Height {122+ continue123+ }124+ if clue.Value <= 0 {125+ continue126+ }127+ clues[clue.Y][clue.X] = clue.Value128+ }129+130+ return &ShikakuData{131+ Width: save.Width,132+ Height: save.Height,133+ Clues: clues,134+ }, nil135+}136+137+func ParseHashiPrintData(saveData []byte) (*HashiData, error) {138+ if len(strings.TrimSpace(string(saveData))) == 0 {139+ return nil, nil140+ }141+142+ var save hashiSave143+ if err := json.Unmarshal(saveData, &save); err != nil {144+ return nil, fmt.Errorf("decode hashiwokakero save: %w", err)145+ }146+ if save.Width <= 0 || save.Height <= 0 {147+ return nil, nil148+ }149+150+ islands := make([]HashiIsland, 0, len(save.Islands))151+ for _, island := range save.Islands {152+ if island.X < 0 || island.X >= save.Width || island.Y < 0 || island.Y >= save.Height {153+ continue154+ }155+ if island.Required <= 0 {156+ continue157+ }158+ islands = append(islands, HashiIsland(island))159+ }160+161+ return &HashiData{162+ Width: save.Width,163+ Height: save.Height,164+ Islands: islands,165+ }, nil166+}167+168+func ParseHitoriPrintData(saveData []byte) (*HitoriData, error) {169+ if len(strings.TrimSpace(string(saveData))) == 0 {170+ return nil, nil171+ }172+173+ var save hitoriSave174+ if err := json.Unmarshal(saveData, &save); err != nil {175+ return nil, fmt.Errorf("decode hitori save: %w", err)176+ }177+178+ rows := splitNormalizedLines(save.Numbers)179+ size := save.Size180+ if size <= 0 {181+ size = len(rows)182+ }183+ if size <= 0 {184+ return nil, nil185+ }186+187+ numbers := make([][]string, size)188+ for y := 0; y < size; y++ {189+ numbers[y] = make([]string, size)190+ if y >= len(rows) {191+ continue192+ }193+194+ rowValues := parseHitoriRowValues(rows[y])195+ for x := 0; x < size && x < len(rowValues); x++ {196+ numbers[y][x] = rowValues[x]197+ }198+ }199+200+ return &HitoriData{201+ Size: size,202+ Numbers: numbers,203+ }, nil204+}205+206+func ParseTakuzuPrintData(saveData []byte) (*TakuzuData, error) {207+ if len(strings.TrimSpace(string(saveData))) == 0 {208+ return nil, nil209+ }210+211+ var save takuzuSave212+ if err := json.Unmarshal(saveData, &save); err != nil {213+ return nil, fmt.Errorf("decode takuzu save: %w", err)214+ }215+216+ stateRows := splitNormalizedLines(save.State)217+ providedRows := splitNormalizedLines(save.Provided)218+219+ size := save.Size220+ if size <= 0 {221+ size = max(len(stateRows), len(providedRows))222+ }223+ if size <= 0 {224+ return nil, nil225+ }226+227+ givens := make([][]string, size)228+ for y := 0; y < size; y++ {229+ givens[y] = make([]string, size)230+231+ var stateRunes []rune232+ if y < len(stateRows) {233+ stateRunes = []rune(stateRows[y])234+ }235+236+ var providedRunes []rune237+ if y < len(providedRows) {238+ providedRunes = []rune(providedRows[y])239+ }240+241+ for x := 0; x < size; x++ {242+ if x >= len(providedRunes) || providedRunes[x] != '#' {243+ continue244+ }245+ if x >= len(stateRunes) {246+ continue247+ }248+ if stateRunes[x] != '0' && stateRunes[x] != '1' {249+ continue250+ }251+ givens[y][x] = string(stateRunes[x])252+ }253+ }254+255+ return &TakuzuData{256+ Size: size,257+ Givens: givens,258+ GroupEveryTwo: true,259+ }, nil260+}261+262+func ParseSudokuPrintData(saveData []byte) (*SudokuData, error) {263+ if len(strings.TrimSpace(string(saveData))) == 0 {264+ return nil, nil265+ }266+267+ var save sudokuSave268+ if err := json.Unmarshal(saveData, &save); err != nil {269+ return nil, fmt.Errorf("decode sudoku save: %w", err)270+ }271+272+ var givens [9][9]int273+ for _, cell := range save.Provided {274+ if !isSudokuCellInBounds(cell.X, cell.Y) {275+ continue276+ }277+ if cell.V < 1 || cell.V > 9 {278+ continue279+ }280+ givens[cell.Y][cell.X] = cell.V281+ }282+283+ return &SudokuData{Givens: givens}, nil284+}285+286+func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) {287+ if len(strings.TrimSpace(string(saveData))) == 0 {288+ return nil, nil289+ }290+291+ var save wordSearchSave292+ if err := json.Unmarshal(saveData, &save); err != nil {293+ return nil, fmt.Errorf("decode word search save: %w", err)294+ }295+296+ rows := strings.Split(strings.ReplaceAll(strings.ReplaceAll(save.Grid, "\r\n", "\n"), "\r", "\n"), "\n")297+ if len(rows) == 1 && rows[0] == "" {298+ rows = nil299+ }300+301+ width := save.Width302+ for _, row := range rows {303+ if n := len([]rune(row)); n > width {304+ width = n305+ }306+ }307+308+ height := save.Height309+ if len(rows) > height {310+ height = len(rows)311+ }312+ if width <= 0 || height <= 0 {313+ return nil, nil314+ }315+316+ grid := make([][]string, height)317+ for y := 0; y < height; y++ {318+ grid[y] = make([]string, width)319+ runes := []rune{}320+ if y < len(rows) {321+ runes = []rune(rows[y])322+ }323+ for x := 0; x < width; x++ {324+ grid[y][x] = " "325+ if x >= len(runes) {326+ continue327+ }328+ r := runes[x]329+ if unicode.IsSpace(r) {330+ continue331+ }332+ grid[y][x] = string(unicode.ToUpper(r))333+ }334+ }335+336+ words := make([]string, 0, len(save.Words))337+ for _, word := range save.Words {338+ text := strings.ToUpper(strings.TrimSpace(word.Text))339+ if text == "" {340+ continue341+ }342+ words = append(words, text)343+ }344+345+ return &WordSearchData{346+ Width: width,347+ Height: height,348+ Grid: grid,349+ Words: words,350+ }, nil351+}352+353+func hydratePuzzlePrintData(p *Puzzle) {354+ if p == nil {355+ return356+ }357+358+ switch normalizePrintableCategory(p.Category) {359+ case "hashiwokakero":360+ if p.Hashi != nil {361+ return362+ }363+ hashi, err := ParseHashiPrintData(p.SaveData)364+ if err == nil {365+ p.Hashi = hashi366+ }367+ case "nurikabe":368+ if p.Nurikabe != nil {369+ return370+ }371+ nurikabe, err := ParseNurikabePrintData(p.SaveData)372+ if err == nil {373+ p.Nurikabe = nurikabe374+ }375+ case "shikaku":376+ if p.Shikaku != nil {377+ return378+ }379+ shikaku, err := ParseShikakuPrintData(p.SaveData)380+ if err == nil {381+ p.Shikaku = shikaku382+ }383+ case "hitori":384+ if p.Hitori != nil {385+ return386+ }387+ hitori, err := ParseHitoriPrintData(p.SaveData)388+ if err == nil {389+ p.Hitori = hitori390+ }391+ case "takuzu":392+ if p.Takuzu != nil {393+ return394+ }395+ takuzu, err := ParseTakuzuPrintData(p.SaveData)396+ if err == nil {397+ p.Takuzu = takuzu398+ }399+ case "sudoku":400+ if p.Sudoku != nil {401+ return402+ }403+ sudoku, err := ParseSudokuPrintData(p.SaveData)404+ if err == nil {405+ p.Sudoku = sudoku406+ }407+ case "wordsearch":408+ if p.WordSearch != nil {409+ return410+ }411+ wordSearch, err := ParseWordSearchPrintData(p.SaveData)412+ if err == nil {413+ p.WordSearch = wordSearch414+ }415+ }416+}417+418+func normalizePrintableCategory(category string) string {419+ category = strings.ToLower(strings.TrimSpace(category))420+ category = strings.ReplaceAll(category, "-", "")421+ category = strings.Join(strings.Fields(category), "")422+ return category423+}424+425+func isSudokuCellInBounds(x, y int) bool {426+ return x >= 0 && x < 9 && y >= 0 && y < 9427+}428+429+func splitNormalizedLines(raw string) []string {430+ normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")431+ if strings.TrimSpace(normalized) == "" {432+ return nil433+ }434+ return strings.Split(normalized, "\n")435+}436+437+func parseHitoriRowValues(row string) []string {438+ row = strings.TrimSpace(row)439+ if row == "" {440+ return nil441+ }442+443+ if strings.Contains(row, " ") || strings.Contains(row, ",") {444+ fields := strings.Fields(strings.ReplaceAll(row, ",", " "))445+ if len(fields) > 1 {446+ values := make([]string, len(fields))447+ for i, field := range fields {448+ values[i] = normalizeHitoriToken(field)449+ }450+ return values451+ }452+ }453+454+ runes := []rune(row)455+ values := make([]string, len(runes))456+ for i, r := range runes {457+ values[i] = normalizeHitoriRune(r)458+ }459+ return values460+}461+462+func normalizeHitoriToken(token string) string {463+ token = strings.TrimSpace(token)464+ if token == "" || token == "." {465+ return ""466+ }467+ if utf8.RuneCountInString(token) == 1 {468+ r, _ := utf8.DecodeRuneInString(token)469+ return normalizeHitoriRune(r)470+ }471+ return token472+}473+474+func normalizeHitoriRune(r rune) string {475+ switch {476+ case r == '.':477+ return ""478+ case r >= '0' && r <= '9':479+ return string(r)480+ default:481+ value := int(r - '0')482+ if value >= 10 && value <= 35 {483+ return fmt.Sprintf("%d", value)484+ }485+ return string(r)486+ }487+}488+489+func parseNurikabeClues(raw string, width, height int) ([][]int, error) {490+ if width <= 0 || height <= 0 {491+ return nil, fmt.Errorf("invalid clue dimensions: %dx%d", width, height)492+ }493+494+ clues := make([][]int, height)495+ for y := 0; y < height; y++ {496+ clues[y] = make([]int, width)497+ }498+499+ rows := splitNormalizedLines(raw)500+ for y := 0; y < len(rows) && y < height; y++ {501+ parts := strings.Split(rows[y], ",")502+ for x := 0; x < len(parts) && x < width; x++ {503+ token := strings.TrimSpace(parts[x])504+ if token == "" {505+ continue506+ }507+ value, err := strconv.Atoi(token)508+ if err != nil {509+ return nil, fmt.Errorf("invalid clue value %q at (%d,%d): %w", token, x, y, err)510+ }511+ if value < 0 {512+ return nil, fmt.Errorf("negative clue value %d at (%d,%d)", value, x, y)513+ }514+ clues[y][x] = value515+ }516+ }517+518+ return clues, nil519+}
A pdfexport/printdata_test.go +212 -0
@@ -0,0 +1,212 @@
1+package pdfexport2+3+import "testing"4+5+func TestParseNurikabePrintData(t *testing.T) {6+ save := []byte(`{"width":3,"height":2,"clues":"1,0,2\n0,3,0","marks":"???\n???"}`)7+8+ data, err := ParseNurikabePrintData(save)9+ if err != nil {10+ t.Fatal(err)11+ }12+ if data == nil {13+ t.Fatal("expected nurikabe print data")14+ }15+ if got, want := data.Width, 3; got != want {16+ t.Fatalf("width = %d, want %d", got, want)17+ }18+ if got, want := data.Height, 2; got != want {19+ t.Fatalf("height = %d, want %d", got, want)20+ }21+ if got, want := data.Clues[0][0], 1; got != want {22+ t.Fatalf("row 0 col 0 = %d, want %d", got, want)23+ }24+ if got, want := data.Clues[0][2], 2; got != want {25+ t.Fatalf("row 0 col 2 = %d, want %d", got, want)26+ }27+ if got, want := data.Clues[1][1], 3; got != want {28+ t.Fatalf("row 1 col 1 = %d, want %d", got, want)29+ }30+}31+32+func TestParseHashiPrintData(t *testing.T) {33+ 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}]}`)34+35+ data, err := ParseHashiPrintData(save)36+ if err != nil {37+ t.Fatal(err)38+ }39+ if data == nil {40+ t.Fatal("expected hashi print data")41+ }42+ if got, want := data.Width, 7; got != want {43+ t.Fatalf("width = %d, want %d", got, want)44+ }45+ if got, want := data.Height, 7; got != want {46+ t.Fatalf("height = %d, want %d", got, want)47+ }48+ if got, want := len(data.Islands), 2; got != want {49+ t.Fatalf("island count = %d, want %d", got, want)50+ }51+ if got, want := data.Islands[0].Required, 3; got != want {52+ t.Fatalf("island[0].required = %d, want %d", got, want)53+ }54+}55+56+func TestParseShikakuPrintDataDuplicateClueCoordinatesUseLatest(t *testing.T) {57+ 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}]}`)58+59+ data, err := ParseShikakuPrintData(save)60+ if err != nil {61+ t.Fatal(err)62+ }63+ if data == nil {64+ t.Fatal("expected shikaku print data")65+ }66+ if got, want := data.Clues[1][1], 5; got != want {67+ t.Fatalf("row 1 col 1 = %d, want %d", got, want)68+ }69+ if got, want := data.Clues[0][2], 4; got != want {70+ t.Fatalf("row 0 col 2 = %d, want %d", got, want)71+ }72+}73+74+func TestParseHitoriPrintData(t *testing.T) {75+ save := []byte(`{"size":4,"numbers":"1 2 10\n4 . .\n7\n"}`)76+77+ data, err := ParseHitoriPrintData(save)78+ if err != nil {79+ t.Fatal(err)80+ }81+ if data == nil {82+ t.Fatal("expected hitori print data")83+ }84+ if got, want := data.Size, 4; got != want {85+ t.Fatalf("size = %d, want %d", got, want)86+ }87+88+ if got, want := data.Numbers[0][2], "10"; got != want {89+ t.Fatalf("row 0 col 2 = %q, want %q", got, want)90+ }91+ if got, want := data.Numbers[1][1], ""; got != want {92+ t.Fatalf("row 1 col 1 = %q, want empty", got)93+ }94+ if got, want := data.Numbers[2][0], "7"; got != want {95+ t.Fatalf("row 2 col 0 = %q, want %q", got, want)96+ }97+ if got, want := data.Numbers[3][3], ""; got != want {98+ t.Fatalf("row 3 col 3 = %q, want empty", got)99+ }100+}101+102+func TestParseHitoriPrintDataCompactRuneEncoding(t *testing.T) {103+ save := []byte(`{"size":3,"numbers":"12:\n4..\n789"}`)104+105+ data, err := ParseHitoriPrintData(save)106+ if err != nil {107+ t.Fatal(err)108+ }109+ if data == nil {110+ t.Fatal("expected hitori print data")111+ }112+113+ if got, want := data.Numbers[0][2], "10"; got != want {114+ t.Fatalf("row 0 col 2 = %q, want %q", got, want)115+ }116+ if got, want := data.Numbers[1][1], ""; got != want {117+ t.Fatalf("row 1 col 1 = %q, want empty", got)118+ }119+}120+121+func TestParseTakuzuPrintData(t *testing.T) {122+ save := []byte(`{"size":4,"state":"01..\n10..\n0011\n1111","provided":"#.\n.##\n####\n#"}`)123+124+ data, err := ParseTakuzuPrintData(save)125+ if err != nil {126+ t.Fatal(err)127+ }128+ if data == nil {129+ t.Fatal("expected takuzu print data")130+ }131+ if got, want := data.Size, 4; got != want {132+ t.Fatalf("size = %d, want %d", got, want)133+ }134+ if !data.GroupEveryTwo {135+ t.Fatal("expected GroupEveryTwo to be true")136+ }137+138+ if got, want := data.Givens[0][0], "0"; got != want {139+ t.Fatalf("row 0 col 0 = %q, want %q", got, want)140+ }141+ if got, want := data.Givens[1][2], ""; got != want {142+ t.Fatalf("row 1 col 2 = %q, want empty", got)143+ }144+ if got, want := data.Givens[2][3], "1"; got != want {145+ t.Fatalf("row 2 col 3 = %q, want %q", got, want)146+ }147+ if got, want := data.Givens[3][1], ""; got != want {148+ t.Fatalf("row 3 col 1 = %q, want empty", got)149+ }150+}151+152+func TestHydratePuzzlePrintDataForTakuzu(t *testing.T) {153+ p := Puzzle{154+ Category: "Takuzu",155+ SaveData: []byte(`{"size":2,"state":"01\n10","provided":"##\n#."}`),156+ }157+158+ hydratePuzzlePrintData(&p)159+ if p.Takuzu == nil {160+ t.Fatal("expected takuzu payload from save hydration")161+ }162+ if got, want := p.Takuzu.Givens[0][1], "1"; got != want {163+ t.Fatalf("row 0 col 1 = %q, want %q", got, want)164+ }165+ if got, want := p.Takuzu.Givens[1][1], ""; got != want {166+ t.Fatalf("row 1 col 1 = %q, want empty", got)167+ }168+}169+170+func TestHydratePuzzlePrintDataForNurikabeAndShikaku(t *testing.T) {171+ nurikabePuzzle := Puzzle{172+ Category: "Nurikabe",173+ SaveData: []byte(`{"width":2,"height":2,"clues":"1,0\n0,2","marks":"??\n??"}`),174+ }175+ hydratePuzzlePrintData(&nurikabePuzzle)176+ if nurikabePuzzle.Nurikabe == nil {177+ t.Fatal("expected nurikabe payload from save hydration")178+ }179+ if got, want := nurikabePuzzle.Nurikabe.Clues[1][1], 2; got != want {180+ t.Fatalf("nurikabe row 1 col 1 = %d, want %d", got, want)181+ }182+183+ shikakuPuzzle := Puzzle{184+ Category: "Shikaku",185+ SaveData: []byte(`{"width":2,"height":2,"clues":[{"x":0,"y":0,"value":1},{"x":1,"y":1,"value":3}]}`),186+ }187+ hydratePuzzlePrintData(&shikakuPuzzle)188+ if shikakuPuzzle.Shikaku == nil {189+ t.Fatal("expected shikaku payload from save hydration")190+ }191+ if got, want := shikakuPuzzle.Shikaku.Clues[1][1], 3; got != want {192+ t.Fatalf("shikaku row 1 col 1 = %d, want %d", got, want)193+ }194+}195+196+func TestHydratePuzzlePrintDataForHashi(t *testing.T) {197+ puzzle := Puzzle{198+ Category: "Hashiwokakero",199+ SaveData: []byte(`{"width":5,"height":5,"islands":[{"x":0,"y":0,"required":2},{"x":4,"y":4,"required":3}],"bridges":[]}`),200+ }201+202+ hydratePuzzlePrintData(&puzzle)203+ if puzzle.Hashi == nil {204+ t.Fatal("expected hashi payload from save hydration")205+ }206+ if got, want := len(puzzle.Hashi.Islands), 2; got != want {207+ t.Fatalf("island count = %d, want %d", got, want)208+ }209+ if got, want := puzzle.Hashi.Islands[1].Required, 3; got != want {210+ t.Fatalf("island[1].required = %d, want %d", got, want)211+ }212+}
M pdfexport/render.go +595 -70
@@ -48,6 +48,15 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
4848 pdf.SetCreator("PuzzleTea", true)4949 pdf.SetAuthor("PuzzleTea", true)5050 pdf.SetTitle(cfg.Title, true)51+ pdf.SetFooterFunc(func() {52+ if pdf.PageNo() <= 1 {53+ return54+ }55+ pdf.SetY(-8)56+ pdf.SetFont("Helvetica", "", 8)57+ pdf.SetTextColor(105, 105, 105)58+ pdf.CellFormat(0, 4, strconv.Itoa(pdf.PageNo()), "", 0, "C", false, 0, "")59+ })5160 5261 renderTitlePage(pdf, docs, puzzles, cfg)5362 for _, puzzle := range puzzles {
@@ -80,13 +89,13 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
8089 pdf.SetFont("Helvetica", "", 11)8190 pdf.SetTextColor(70, 70, 70)8291 pdf.SetXY(0, 36)83- pdf.CellFormat(pageW, 6, "A5 Printable Puzzle Pack", "", 0, "C", false, 0, "")92+ pdf.CellFormat(pageW, 6, "PuzzleTea Puzzle Pack", "", 0, "C", false, 0, "")8493 8594 metaY := 50.08695 pdf.SetTextColor(25, 25, 25)8796 pdf.SetFont("Helvetica", "", 10)8897 pdf.SetXY(margin, metaY)89- pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format(time.RFC3339)), "", 0, "L", false, 0, "")98+ pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format("January 2, 2006")), "", 0, "L", false, 0, "")9099 metaY += 691100 92101 categories := summarizeCategories(puzzles)
@@ -103,29 +112,7 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
103112 pdf.CellFormat(pageW-2*margin, 6, "Source Exports", "", 0, "L", false, 0, "")104113 metaY += 7105114 106- pdf.SetFont("Helvetica", "", 8.5)107- pdf.SetTextColor(85, 85, 85)108- for _, doc := range docs {109- line := fmt.Sprintf("%s | Category: %s | Mode: %s | Count: %d | Seed: %s",110- doc.Metadata.SourceFileName,111- doc.Metadata.Category,112- doc.Metadata.ModeSelection,113- doc.Metadata.Count,114- emptyAs(doc.Metadata.Seed, "none"),115- )116- wrapped := pdf.SplitLines([]byte(line), pageW-2*margin)117- for _, raw := range wrapped {118- if metaY > pageH-45 {119- break120- }121- pdf.SetXY(margin, metaY)122- pdf.CellFormat(pageW-2*margin, 5, string(raw), "", 0, "L", false, 0, "")123- metaY += 5124- }125- if metaY > pageH-45 {126- break127- }128- }115+ renderSourceExportsTable(pdf, docs, margin, metaY, pageW-2*margin, pageH-45)129116 130117 pdf.SetTextColor(50, 50, 50)131118 pdf.SetFont("Helvetica", "B", 12)
@@ -140,6 +127,7 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
140127 func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) {141128 pdf.AddPage()142129 pageW, pageH := pdf.GetPageSize()130+ hydratePuzzlePrintData(&puzzle)143131 144132 pdf.SetTextColor(20, 20, 20)145133 pdf.SetFont("Helvetica", "B", 13)
@@ -150,17 +138,45 @@ func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) {
150138 pdf.SetFont("Helvetica", "", 9)151139 pdf.SetTextColor(95, 95, 95)152140 pdf.SetXY(0, 17)153- subtitle := fmt.Sprintf("Mode: %s | Source: %s | Difficulty score: %.2f",154- emptyAs(puzzle.ModeSelection, "mixed modes"),155- puzzle.SourceFileName,156- puzzle.DifficultyScore,157- )141+ subtitleParts := []string{fmt.Sprintf("Difficulty Score: %d/10", difficultyScoreOutOfTen(puzzle.DifficultyScore))}142+ if !isMixedModes(puzzle.ModeSelection) {143+ subtitleParts = append([]string{fmt.Sprintf("Mode: %s", puzzle.ModeSelection)}, subtitleParts...)144+ }145+ subtitle := strings.Join(subtitleParts, " | ")158146 pdf.CellFormat(pageW, 5, subtitle, "", 0, "C", false, 0, "")159147 160148 if puzzle.Nonogram != nil {161149 renderNonogramPage(pdf, puzzle.Nonogram)162150 return163151 }152+ if puzzle.Nurikabe != nil {153+ renderNurikabePage(pdf, puzzle.Nurikabe)154+ return155+ }156+ if puzzle.Shikaku != nil {157+ renderShikakuPage(pdf, puzzle.Shikaku)158+ return159+ }160+ if puzzle.Hashi != nil {161+ renderHashiPage(pdf, puzzle.Hashi)162+ return163+ }164+ if puzzle.Hitori != nil {165+ renderHitoriPage(pdf, puzzle.Hitori)166+ return167+ }168+ if puzzle.Takuzu != nil {169+ renderTakuzuPage(pdf, puzzle.Takuzu)170+ return171+ }172+ if puzzle.Sudoku != nil {173+ renderSudokuPage(pdf, puzzle.Sudoku)174+ return175+ }176+ if puzzle.WordSearch != nil {177+ renderWordSearchPage(pdf, puzzle.WordSearch)178+ return179+ }164180 if puzzle.Table != nil {165181 renderGridTablePage(pdf, puzzle.Table)166182 return
@@ -175,11 +191,15 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {
175191 176192 pageW, pageH := pdf.GetPageSize()177193 marginX := 10.0194+ padding := 3.0178195 top := 28.0179196 bottom := 12.0180197 181- rowHintCols := maxHintDepth(data.RowHints)182- colHintRows := maxHintDepth(data.ColHints)198+ rowHints := normalizeNonogramHintsForRender(data.RowHints, data.Height)199+ colHints := normalizeNonogramHintsForRender(data.ColHints, data.Width)200+201+ rowHintCols := maxHintDepth(rowHints)202+ colHintRows := maxHintDepth(colHints)183203 if rowHintCols < 1 {184204 rowHintCols = 1185205 }
@@ -190,58 +210,398 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {
190210 totalCols := rowHintCols + data.Width191211 totalRows := colHintRows + data.Height192212 193- availW := pageW - 2*marginX194- availH := pageH - top - bottom213+ availW := pageW - 2*(marginX+padding)214+ availH := pageH - top - bottom - 2*padding195215 cellSize := math.Min(availW/float64(totalCols), availH/float64(totalRows))196- if cellSize > 8.6 {197- cellSize = 8.6216+ if cellSize > 8.8 {217+ cellSize = 8.8218+ }219+ if cellSize < 3.2 {220+ cellSize = 3.2198221 }199222 200223 blockW := float64(totalCols) * cellSize201224 blockH := float64(totalRows) * cellSize202- startX := (pageW - blockW) / 2203- startY := top + (availH-blockH)/2225+ gridW := float64(data.Width) * cellSize226+ gridH := float64(data.Height) * cellSize227+ startX := marginX + padding + (availW-blockW)/2228+ startY := top + padding + (availH-blockH)/2229+230+ xSep := startX + float64(rowHintCols)*cellSize231+ ySep := startY + float64(colHintRows)*cellSize232+233+ for row := 0; row < colHintRows; row++ {234+ for col := 0; col < data.Width; col++ {235+ cellX := xSep + float64(col)*cellSize236+ cellY := startY + float64(row)*cellSize237+ if text := colHintText(colHints[col], colHintRows, row); text != "" {238+ drawNonogramHintText(pdf, cellX, cellY, cellSize, cellSize, text)239+ }240+ }241+ }242+243+ for row := 0; row < data.Height; row++ {244+ for col := 0; col < rowHintCols; col++ {245+ cellX := startX + float64(col)*cellSize246+ cellY := ySep + float64(row)*cellSize247+ if text := rowHintText(rowHints[row], rowHintCols, col); text != "" {248+ drawNonogramHintText(pdf, cellX, cellY, cellSize, cellSize, text)249+ }250+ }251+ }252+253+ drawNonogramPuzzleGrid(pdf, xSep, ySep, data.Width, data.Height, cellSize)254+255+ drawNonogramMajorLines(pdf, xSep, ySep, cellSize, data.Width, data.Height, 5)256+257+ pdf.SetLineWidth(0.60)258+ pdf.Rect(xSep, ySep, gridW, gridH, "D")259+}260+261+func drawNonogramPuzzleGrid(262+ pdf *fpdf.Fpdf,263+ startX,264+ startY float64,265+ width,266+ height int,267+ cellSize float64,268+) {269+ if width <= 0 || height <= 0 || cellSize <= 0 {270+ return271+ }272+273+ gridW := float64(width) * cellSize274+ gridH := float64(height) * cellSize204275 205276 pdf.SetDrawColor(45, 45, 45)206277 pdf.SetLineWidth(0.12)207- for r := 0; r < totalRows; r++ {208- for c := 0; c < totalCols; c++ {209- x := startX + float64(c)*cellSize210- y := startY + float64(r)*cellSize211- pdf.Rect(x, y, cellSize, cellSize, "D")278+ for col := 0; col <= width; col++ {279+ x := startX + float64(col)*cellSize280+ pdf.Line(x, startY, x, startY+gridH)281+ }282+ for row := 0; row <= height; row++ {283+ y := startY + float64(row)*cellSize284+ pdf.Line(startX, y, startX+gridW, y)285+ }286+}212287 213- switch {214- case r < colHintRows && c >= rowHintCols:215- col := c - rowHintCols216- if text := colHintText(data.ColHints[col], colHintRows, r); text != "" {217- drawCellText(pdf, x, y, cellSize, cellSize, text, true)218- }219- case r >= colHintRows && c < rowHintCols:220- row := r - colHintRows221- if text := rowHintText(data.RowHints[row], rowHintCols, c); text != "" {222- drawCellText(pdf, x, y, cellSize, cellSize, text, true)223- }224- case r >= colHintRows && c >= rowHintCols:225- row := r - colHintRows226- col := c - rowHintCols227- if row < len(data.Grid) && col < len(data.Grid[row]) {228- cellText := strings.TrimSpace(data.Grid[row][col])229- if cellText != "" && cellText != "." {230- drawCellText(pdf, x, y, cellSize, cellSize, cellText, false)231- }232- }288+func drawNonogramHintText(pdf *fpdf.Fpdf, x, y, w, h float64, text string) {289+ if strings.TrimSpace(text) == "" {290+ return291+ }292+293+ // Darker, compact hint typography for dense nonograms.294+ pdf.SetTextColor(55, 55, 55)295+ fontSize := standardCellFontSize(h, 0.70)296+ pdf.SetFont("Helvetica", "", fontSize)297+ lineH := fontSize * 0.86298+ pdf.SetXY(x, y+(h-lineH)/2)299+ pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "")300+}301+302+func renderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) {303+ if data == nil {304+ return305+ }306+307+ pageW, pageH := pdf.GetPageSize()308+ marginX := 10.0309+ top := 28.0310+ bottom := 16.0311+ availW := pageW - 2*marginX312+ availH := pageH - top - bottom313+314+ cellSize := math.Min(availW/9.0, availH/9.0)315+ if cellSize > 12.5 {316+ cellSize = 12.5317+ }318+ if cellSize < 10.5 {319+ cellSize = 10.5320+ }321+322+ boardW := 9.0 * cellSize323+ boardH := 9.0 * cellSize324+ startX := (pageW - boardW) / 2325+ startY := top + (availH-boardH)/2326+327+ drawSudokuGridLines(pdf, startX, startY, cellSize)328+ drawSudokuGivens(pdf, startX, startY, cellSize, data.Givens)329+330+ pdf.SetFont("Helvetica", "", 8)331+ pdf.SetTextColor(85, 85, 85)332+ pdf.SetXY(marginX, pageH-11)333+ pdf.CellFormat(pageW-2*marginX, 5, "Fill rows, columns, and 3x3 boxes with 1-9", "", 0, "C", false, 0, "")334+}335+336+func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) {337+ pdf.SetDrawColor(25, 25, 25)338+339+ for i := range 10 {340+ x := startX + float64(i)*cellSize341+ pdf.SetLineWidth(sudokuLineWidthFor(i))342+ pdf.Line(x, startY, x, startY+9.0*cellSize)343+ }344+345+ for i := range 10 {346+ y := startY + float64(i)*cellSize347+ pdf.SetLineWidth(sudokuLineWidthFor(i))348+ pdf.Line(startX, y, startX+9.0*cellSize, y)349+ }350+}351+352+func sudokuLineWidthFor(index int) float64 {353+ switch {354+ case index == 0 || index == 9:355+ return 0.70356+ case index%3 == 0:357+ return 0.55358+ default:359+ return 0.15360+ }361+}362+363+func drawSudokuGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [9][9]int) {364+ fontSize := standardCellFontSize(cellSize, 0.62)365+ lineH := fontSize * 0.85366+ pdf.SetFont("Helvetica", "B", fontSize)367+ pdf.SetTextColor(20, 20, 20)368+369+ for y := range 9 {370+ for x := range 9 {371+ value := givens[y][x]372+ if value < 1 || value > 9 {373+ continue233374 }375+ cellX := startX + float64(x)*cellSize376+ cellY := startY + float64(y)*cellSize377+ pdf.SetXY(cellX, cellY+(cellSize-lineH)/2)378+ pdf.CellFormat(cellSize, lineH, strconv.Itoa(value), "", 0, "C", false, 0, "")234379 }235380 }381+}236382 237- xSep := startX + float64(rowHintCols)*cellSize238- ySep := startY + float64(colHintRows)*cellSize239- pdf.SetLineWidth(0.4)240- pdf.Line(xSep, startY, xSep, startY+blockH)241- pdf.Line(startX, ySep, startX+blockW, ySep)383+func renderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) {384+ if data == nil || data.Width <= 0 || data.Height <= 0 {385+ return386+ }387+388+ pageW, pageH := pdf.GetPageSize()389+ marginX := 10.0390+ top := 28.0391+ bottom := 12.0392+ availW := pageW - 2*marginX393+ availH := pageH - top - bottom394+395+ columnCount := wordSearchColumnCount(data.Width, len(data.Words))396+ wordFontSize := 8.3397+ wordLineHeight := 4.2398+ gridListGap := 4.0399+400+ estimatedWordLines := estimateWordBankLineCount(pdf, data.Words, columnCount, availW, wordFontSize)401+ wordBankHeight := 7.0 + float64(estimatedWordLines)*wordLineHeight402+ maxWordBankHeight := availH * 0.42403+ if wordBankHeight > maxWordBankHeight {404+ wordBankHeight = maxWordBankHeight405+ }406+ if wordBankHeight < 16 {407+ wordBankHeight = 16408+ }409+410+ gridAreaH := availH - wordBankHeight - gridListGap411+ if gridAreaH < availH*0.5 {412+ gridAreaH = availH * 0.5413+ }414+415+ cellSize := math.Min(availW/float64(data.Width), gridAreaH/float64(data.Height))416+ if cellSize > 10.5 {417+ cellSize = 10.5418+ }419+ if cellSize < 4.2 {420+ cellSize = 4.2421+ }422+423+ gridW := float64(data.Width) * cellSize424+ gridH := float64(data.Height) * cellSize425+ gridX := (pageW - gridW) / 2426+ gridY := top + (gridAreaH-gridH)/2427+428+ drawWordSearchGrid(pdf, data, gridX, gridY, cellSize)429+ drawWordBank(pdf, data.Words, marginX, gridY+gridH+gridListGap, availW, pageH-bottom-(gridY+gridH+gridListGap), columnCount)430+}431+432+func drawWordSearchGrid(pdf *fpdf.Fpdf, data *WordSearchData, startX, startY, cellSize float64) {433+ pdf.SetDrawColor(45, 45, 45)434+ pdf.SetLineWidth(0.12)435+ for y := range data.Height {436+ for x := range data.Width {437+ cellX := startX + float64(x)*cellSize438+ cellY := startY + float64(y)*cellSize439+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")440+441+ cellText := " "442+ if y < len(data.Grid) && x < len(data.Grid[y]) {443+ cellText = strings.TrimSpace(strings.ToUpper(data.Grid[y][x]))444+ }445+ if cellText == "" || cellText == "." {446+ continue447+ }448+449+ fontSize := standardCellFontSize(cellSize, 0.74)450+ lineH := fontSize * 0.86451+ pdf.SetFont("Helvetica", "B", fontSize)452+ pdf.SetTextColor(18, 18, 18)453+ pdf.SetXY(cellX, cellY+(cellSize-lineH)/2)454+ pdf.CellFormat(cellSize, lineH, cellText, "", 0, "C", false, 0, "")455+ }456+ }242457 243458 pdf.SetLineWidth(0.55)244- pdf.Rect(startX, startY, blockW, blockH, "D")459+ pdf.Rect(startX, startY, float64(data.Width)*cellSize, float64(data.Height)*cellSize, "D")460+}461+462+func drawWordBank(pdf *fpdf.Fpdf, words []string, x, y, width, height float64, columns int) {463+ if height <= 0 {464+ return465+ }466+467+ pdf.SetTextColor(40, 40, 40)468+ pdf.SetFont("Helvetica", "B", 9.2)469+ pdf.SetXY(x, y)470+ pdf.CellFormat(width, 4.8, "Word Bank", "", 0, "L", false, 0, "")471+472+ pdf.SetFont("Helvetica", "", 8.3)473+ pdf.SetTextColor(70, 70, 70)474+ pdf.SetXY(x, y+4.8)475+ pdf.CellFormat(width, 4.2, "Words may run in all 8 directions", "", 0, "L", false, 0, "")476+477+ listY := y + 9.0478+ if len(words) == 0 {479+ pdf.SetFont("Helvetica", "", 8.8)480+ pdf.SetTextColor(95, 95, 95)481+ pdf.SetXY(x, listY)482+ pdf.CellFormat(width, 4.6, "(word list unavailable)", "", 0, "L", false, 0, "")483+ return484+ }485+486+ columnGap := 4.0487+ if columns < 1 {488+ columns = 1489+ }490+ colWidth := (width - float64(columns-1)*columnGap) / float64(columns)491+ if colWidth <= 0 {492+ colWidth = width493+ columns = 1494+ }495+496+ colLines := layoutWordBankColumns(pdf, words, columns, colWidth)497+ lineHeight := 4.1498+ maxLines := int(height / lineHeight)499+ if maxLines <= 0 {500+ return501+ }502+503+ pdf.SetTextColor(25, 25, 25)504+ pdf.SetFont("Helvetica", "", 8.8)505+ for c := range columns {506+ colX := x + float64(c)*(colWidth+columnGap)507+ curY := listY508+ lines := colLines[c]509+ for _, line := range lines {510+ if int((curY-listY)/lineHeight) >= maxLines {511+ break512+ }513+ pdf.SetXY(colX, curY)514+ pdf.CellFormat(colWidth, lineHeight, line, "", 0, "L", false, 0, "")515+ curY += lineHeight516+ }517+ }518+}519+520+func wordSearchColumnCount(width, wordCount int) int {521+ if width >= 20 || wordCount > 18 {522+ return 3523+ }524+ if wordCount <= 0 {525+ return 1526+ }527+ return 2528+}529+530+func estimateWordBankLineCount(pdf *fpdf.Fpdf, words []string, columns int, availW, fontSize float64) int {531+ if len(words) == 0 {532+ return 1533+ }534+ if columns < 1 {535+ columns = 1536+ }537+538+ colWidth := (availW - float64(columns-1)*4.0) / float64(columns)539+ if colWidth <= 0 {540+ colWidth = availW541+ }542+543+ pdf.SetFont("Helvetica", "", fontSize)544+ lineCounts := make([]int, columns)545+ for _, word := range words {546+ text := strings.ToUpper(strings.TrimSpace(word))547+ if text == "" {548+ continue549+ }550+ lines := pdf.SplitLines([]byte(text), colWidth)551+ if len(lines) == 0 {552+ lines = [][]byte{[]byte(text)}553+ }554+ idx := minLineCountColumn(lineCounts)555+ lineCounts[idx] += len(lines)556+ }557+558+ maxLines := 0559+ for _, lineCount := range lineCounts {560+ if lineCount > maxLines {561+ maxLines = lineCount562+ }563+ }564+ if maxLines == 0 {565+ return 1566+ }567+ return maxLines568+}569+570+func layoutWordBankColumns(pdf *fpdf.Fpdf, words []string, columns int, colWidth float64) [][]string {571+ colLines := make([][]string, columns)572+ if len(words) == 0 || columns <= 0 {573+ return colLines574+ }575+576+ lineCounts := make([]int, columns)577+ for _, word := range words {578+ text := strings.ToUpper(strings.TrimSpace(word))579+ if text == "" {580+ continue581+ }582+ wrapped := pdf.SplitLines([]byte(text), colWidth)583+ if len(wrapped) == 0 {584+ wrapped = [][]byte{[]byte(text)}585+ }586+587+ idx := minLineCountColumn(lineCounts)588+ for _, line := range wrapped {589+ colLines[idx] = append(colLines[idx], string(line))590+ }591+ lineCounts[idx] += len(wrapped)592+ }593+594+ return colLines595+}596+597+func minLineCountColumn(lineCounts []int) int {598+ idx := 0599+ for i := 1; i < len(lineCounts); i++ {600+ if lineCounts[i] < lineCounts[idx] {601+ idx = i602+ }603+ }604+ return idx245605 }246606 247607 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) {
399759 pdf.SetTextColor(25, 25, 25)400760 }401761 402- fontSize := math.Max(3.1, math.Min(6.3, h*0.63))762+ fontSize := standardCellFontSize(h, 0.63)403763 pdf.SetFont("Helvetica", "", fontSize)404764 lineH := fontSize * 0.9405765 pdf.SetXY(x, y+(h-lineH)/2)
@@ -438,6 +798,48 @@ func maxHintDepth(hints [][]int) int {
438798 return maxDepth439799 }440800 801+func normalizeNonogramHintsForRender(hints [][]int, size int) [][]int {802+ if size <= 0 {803+ return nil804+ }805+806+ normalized := make([][]int, size)807+ for i := 0; i < size; i++ {808+ if i >= len(hints) || len(hints[i]) == 0 {809+ normalized[i] = []int{0}810+ continue811+ }812+ normalized[i] = append([]int(nil), hints[i]...)813+ }814+ return normalized815+}816+817+func drawNonogramMajorLines(818+ pdf *fpdf.Fpdf,819+ puzzleStartX,820+ puzzleStartY,821+ cellSize float64,822+ width,823+ height,824+ step int,825+) {826+ if step <= 0 || width <= 0 || height <= 0 {827+ return828+ }829+830+ pdf.SetDrawColor(45, 45, 45)831+ pdf.SetLineWidth(0.30)832+833+ for col := step; col < width; col += step {834+ x := puzzleStartX + float64(col)*cellSize835+ pdf.Line(x, puzzleStartY, x, puzzleStartY+float64(height)*cellSize)836+ }837+ for row := step; row < height; row += step {838+ y := puzzleStartY + float64(row)*cellSize839+ pdf.Line(puzzleStartX, y, puzzleStartX+float64(width)*cellSize, y)840+ }841+}842+441843 func summarizeCategories(puzzles []Puzzle) []string {442844 set := map[string]struct{}{}443845 for _, p := range puzzles {
@@ -469,9 +871,132 @@ func defaultTitle(docs []PackDocument) string {
469871 return "PuzzleTea Mixed Puzzle Pack"470872 }471873 874+func renderSourceExportsTable(875+ pdf *fpdf.Fpdf,876+ docs []PackDocument,877+ x, y, width, maxY float64,878+) float64 {879+ if width <= 0 || y >= maxY {880+ return y881+ }882+883+ headers := []string{"Source", "Category", "Mode", "Count", "Seed"}884+ columnRatios := []float64{0.33, 0.20, 0.22, 0.10, 0.15}885+ columnWidths := make([]float64, len(columnRatios))886+ usedWidth := 0.0887+ for i := 0; i < len(columnRatios)-1; i++ {888+ columnWidths[i] = width * columnRatios[i]889+ usedWidth += columnWidths[i]890+ }891+ columnWidths[len(columnWidths)-1] = width - usedWidth892+893+ headerHeight := 5.2894+ rowHeight := 4.8895+ availableRowsHeight := maxY - y - headerHeight896+ if availableRowsHeight < rowHeight {897+ return y898+ }899+ maxRows := int(math.Floor(availableRowsHeight / rowHeight))900+ if maxRows < 1 {901+ return y902+ }903+904+ rowCount := len(docs)905+ if rowCount > maxRows {906+ rowCount = maxRows907+ }908+909+ pdf.SetDrawColor(125, 125, 125)910+ pdf.SetLineWidth(0.14)911+ pdf.SetFillColor(245, 245, 245)912+ pdf.SetTextColor(45, 45, 45)913+ pdf.SetFont("Helvetica", "B", 8.4)914+915+ curX := x916+ for i, header := range headers {917+ pdf.SetXY(curX, y)918+ pdf.CellFormat(columnWidths[i], headerHeight, header, "1", 0, "C", true, 0, "")919+ curX += columnWidths[i]920+ }921+922+ pdf.SetFont("Helvetica", "", 8.1)923+ pdf.SetTextColor(70, 70, 70)924+ for i := 0; i < rowCount; i++ {925+ rowY := y + headerHeight + float64(i)*rowHeight926+ mode := ""927+ if !isMixedModes(docs[i].Metadata.ModeSelection) {928+ mode = docs[i].Metadata.ModeSelection929+ }930+931+ values := []string{932+ docs[i].Metadata.SourceFileName,933+ docs[i].Metadata.Category,934+ mode,935+ strconv.Itoa(docs[i].Metadata.Count),936+ emptyAs(docs[i].Metadata.Seed, "none"),937+ }938+939+ curX = x940+ for col := range values {941+ cellText := fitTableCellText(pdf, values[col], columnWidths[col]-1.6)942+ align := "L"943+ if col == 3 {944+ align = "C"945+ }946+ pdf.SetXY(curX, rowY)947+ pdf.CellFormat(columnWidths[col], rowHeight, cellText, "1", 0, align, false, 0, "")948+ curX += columnWidths[col]949+ }950+ }951+952+ return y + headerHeight + float64(rowCount)*rowHeight953+}954+955+func fitTableCellText(pdf *fpdf.Fpdf, text string, maxWidth float64) string {956+ text = strings.TrimSpace(text)957+ if text == "" {958+ return ""959+ }960+ if maxWidth <= 0 {961+ return ""962+ }963+ if pdf.GetStringWidth(text) <= maxWidth {964+ return text965+ }966+967+ ellipsis := "..."968+ if pdf.GetStringWidth(ellipsis) > maxWidth {969+ return ellipsis970+ }971+972+ runes := []rune(text)973+ for len(runes) > 0 {974+ candidate := string(runes) + ellipsis975+ if pdf.GetStringWidth(candidate) <= maxWidth {976+ return candidate977+ }978+ runes = runes[:len(runes)-1]979+ }980+ return ellipsis981+}982+472983 func emptyAs(v, fallback string) string {473984 if strings.TrimSpace(v) == "" {474985 return fallback475986 }476987 return v477988 }989+990+func difficultyScoreOutOfTen(score float64) int {991+ if score < 0 {992+ score = 0993+ }994+ if score > 1 {995+ score = 1996+ }997+ return int(math.Round(score * 10))998+}999+1000+func isMixedModes(mode string) bool {1001+ return normalizeToken(mode) == "mixed modes"1002+}
A pdfexport/render_hashi.go +89 -0
@@ -0,0 +1,89 @@
1+package pdfexport2+3+import (4+ "math"5+ "strconv"6+ "unicode/utf8"7+8+ "github.com/go-pdf/fpdf"9+)10+11+func renderHashiPage(pdf *fpdf.Fpdf, data *HashiData) {12+ if data == nil || data.Width <= 0 || data.Height <= 0 {13+ return14+ }15+16+ pageW, pageH := pdf.GetPageSize()17+ marginX := 10.018+ top := 28.019+ bottom := 22.020+21+ availW := pageW - 2*marginX22+ availH := pageH - top - bottom23+24+ spanX := max(data.Width-1, 1)25+ spanY := max(data.Height-1, 1)26+ step := math.Min(availW/float64(spanX), availH/float64(spanY))27+ step = math.Max(5.2, math.Min(16.0, step))28+29+ boardW := float64(spanX) * step30+ boardH := float64(spanY) * step31+ originX := (pageW - boardW) / 232+ originY := top + (availH-boardH)/233+34+ drawHashiGuideDots(pdf, originX, originY, data.Width, data.Height, step)35+ drawHashiIslands(pdf, originX, originY, step, data.Islands)36+37+ ruleY := originY + boardH + 538+ if ruleY+4 <= pageH-6 {39+ pdf.SetTextColor(85, 85, 85)40+ pdf.SetFont("Helvetica", "", 7.2)41+ pdf.SetXY(marginX, ruleY)42+ pdf.CellFormat(pageW-2*marginX, 4, "Connect islands horizontally/vertically with up to two bridges and no crossings.", "", 0, "C", false, 0, "")43+ }44+}45+46+func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height int, step float64) {47+ pdf.SetFillColor(235, 235, 235)48+ r := math.Max(0.20, math.Min(0.55, step*0.035))49+ for y := 0; y < height; y++ {50+ for x := 0; x < width; x++ {51+ cx := originX + float64(x)*step52+ cy := originY + float64(y)*step53+ pdf.Circle(cx, cy, r, "F")54+ }55+ }56+}57+58+func drawHashiIslands(pdf *fpdf.Fpdf, originX, originY, step float64, islands []HashiIsland) {59+ radius := math.Max(1.4, math.Min(3.2, step*0.23))60+ pdf.SetDrawColor(20, 20, 20)61+ pdf.SetFillColor(255, 255, 255)62+ pdf.SetLineWidth(0.26)63+64+ for _, island := range islands {65+ cx := originX + float64(island.X)*step66+ cy := originY + float64(island.Y)*step67+ pdf.Circle(cx, cy, radius, "DF")68+ drawHashiIslandNumber(pdf, cx, cy, radius, island.Required)69+ }70+}71+72+func drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int) {73+ text := strconv.Itoa(required)74+ fontSize := standardCellFontSize(radius*2.0, 0.95)75+ runeCount := utf8.RuneCountInString(text)76+ switch {77+ case runeCount >= 3:78+ fontSize *= 0.7079+ case runeCount == 2:80+ fontSize *= 0.8281+ }82+ fontSize = clampStandardCellFontSize(fontSize)83+84+ pdf.SetTextColor(18, 18, 18)85+ pdf.SetFont("Helvetica", "B", fontSize)86+ lineH := fontSize * 0.8887+ pdf.SetXY(cx-radius, cy-lineH/2)88+ pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "")89+}
A pdfexport/render_hitori_takuzu.go +184 -0
@@ -0,0 +1,184 @@
1+package pdfexport2+3+import (4+ "math"5+ "strings"6+ "unicode/utf8"7+8+ "github.com/go-pdf/fpdf"9+)10+11+func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) {12+ if data == nil || data.Size <= 0 {13+ return14+ }15+16+ size := data.Size17+ pageW, pageH := pdf.GetPageSize()18+ marginX := 10.019+ top := 28.020+ bottom := 22.021+22+ availW := pageW - 2*marginX23+ availH := pageH - top - bottom24+ cellSize := math.Min(availW/float64(size), availH/float64(size))25+ cellSize = math.Max(5.0, math.Min(12.0, cellSize))26+27+ blockW := float64(size) * cellSize28+ blockH := float64(size) * cellSize29+ startX := (pageW - blockW) / 230+ startY := top + (availH-blockH)/231+32+ pdf.SetDrawColor(55, 55, 55)33+ pdf.SetLineWidth(0.16)34+ for y := 0; y < size; y++ {35+ for x := 0; x < size; x++ {36+ cellX := startX + float64(x)*cellSize37+ cellY := startY + float64(y)*cellSize38+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")39+40+ text := ""41+ if y < len(data.Numbers) && x < len(data.Numbers[y]) {42+ text = strings.TrimSpace(data.Numbers[y][x])43+ }44+ if text == "" || text == "." {45+ continue46+ }47+ drawHitoriCellNumber(pdf, cellX, cellY, cellSize, text)48+ }49+ }50+51+ pdf.SetDrawColor(35, 35, 35)52+ pdf.SetLineWidth(0.62)53+ pdf.Rect(startX, startY, blockW, blockH, "D")54+55+ ruleY := startY + blockH + 556+ if ruleY+4 <= pageH-6 {57+ pdf.SetTextColor(85, 85, 85)58+ pdf.SetFont("Helvetica", "", 7.3)59+ pdf.SetXY(marginX, ruleY)60+ pdf.CellFormat(61+ pageW-2*marginX,62+ 4,63+ "Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected.",64+ "",65+ 0,66+ "C",67+ false,68+ 0,69+ "",70+ )71+ }72+}73+74+func drawHitoriCellNumber(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) {75+ fontSize := standardCellFontSize(cellSize, 0.58)76+ runeCount := utf8.RuneCountInString(text)77+ switch {78+ case runeCount >= 3:79+ fontSize *= 0.780+ case runeCount == 2:81+ fontSize *= 0.8282+ }83+ fontSize = clampStandardCellFontSize(fontSize)84+85+ pdf.SetTextColor(20, 20, 20)86+ pdf.SetFont("Helvetica", "B", fontSize)87+ lineH := fontSize * 0.9288+ pdf.SetXY(x, y+(cellSize-lineH)/2)89+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")90+}91+92+func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) {93+ if data == nil || data.Size <= 0 {94+ return95+ }96+97+ size := data.Size98+ pageW, pageH := pdf.GetPageSize()99+ marginX := 10.0100+ top := 28.0101+ bottom := 24.0102+103+ availW := pageW - 2*marginX104+ availH := pageH - top - bottom105+ cellSize := math.Min(availW/float64(size), availH/float64(size))106+ cellSize = math.Max(4.4, math.Min(11.0, cellSize))107+108+ blockW := float64(size) * cellSize109+ blockH := float64(size) * cellSize110+ startX := (pageW - blockW) / 2111+ startY := top + (availH-blockH)/2112+113+ pdf.SetDrawColor(60, 60, 60)114+ pdf.SetLineWidth(0.14)115+ for y := 0; y < size; y++ {116+ for x := 0; x < size; x++ {117+ cellX := startX + float64(x)*cellSize118+ cellY := startY + float64(y)*cellSize119+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")120+ }121+ }122+123+ if data.GroupEveryTwo {124+ pdf.SetDrawColor(145, 145, 145)125+ pdf.SetLineWidth(0.24)126+ for i := 2; i < size; i += 2 {127+ x := startX + float64(i)*cellSize128+ y := startY + float64(i)*cellSize129+ pdf.Line(x, startY, x, startY+blockH)130+ pdf.Line(startX, y, startX+blockW, y)131+ }132+ }133+134+ for y := 0; y < size; y++ {135+ for x := 0; x < size; x++ {136+ text := ""137+ if y < len(data.Givens) && x < len(data.Givens[y]) {138+ text = strings.TrimSpace(data.Givens[y][x])139+ }140+ if text == "" {141+ continue142+ }143+144+ cellX := startX + float64(x)*cellSize145+ cellY := startY + float64(y)*cellSize146+ drawTakuzuGiven(pdf, cellX, cellY, cellSize, size, text)147+ }148+ }149+150+ pdf.SetDrawColor(35, 35, 35)151+ pdf.SetLineWidth(0.60)152+ pdf.Rect(startX, startY, blockW, blockH, "D")153+154+ ruleY := startY + blockH + 4155+ if ruleY+8 <= pageH-6 {156+ pdf.SetTextColor(85, 85, 85)157+ pdf.SetFont("Helvetica", "", 7.1)158+ pdf.SetXY(marginX, ruleY)159+ pdf.CellFormat(pageW-2*marginX, 4, "No three equal adjacent in any row or column.", "", 0, "C", false, 0, "")160+ pdf.SetXY(marginX, ruleY+4)161+ pdf.CellFormat(pageW-2*marginX, 4, "Each row/column has equal 0 and 1 counts, and rows/columns are unique.", "", 0, "C", false, 0, "")162+ }163+}164+165+func drawTakuzuGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, size int, text string) {166+ fontSize := takuzuGivenFontSize(cellSize, size)167+168+ pdf.SetTextColor(15, 15, 15)169+ pdf.SetFont("Helvetica", "B", fontSize)170+ lineH := fontSize * 0.9171+ pdf.SetXY(x, y+(cellSize-lineH)/2)172+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")173+}174+175+func takuzuGivenFontSize(cellSize float64, size int) float64 {176+ fontSize := standardCellFontSize(cellSize, 0.68)177+ switch {178+ case size >= 14:179+ fontSize *= 0.94180+ case size >= 12:181+ fontSize *= 0.97182+ }183+ return clampStandardCellFontSize(fontSize)184+}
A pdfexport/render_nonogram_test.go +70 -0
@@ -0,0 +1,70 @@
1+package pdfexport2+3+import "testing"4+5+func TestNormalizeNonogramHintsForRender(t *testing.T) {6+ tests := []struct {7+ name string8+ hints [][]int9+ size int10+ want [][]int11+ }{12+ {13+ name: "empty source defaults to zeros",14+ hints: nil,15+ size: 3,16+ want: [][]int{{0}, {0}, {0}},17+ },18+ {19+ name: "preserves provided hint rows",20+ hints: [][]int{{3, 1}, {}, {2}},21+ size: 3,22+ want: [][]int{{3, 1}, {0}, {2}},23+ },24+ {25+ name: "pads beyond provided rows",26+ hints: [][]int{{1}},27+ size: 3,28+ want: [][]int{{1}, {0}, {0}},29+ },30+ {31+ name: "non-positive size returns nil",32+ hints: [][]int{{1}},33+ size: 0,34+ want: nil,35+ },36+ }37+38+ for _, tt := range tests {39+ t.Run(tt.name, func(t *testing.T) {40+ got := normalizeNonogramHintsForRender(tt.hints, tt.size)41+ if len(got) != len(tt.want) {42+ t.Fatalf("rows = %d, want %d", len(got), len(tt.want))43+ }44+ for row := range len(tt.want) {45+ if len(got[row]) != len(tt.want[row]) {46+ t.Fatalf("row %d len = %d, want %d", row, len(got[row]), len(tt.want[row]))47+ }48+ for col := range len(tt.want[row]) {49+ if got[row][col] != tt.want[row][col] {50+ t.Fatalf("row %d col %d = %d, want %d", row, col, got[row][col], tt.want[row][col])51+ }52+ }53+ }54+ })55+ }56+}57+58+func TestNormalizeNonogramHintsForRenderCopiesRows(t *testing.T) {59+ src := [][]int{{2, 1}}60+61+ got := normalizeNonogramHintsForRender(src, 1)62+ if got == nil || len(got) != 1 {63+ t.Fatalf("unexpected normalized hints: %#v", got)64+ }65+66+ src[0][0] = 967+ if got[0][0] != 2 {68+ t.Fatalf("normalized hint should be copied, got %d want 2", got[0][0])69+ }70+}
A pdfexport/render_nurikabe_shikaku.go +124 -0
@@ -0,0 +1,124 @@
1+package pdfexport2+3+import (4+ "math"5+ "strconv"6+ "unicode/utf8"7+8+ "github.com/go-pdf/fpdf"9+)10+11+func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) {12+ if data == nil || data.Width <= 0 || data.Height <= 0 {13+ return14+ }15+16+ pageW, pageH := pdf.GetPageSize()17+ marginX := 10.018+ top := 28.019+ bottom := 22.020+21+ availW := pageW - 2*marginX22+ availH := pageH - top - bottom23+ cellSize := math.Min(availW/float64(data.Width), availH/float64(data.Height))24+ cellSize = math.Max(4.8, math.Min(12.0, cellSize))25+26+ blockW := float64(data.Width) * cellSize27+ blockH := float64(data.Height) * cellSize28+ startX := (pageW - blockW) / 229+ startY := top + (availH-blockH)/230+31+ pdf.SetDrawColor(55, 55, 55)32+ pdf.SetLineWidth(0.15)33+ for y := 0; y < data.Height; y++ {34+ for x := 0; x < data.Width; x++ {35+ cellX := startX + float64(x)*cellSize36+ cellY := startY + float64(y)*cellSize37+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")38+39+ if y >= len(data.Clues) || x >= len(data.Clues[y]) || data.Clues[y][x] <= 0 {40+ continue41+ }42+ drawRectanglePuzzleClue(pdf, cellX, cellY, cellSize, data.Clues[y][x])43+ }44+ }45+46+ pdf.SetDrawColor(35, 35, 35)47+ pdf.SetLineWidth(0.62)48+ pdf.Rect(startX, startY, blockW, blockH, "D")49+50+ ruleY := startY + blockH + 551+ if ruleY+4 <= pageH-6 {52+ pdf.SetTextColor(85, 85, 85)53+ pdf.SetFont("Helvetica", "", 7.2)54+ pdf.SetXY(marginX, ruleY)55+ pdf.CellFormat(pageW-2*marginX, 4, "Expand each numbered island to its size; connect all sea cells into one wall.", "", 0, "C", false, 0, "")56+ }57+}58+59+func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) {60+ if data == nil || data.Width <= 0 || data.Height <= 0 {61+ return62+ }63+64+ pageW, pageH := pdf.GetPageSize()65+ marginX := 10.066+ top := 28.067+ bottom := 22.068+69+ availW := pageW - 2*marginX70+ availH := pageH - top - bottom71+ cellSize := math.Min(availW/float64(data.Width), availH/float64(data.Height))72+ cellSize = math.Max(4.8, math.Min(12.0, cellSize))73+74+ blockW := float64(data.Width) * cellSize75+ blockH := float64(data.Height) * cellSize76+ startX := (pageW - blockW) / 277+ startY := top + (availH-blockH)/278+79+ pdf.SetDrawColor(55, 55, 55)80+ pdf.SetLineWidth(0.15)81+ for y := 0; y < data.Height; y++ {82+ for x := 0; x < data.Width; x++ {83+ cellX := startX + float64(x)*cellSize84+ cellY := startY + float64(y)*cellSize85+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")86+87+ if y >= len(data.Clues) || x >= len(data.Clues[y]) || data.Clues[y][x] <= 0 {88+ continue89+ }90+ drawRectanglePuzzleClue(pdf, cellX, cellY, cellSize, data.Clues[y][x])91+ }92+ }93+94+ pdf.SetDrawColor(35, 35, 35)95+ pdf.SetLineWidth(0.62)96+ pdf.Rect(startX, startY, blockW, blockH, "D")97+98+ ruleY := startY + blockH + 599+ if ruleY+4 <= pageH-6 {100+ pdf.SetTextColor(85, 85, 85)101+ pdf.SetFont("Helvetica", "", 7.2)102+ pdf.SetXY(marginX, ruleY)103+ pdf.CellFormat(pageW-2*marginX, 4, "Partition into rectangles where each clue equals its rectangle area.", "", 0, "C", false, 0, "")104+ }105+}106+107+func drawRectanglePuzzleClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) {108+ text := strconv.Itoa(value)109+ fontSize := standardCellFontSize(cellSize, 0.58)110+ runeCount := utf8.RuneCountInString(text)111+ switch {112+ case runeCount >= 3:113+ fontSize *= 0.72114+ case runeCount == 2:115+ fontSize *= 0.84116+ }117+ fontSize = clampStandardCellFontSize(fontSize)118+119+ pdf.SetTextColor(20, 20, 20)120+ pdf.SetFont("Helvetica", "B", fontSize)121+ lineH := fontSize * 0.92122+ pdf.SetXY(x, y+(cellSize-lineH)/2)123+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")124+}
A pdfexport/render_takuzu_test.go +44 -0
M pdfexport/types.go +54 -0