clone

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

print unification and new font with proper license inclusion

Dami committed 8d700de
commit
8d700dec92f33d404c169028fa0b580df5c0c7c2
parent
c82d6433d7ca18173f959703aa2f43043ac7db28
author
Dami <dami.etoile@gmail.com>
19 files changed, +811 -536
MREADME.md9+-
Mcmd/export_pdf.go4+-
Mcmd/new_export.go76------
Mcmd/new_export_test.go63+-----
Mpdfexport/font.go4+-
Mpdfexport/font_test.go8+-
Apdfexport/fonts.go27++
Apdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttfbin
Apdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttfbin
Apdfexport/fonts/OFL.txt93++++++++
Mpdfexport/jsonl.go33+---
Mpdfexport/jsonl_test.go130++---------
Mpdfexport/render.go360+++++++++++++++++++-----------
Mpdfexport/render_hashi.go67++++--
Mpdfexport/render_hitori_takuzu.go119+++++-----
Apdfexport/render_layout_test.go104+++++++++
Mpdfexport/render_nurikabe_shikaku.go87++++---
Mpdfexport/render_takuzu_test.go4+-
Apdfexport/render_tokens.go159+++++++++++++
M README.md +8 -1
M cmd/export_pdf.go +2 -2
M cmd/new_export.go +0 -76
@@ -166,61 +166,6 @@ func buildExportRecords(
166166 return nil, fmt.Errorf("render puzzle %d: %w", i+1, err)167167 }168168 169- nonogram, table, err := pdfexport.ParsePrintableFromSnippet(gameType, snippet)170- if err != nil {171- return nil, fmt.Errorf("build print payload for puzzle %d: %w", i+1, err)172- }173-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-207- printKind := "text"208- if nonogram != nil {209- 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"220- } else if table != nil {221- printKind = "grid-table"222- }223-224169 records = append(records, pdfexport.JSONLRecord{225170 Schema: pdfexport.ExportSchemaV1,226171 Pack: pdfexport.JSONLPackMeta{
@@ -239,20 +184,6 @@ func buildExportRecords(
239184 Save: json.RawMessage(save),240185 Snippet: snippet,241186 },242- Print: pdfexport.JSONLPrintData{243- Kind: printKind,244- Paper: "A5",245- MarginMM: 10,246- EmptyGlyph: " ",247- HintTone: "dim",248- Nonogram: nonogram,249- Hashi: hashi,250- Nurikabe: nurikabe,251- Shikaku: shikaku,252- Sudoku: sudoku,253- WordSearch: wordSearch,254- Table: table,255- },256187 })257188 }258189
@@ -271,13 +202,6 @@ func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error)
271202 return seeded.SpawnSeeded(rng)272203 }273204 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-281205 func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error {282206 var b strings.Builder283207 for _, record := range records {
M cmd/new_export_test.go +4 -59
@@ -152,7 +152,7 @@ func TestRunNewExportOverwritesOutputFile(t *testing.T) {
152152 }153153 }154154 155-func TestRunNewExportSudokuPrintPayload(t *testing.T) {155+func TestRunNewExportOmitsPrintPayload(t *testing.T) {156156 withExportFlagReset(t)157157 flagExport = 1158158
@@ -166,67 +166,12 @@ func TestRunNewExportSudokuPrintPayload(t *testing.T) {
166166 t.Fatalf("jsonl lines = %d, want 1", len(lines))167167 }168168 169- var record pdfexport.JSONLRecord169+ var record map[string]any170170 if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {171171 t.Fatal(err)172172 }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")173+ if _, ok := record["print"]; ok {174+ t.Fatal("did not expect print payload in export record")230175 }231176 }232177
M pdfexport/font.go +2 -2
M pdfexport/font_test.go +4 -4
@@ -13,7 +13,7 @@ func TestStandardCellFontSizeBounds(t *testing.T) {
1313 name: "clamps low",1414 cellSize: 3.0,1515 scale: 0.6,16- want: 4.2,16+ want: 5.2,1717 },1818 {1919 name: "keeps in range",
@@ -25,7 +25,7 @@ func TestStandardCellFontSizeBounds(t *testing.T) {
2525 name: "clamps high",2626 cellSize: 20.0,2727 scale: 0.7,28- want: 8.0,28+ want: 8.2,2929 },3030 }3131
@@ -48,7 +48,7 @@ func TestClampStandardCellFontSizeBounds(t *testing.T) {
4848 {4949 name: "below min",5050 in: 3.9,51- want: 4.2,51+ want: 5.2,5252 },5353 {5454 name: "in range",
@@ -58,7 +58,7 @@ func TestClampStandardCellFontSizeBounds(t *testing.T) {
5858 {5959 name: "above max",6060 in: 9.1,61- want: 8.0,61+ want: 8.2,6262 },6363 }6464
A pdfexport/fonts.go +27 -0
A pdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttf +0 -0

Binary file.

A pdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf +0 -0

Binary file.

A pdfexport/fonts/OFL.txt +93 -0
@@ -0,0 +1,93 @@
1+Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next) 2+ 3+This Font Software is licensed under the SIL Open Font License, Version 1.1. 4+This license is copied below, and is also available with a FAQ at: 5+https://openfontlicense.org 6+ 7+ 8+----------------------------------------------------------- 9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10+----------------------------------------------------------- 11+ 12+PREAMBLE 13+The goals of the Open Font License (OFL) are to stimulate worldwide 14+development of collaborative font projects, to support the font creation 15+efforts of academic and linguistic communities, and to provide a free and 16+open framework in which fonts may be shared and improved in partnership 17+with others. 18+ 19+The OFL allows the licensed fonts to be used, studied, modified and 20+redistributed freely as long as they are not sold by themselves. The 21+fonts, including any derivative works, can be bundled, embedded, 22+redistributed and/or sold with any software provided that any reserved 23+names are not used by derivative works. The fonts and derivatives, 24+however, cannot be released under any other type of license. The 25+requirement for fonts to remain under this license does not apply 26+to any document created using the fonts or their derivatives. 27+ 28+DEFINITIONS 29+"Font Software" refers to the set of files released by the Copyright 30+Holder(s) under this license and clearly marked as such. This may 31+include source files, build scripts and documentation. 32+ 33+"Reserved Font Name" refers to any names specified as such after the 34+copyright statement(s). 35+ 36+"Original Version" refers to the collection of Font Software components as 37+distributed by the Copyright Holder(s). 38+ 39+"Modified Version" refers to any derivative made by adding to, deleting, 40+or substituting -- in part or in whole -- any of the components of the 41+Original Version, by changing formats or by porting the Font Software to a 42+new environment. 43+ 44+"Author" refers to any designer, engineer, programmer, technical 45+writer or other person who contributed to the Font Software. 46+ 47+PERMISSION & CONDITIONS 48+Permission is hereby granted, free of charge, to any person obtaining 49+a copy of the Font Software, to use, study, copy, merge, embed, modify, 50+redistribute, and sell modified and unmodified copies of the Font 51+Software, subject to the following conditions: 52+ 53+1) Neither the Font Software nor any of its individual components, 54+in Original or Modified Versions, may be sold by itself. 55+ 56+2) Original or Modified Versions of the Font Software may be bundled, 57+redistributed and/or sold with any software, provided that each copy 58+contains the above copyright notice and this license. These can be 59+included either as stand-alone text files, human-readable headers or 60+in the appropriate machine-readable metadata fields within text or 61+binary files as long as those fields can be easily viewed by the user. 62+ 63+3) No Modified Version of the Font Software may use the Reserved Font 64+Name(s) unless explicit written permission is granted by the corresponding 65+Copyright Holder. This restriction only applies to the primary font name as 66+presented to the users. 67+ 68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69+Software shall not be used to promote, endorse or advertise any 70+Modified Version, except to acknowledge the contribution(s) of the 71+Copyright Holder(s) and the Author(s) or with their explicit written 72+permission. 73+ 74+5) The Font Software, modified or unmodified, in part or in whole, 75+must be distributed entirely under this license, and must not be 76+distributed under any other license. The requirement for fonts to 77+remain under this license does not apply to any document created 78+using the Font Software. 79+ 80+TERMINATION 81+This license becomes null and void if any of the above conditions are 82+not met. 83+ 84+DISCLAIMER 85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93+OTHER DEALINGS IN THE FONT SOFTWARE.
M pdfexport/jsonl.go +3 -30
@@ -12,10 +12,9 @@ import (
1212 const ExportSchemaV1 = "puzzletea.export.v1"1313 1414 type JSONLRecord struct {15- Schema string `json:"schema"`16- Pack JSONLPackMeta `json:"pack"`17- Puzzle JSONLPuzzle `json:"puzzle"`18- Print JSONLPrintData `json:"print"`15+ Schema string `json:"schema"`16+ Pack JSONLPackMeta `json:"pack"`17+ Puzzle JSONLPuzzle `json:"puzzle"`1918 }2019 2120 type JSONLPackMeta struct {
@@ -36,23 +35,6 @@ type JSONLPuzzle struct {
3635 Snippet string `json:"snippet,omitempty"`3736 }3837 39-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- 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"`54-}55-5638 func ParseJSONLFiles(paths []string) ([]PackDocument, error) {5739 docs := make([]PackDocument, 0, len(paths))5840 for _, path := range paths {
@@ -129,15 +111,6 @@ func ParseJSONLFile(path string) (PackDocument, error) {
129111 Index: record.Puzzle.Index,130112 Body: record.Puzzle.Snippet,131113 SaveData: append([]byte(nil), record.Puzzle.Save...),132- 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,140- Table: record.Print.Table,141114 }142115 hydratePuzzlePrintData(&p)143116
M pdfexport/jsonl_test.go +26 -104
@@ -9,9 +9,7 @@ import (
99 )1010 1111 func TestParseJSONLFile(t *testing.T) {12- temp := t.TempDir()13- path := filepath.Join(temp, "pack.jsonl")14-12+ path := filepath.Join(t.TempDir(), "pack.jsonl")1513 record := JSONLRecord{1614 Schema: ExportSchemaV1,1715 Pack: JSONLPackMeta{
@@ -34,22 +32,8 @@ func TestParseJSONLFile(t *testing.T) {
3432 "| . | 1 | 2 |\n" +3533 "| 1 | . | . |\n",3634 },37- Print: JSONLPrintData{38- Kind: "nonogram",39- Paper: "A5",40- MarginMM: 10,41- EmptyGlyph: " ",42- HintTone: "dim",43- },44- }45-46- data, err := json.Marshal(record)47- if err != nil {48- t.Fatal(err)49- }50- if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {51- t.Fatal(err)5235 }36+ writeSingleJSONLRecord(t, path, record)5337 5438 doc, err := ParseJSONLFile(path)5539 if err != nil {
@@ -64,8 +48,7 @@ func TestParseJSONLFile(t *testing.T) {
6448 }6549 6650 func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) {67- temp := t.TempDir()68- path := filepath.Join(temp, "pack.md")51+ path := filepath.Join(t.TempDir(), "pack.md")6952 if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {7053 t.Fatal(err)7154 }
@@ -80,9 +63,7 @@ func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) {
8063 }8164 8265 func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) {83- temp := t.TempDir()84- path := filepath.Join(temp, "takuzu-pack.jsonl")85-66+ path := filepath.Join(t.TempDir(), "takuzu-pack.jsonl")8667 record := JSONLRecord{8768 Schema: ExportSchemaV1,8869 Pack: JSONLPackMeta{
@@ -100,18 +81,8 @@ func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) {
10081 Mode: "Beginner",10182 Save: json.RawMessage(`{"size":2,"state":"01\n10","provided":"##\n#."}`),10283 },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)11484 }85+ writeSingleJSONLRecord(t, path, record)11586 11687 doc, err := ParseJSONLFile(path)11788 if err != nil {
@@ -129,9 +100,7 @@ func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) {
129100 }130101 131102 func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) {132- temp := t.TempDir()133- path := filepath.Join(temp, "sudoku.jsonl")134-103+ path := filepath.Join(t.TempDir(), "sudoku.jsonl")135104 record := JSONLRecord{136105 Schema: ExportSchemaV1,137106 Pack: JSONLPackMeta{
@@ -148,18 +117,8 @@ func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) {
148117 Mode: "Easy",149118 Save: json.RawMessage(`{"grid":"500000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":5}]}`),150119 },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)162120 }121+ writeSingleJSONLRecord(t, path, record)163122 164123 doc, err := ParseJSONLFile(path)165124 if err != nil {
@@ -177,9 +136,7 @@ func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) {
177136 }178137 179138 func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) {180- temp := t.TempDir()181- path := filepath.Join(temp, "wordsearch.jsonl")182-139+ path := filepath.Join(t.TempDir(), "wordsearch.jsonl")183140 record := JSONLRecord{184141 Schema: ExportSchemaV1,185142 Pack: JSONLPackMeta{
@@ -196,18 +153,8 @@ func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) {
196153 Mode: "Standard",197154 Save: json.RawMessage(`{"width":3,"height":3,"grid":"abc\ndef\nghi","words":[{"text":"ace"},{"text":"fig"}]}`),198155 },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)210156 }157+ writeSingleJSONLRecord(t, path, record)211158 212159 doc, err := ParseJSONLFile(path)213160 if err != nil {
@@ -228,9 +175,7 @@ func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) {
228175 }229176 230177 func TestParseJSONLFileHydratesNurikabeFromSave(t *testing.T) {231- temp := t.TempDir()232- path := filepath.Join(temp, "nurikabe.jsonl")233-178+ path := filepath.Join(t.TempDir(), "nurikabe.jsonl")234179 record := JSONLRecord{235180 Schema: ExportSchemaV1,236181 Pack: JSONLPackMeta{
@@ -247,18 +192,8 @@ func TestParseJSONLFileHydratesNurikabeFromSave(t *testing.T) {
247192 Mode: "Mini",248193 Save: json.RawMessage(`{"width":2,"height":2,"clues":"1,0\n0,2","marks":"??\n??"}`),249194 },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)261195 }196+ writeSingleJSONLRecord(t, path, record)262197 263198 doc, err := ParseJSONLFile(path)264199 if err != nil {
@@ -276,9 +211,7 @@ func TestParseJSONLFileHydratesNurikabeFromSave(t *testing.T) {
276211 }277212 278213 func TestParseJSONLFileHydratesShikakuFromSave(t *testing.T) {279- temp := t.TempDir()280- path := filepath.Join(temp, "shikaku.jsonl")281-214+ path := filepath.Join(t.TempDir(), "shikaku.jsonl")282215 record := JSONLRecord{283216 Schema: ExportSchemaV1,284217 Pack: JSONLPackMeta{
@@ -295,18 +228,8 @@ func TestParseJSONLFileHydratesShikakuFromSave(t *testing.T) {
295228 Mode: "Mini",296229 Save: json.RawMessage(`{"width":2,"height":2,"clues":[{"x":0,"y":0,"value":1},{"x":1,"y":1,"value":4}]}`),297230 },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)309231 }232+ writeSingleJSONLRecord(t, path, record)310233 311234 doc, err := ParseJSONLFile(path)312235 if err != nil {
@@ -324,9 +247,7 @@ func TestParseJSONLFileHydratesShikakuFromSave(t *testing.T) {
324247 }325248 326249 func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) {327- temp := t.TempDir()328- path := filepath.Join(temp, "hashi.jsonl")329-250+ path := filepath.Join(t.TempDir(), "hashi.jsonl")330251 record := JSONLRecord{331252 Schema: ExportSchemaV1,332253 Pack: JSONLPackMeta{
@@ -343,18 +264,8 @@ func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) {
343264 Mode: "Standard",344265 Save: json.RawMessage(`{"width":7,"height":7,"islands":[{"x":0,"y":0,"required":3},{"x":6,"y":6,"required":2}],"bridges":[]}`),345266 },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)357267 }268+ writeSingleJSONLRecord(t, path, record)358269 359270 doc, err := ParseJSONLFile(path)360271 if err != nil {
@@ -370,3 +281,14 @@ func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) {
370281 t.Fatalf("hashi island count = %d, want %d", got, want)371282 }372283 }284+285+func writeSingleJSONLRecord(t *testing.T, path string, record JSONLRecord) {286+ t.Helper()287+ data, err := json.Marshal(record)288+ if err != nil {289+ t.Fatal(err)290+ }291+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {292+ t.Fatal(err)293+ }294+}
M pdfexport/render.go +226 -134
@@ -13,11 +13,6 @@ import (
1313 "github.com/go-pdf/fpdf"1414 )1515 16-const (17- a5WidthMM = 148.018- a5HeightMM = 210.019-)20-2116 func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) error {2217 if strings.TrimSpace(outputPath) == "" {2318 return fmt.Errorf("output path is required")
@@ -40,10 +35,13 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
4035 OrientationStr: "P",4136 UnitStr: "mm",4237 Size: fpdf.SizeType{43- Wd: a5WidthMM,44- Ht: a5HeightMM,38+ Wd: halfLetterWidthMM,39+ Ht: halfLetterHeightMM,4540 },4641 })42+ if err := registerPDFFonts(pdf); err != nil {43+ return err44+ }4745 pdf.SetAutoPageBreak(false, 0)4846 pdf.SetCreator("PuzzleTea", true)4947 pdf.SetAuthor("PuzzleTea", true)
@@ -53,8 +51,8 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
5351 return5452 }5553 pdf.SetY(-8)56- pdf.SetFont("Helvetica", "", 8)57- pdf.SetTextColor(105, 105, 105)54+ pdf.SetFont(sansFontFamily, "", 8)55+ pdf.SetTextColor(footerTextGray, footerTextGray, footerTextGray)5856 pdf.CellFormat(0, 4, strconv.Itoa(pdf.PageNo()), "", 0, "C", false, 0, "")5957 })6058
@@ -82,31 +80,50 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
8280 margin := 12.08381 8482 pdf.SetTextColor(20, 20, 20)85- pdf.SetFont("Helvetica", "B", 22)83+ pdf.SetFont(sansFontFamily, "B", 22)8684 pdf.SetXY(0, 24)8785 pdf.CellFormat(pageW, 10, cfg.Title, "", 0, "C", false, 0, "")8886 89- pdf.SetFont("Helvetica", "", 11)87+ pdf.SetFont(sansFontFamily, "", 11)9088 pdf.SetTextColor(70, 70, 70)9189 pdf.SetXY(0, 36)9290 pdf.CellFormat(pageW, 6, "PuzzleTea Puzzle Pack", "", 0, "C", false, 0, "")9391 9492 metaY := 50.09593 pdf.SetTextColor(25, 25, 25)96- pdf.SetFont("Helvetica", "", 10)94+ pdf.SetFont(sansFontFamily, "", 10)9795 pdf.SetXY(margin, metaY)9896 pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format("January 2, 2006")), "", 0, "L", false, 0, "")9997 metaY += 698+ versionLine := fmt.Sprintf("PuzzleTea Version: %s", strings.Join(summarizeVersions(docs), ", "))99+ wrappedVersions := pdf.SplitLines([]byte(versionLine), pageW-2*margin)100+ if len(wrappedVersions) == 0 {101+ wrappedVersions = [][]byte{[]byte(versionLine)}102+ }103+ for _, line := range wrappedVersions {104+ pdf.SetXY(margin, metaY)105+ pdf.CellFormat(pageW-2*margin, 5.2, string(line), "", 0, "L", false, 0, "")106+ metaY += 5.2107+ }108+ metaY += 0.8100109 101110 categories := summarizeCategories(puzzles)102111 pdf.SetXY(margin, metaY)103112 pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Puzzles: %d", len(puzzles)), "", 0, "L", false, 0, "")104113 metaY += 6105- pdf.SetXY(margin, metaY)106- pdf.CellFormat(pageW-2*margin, 6, fmt.Sprintf("Categories: %s", strings.Join(categories, ", ")), "", 0, "L", false, 0, "")107- metaY += 8114+ categoryLine := fmt.Sprintf("Categories: %s", strings.Join(categories, ", "))115+ wrappedCategories := pdf.SplitLines([]byte(categoryLine), pageW-2*margin)116+ if len(wrappedCategories) == 0 {117+ wrappedCategories = [][]byte{[]byte(categoryLine)}118+ }119+ for _, line := range wrappedCategories {120+ pdf.SetXY(margin, metaY)121+ pdf.CellFormat(pageW-2*margin, 5.2, string(line), "", 0, "L", false, 0, "")122+ metaY += 5.2123+ }124+ metaY += 2.8108125 109- pdf.SetFont("Helvetica", "B", 10)126+ pdf.SetFont(sansFontFamily, "B", 10)110127 pdf.SetTextColor(45, 45, 45)111128 pdf.SetXY(margin, metaY)112129 pdf.CellFormat(pageW-2*margin, 6, "Source Exports", "", 0, "L", false, 0, "")
@@ -115,11 +132,11 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
115132 renderSourceExportsTable(pdf, docs, margin, metaY, pageW-2*margin, pageH-45)116133 117134 pdf.SetTextColor(50, 50, 50)118- pdf.SetFont("Helvetica", "B", 12)135+ pdf.SetFont(sansFontFamily, "B", 12)119136 pdf.SetXY(margin, pageH-30)120137 pdf.CellFormat(pageW-2*margin, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "")121138 122- pdf.SetFont("Helvetica", "", 10)139+ pdf.SetFont(sansFontFamily, "", 10)123140 pdf.SetXY(margin, pageH-22)124141 pdf.CellFormat(pageW-2*margin, 6, cfg.AdvertText, "", 0, "C", false, 0, "")125142 }
@@ -129,14 +146,12 @@ func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) {
129146 pageW, pageH := pdf.GetPageSize()130147 hydratePuzzlePrintData(&puzzle)131148 132- pdf.SetTextColor(20, 20, 20)133- pdf.SetFont("Helvetica", "B", 13)149+ setPuzzleTitleStyle(pdf)134150 pdf.SetXY(0, 10)135151 title := fmt.Sprintf("%s %d: %s", puzzle.Category, puzzle.Index, puzzle.Name)136152 pdf.CellFormat(pageW, 7, title, "", 0, "C", false, 0, "")137153 138- pdf.SetFont("Helvetica", "", 9)139- pdf.SetTextColor(95, 95, 95)154+ setPuzzleSubtitleStyle(pdf)140155 pdf.SetXY(0, 17)141156 subtitleParts := []string{fmt.Sprintf("Difficulty Score: %d/10", difficultyScoreOutOfTen(puzzle.DifficultyScore))}142157 if !isMixedModes(puzzle.ModeSelection) {
@@ -190,10 +205,6 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {
190205 }191206 192207 pageW, pageH := pdf.GetPageSize()193- marginX := 10.0194- padding := 3.0195- top := 28.0196- bottom := 12.0197208 198209 rowHints := normalizeNonogramHintsForRender(data.RowHints, data.Height)199210 colHints := normalizeNonogramHintsForRender(data.ColHints, data.Width)
@@ -207,28 +218,25 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {
207218 colHintRows = 1208219 }209220 210- totalCols := rowHintCols + data.Width211- totalRows := colHintRows + data.Height212-213- availW := pageW - 2*(marginX+padding)214- availH := pageH - top - bottom - 2*padding215- cellSize := math.Min(availW/float64(totalCols), availH/float64(totalRows))216- if cellSize > 8.8 {217- cellSize = 8.8218- }219- if cellSize < 3.2 {220- cellSize = 3.2221+ layout := layoutNonogram(222+ pageW,223+ pageH,224+ data.Width,225+ data.Height,226+ rowHintCols,227+ colHintRows,228+ )229+ cellSize := layout.cellSize230+ if cellSize <= 0 {231+ return221232 }222233 223- blockW := float64(totalCols) * cellSize224- blockH := float64(totalRows) * cellSize225234 gridW := float64(data.Width) * cellSize226235 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)*cellSize236+ startX := layout.hintStartX237+ startY := layout.hintStartY238+ xSep := layout.gridX239+ ySep := layout.gridY232240 233241 for row := 0; row < colHintRows; row++ {234242 for col := 0; col < data.Width; col++ {
@@ -254,8 +262,24 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {
254262 255263 drawNonogramMajorLines(pdf, xSep, ySep, cellSize, data.Width, data.Height, 5)256264 257- pdf.SetLineWidth(0.60)265+ pdf.SetLineWidth(outerBorderLineMM)258266 pdf.Rect(xSep, ySep, gridW, gridH, "D")267+268+ ruleY := ySep + gridH + 3.5269+ ruleY = instructionY(ruleY-3.5, pageH, 1)270+ setInstructionStyle(pdf)271+ pdf.SetXY(pageMarginXMM, ruleY)272+ pdf.CellFormat(273+ pageW-2*pageMarginXMM,274+ instructionLineHMM,275+ "Use row/column hints to fill blocks in order; groups are separated by at least one blank cell.",276+ "",277+ 0,278+ "C",279+ false,280+ 0,281+ "",282+ )259283 }260284 261285 func drawNonogramPuzzleGrid(
@@ -274,7 +298,7 @@ func drawNonogramPuzzleGrid(
274298 gridH := float64(height) * cellSize275299 276300 pdf.SetDrawColor(45, 45, 45)277- pdf.SetLineWidth(0.12)301+ pdf.SetLineWidth(thinGridLineMM)278302 for col := 0; col <= width; col++ {279303 x := startX + float64(col)*cellSize280304 pdf.Line(x, startY, x, startY+gridH)
@@ -290,47 +314,100 @@ func drawNonogramHintText(pdf *fpdf.Fpdf, x, y, w, h float64, text string) {
290314 return291315 }292316 293- // Darker, compact hint typography for dense nonograms.294- pdf.SetTextColor(55, 55, 55)317+ // Hints are puzzle-critical, so keep them bold and centered.318+ pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)295319 fontSize := standardCellFontSize(h, 0.70)296- pdf.SetFont("Helvetica", "", fontSize)320+ pdf.SetFont(sansFontFamily, "B", fontSize)297321 lineH := fontSize * 0.86298322 pdf.SetXY(x, y+(h-lineH)/2)299323 pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "")300324 }301325 326+type nonogramLayout struct {327+ cellSize float64328+ hintStartX float64329+ hintStartY float64330+ gridX float64331+ gridY float64332+}333+334+func layoutNonogram(335+ pageW,336+ pageH float64,337+ gridCols,338+ gridRows,339+ rowHintCols,340+ colHintRows int,341+) nonogramLayout {342+ totalCols := rowHintCols + gridCols343+ totalRows := colHintRows + gridRows344+ area := puzzleBoardRect(pageW, pageH, 1)345+ cellSize := fitBoardCellSize(totalCols, totalRows, area, boardFamilyNonogram)346+ if cellSize <= 0 {347+ return nonogramLayout{}348+ }349+350+ if rowHintCols > 0 {351+ centeredCapW := area.w / float64(gridCols+2*rowHintCols)352+ if centeredCapW > 0 && centeredCapW < cellSize {353+ cellSize = centeredCapW354+ }355+ }356+ if colHintRows > 0 {357+ centeredCapH := area.h / float64(gridRows+2*colHintRows)358+ if centeredCapH > 0 && centeredCapH < cellSize {359+ cellSize = centeredCapH360+ }361+ }362+363+ gridW := float64(gridCols) * cellSize364+ gridH := float64(gridRows) * cellSize365+ gridX := area.x + (area.w-gridW)/2366+ gridY := area.y + (area.h-gridH)/2367+ hintStartX := gridX - float64(rowHintCols)*cellSize368+ hintStartY := gridY - float64(colHintRows)*cellSize369+370+ return nonogramLayout{371+ cellSize: cellSize,372+ hintStartX: hintStartX,373+ hintStartY: hintStartY,374+ gridX: gridX,375+ gridY: gridY,376+ }377+}378+302379 func renderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) {303380 if data == nil {304381 return305382 }306383 307384 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.5385+ area := puzzleBoardRect(pageW, pageH, 1)386+ cellSize := fitBoardCellSize(9, 9, area, boardFamilySudoku)387+ if cellSize <= 0 {388+ return320389 }321390 322- boardW := 9.0 * cellSize323391 boardH := 9.0 * cellSize324- startX := (pageW - boardW) / 2325- startY := top + (availH-boardH)/2392+ startX, startY := centeredOrigin(area, 9, 9, cellSize)326393 327394 drawSudokuGridLines(pdf, startX, startY, cellSize)328395 drawSudokuGivens(pdf, startX, startY, cellSize, data.Givens)329396 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, "")397+ ruleY := instructionY(startY+boardH, pageH, 1)398+ setInstructionStyle(pdf)399+ pdf.SetXY(pageMarginXMM, ruleY)400+ pdf.CellFormat(401+ pageW-2*pageMarginXMM,402+ instructionLineHMM,403+ "Fill rows, columns, and 3x3 boxes with 1-9",404+ "",405+ 0,406+ "C",407+ false,408+ 0,409+ "",410+ )334411 }335412 336413 func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) {
@@ -352,19 +429,19 @@ func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) {
352429 func sudokuLineWidthFor(index int) float64 {353430 switch {354431 case index == 0 || index == 9:355- return 0.70432+ return 0.72356433 case index%3 == 0:357- return 0.55434+ return 0.56358435 default:359- return 0.15436+ return thinGridLineMM360437 }361438 }362439 363440 func drawSudokuGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [9][9]int) {364441 fontSize := standardCellFontSize(cellSize, 0.62)365442 lineH := fontSize * 0.85366- pdf.SetFont("Helvetica", "B", fontSize)367- pdf.SetTextColor(20, 20, 20)443+ pdf.SetFont(sansFontFamily, "B", fontSize)444+ pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)368445 369446 for y := range 9 {370447 for x := range 9 {
@@ -386,16 +463,14 @@ func renderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) {
386463 }387464 388465 pageW, pageH := pdf.GetPageSize()389- marginX := 10.0390- top := 28.0391- bottom := 12.0392- availW := pageW - 2*marginX393- availH := pageH - top - bottom466+ body := puzzleBodyRect(pageW, pageH)467+ availW := body.w468+ availH := body.h394469 395470 columnCount := wordSearchColumnCount(data.Width, len(data.Words))396- wordFontSize := 8.3471+ wordFontSize := puzzleWordBankFontSize397472 wordLineHeight := 4.2398- gridListGap := 4.0473+ gridListGap := wordSearchGridListGap399474 400475 estimatedWordLines := estimateWordBankLineCount(pdf, data.Words, columnCount, availW, wordFontSize)401476 wordBankHeight := 7.0 + float64(estimatedWordLines)*wordLineHeight
@@ -412,26 +487,32 @@ func renderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) {
412487 gridAreaH = availH * 0.5413488 }414489 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.2490+ gridArea := rectMM{x: body.x, y: body.y, w: availW, h: gridAreaH}491+ cellSize := fitBoardCellSize(data.Width, data.Height, gridArea, boardFamilyWordSearch)492+ if cellSize <= 0 {493+ return421494 }422495 423496 gridW := float64(data.Width) * cellSize424497 gridH := float64(data.Height) * cellSize425- gridX := (pageW - gridW) / 2426- gridY := top + (gridAreaH-gridH)/2498+ gridX := body.x + (gridArea.w-gridW)/2499+ gridY := body.y + (gridArea.h-gridH)/2427500 428501 drawWordSearchGrid(pdf, data, gridX, gridY, cellSize)429- drawWordBank(pdf, data.Words, marginX, gridY+gridH+gridListGap, availW, pageH-bottom-(gridY+gridH+gridListGap), columnCount)502+ drawWordBank(503+ pdf,504+ data.Words,505+ body.x,506+ gridY+gridH+gridListGap,507+ availW,508+ pageH-puzzleBottomInsetMM-(gridY+gridH+gridListGap),509+ columnCount,510+ )430511 }431512 432513 func drawWordSearchGrid(pdf *fpdf.Fpdf, data *WordSearchData, startX, startY, cellSize float64) {433514 pdf.SetDrawColor(45, 45, 45)434- pdf.SetLineWidth(0.12)515+ pdf.SetLineWidth(thinGridLineMM)435516 for y := range data.Height {436517 for x := range data.Width {437518 cellX := startX + float64(x)*cellSize
@@ -448,14 +529,14 @@ func drawWordSearchGrid(pdf *fpdf.Fpdf, data *WordSearchData, startX, startY, ce
448529 449530 fontSize := standardCellFontSize(cellSize, 0.74)450531 lineH := fontSize * 0.86451- pdf.SetFont("Helvetica", "B", fontSize)452- pdf.SetTextColor(18, 18, 18)532+ pdf.SetFont(sansFontFamily, "B", fontSize)533+ pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)453534 pdf.SetXY(cellX, cellY+(cellSize-lineH)/2)454535 pdf.CellFormat(cellSize, lineH, cellText, "", 0, "C", false, 0, "")455536 }456537 }457538 458- pdf.SetLineWidth(0.55)539+ pdf.SetLineWidth(outerBorderLineMM)459540 pdf.Rect(startX, startY, float64(data.Width)*cellSize, float64(data.Height)*cellSize, "D")460541 }461542
@@ -465,19 +546,19 @@ func drawWordBank(pdf *fpdf.Fpdf, words []string, x, y, width, height float64, c
465546 }466547 467548 pdf.SetTextColor(40, 40, 40)468- pdf.SetFont("Helvetica", "B", 9.2)549+ pdf.SetFont(sansFontFamily, "B", puzzleWordBankHeadSize)469550 pdf.SetXY(x, y)470551 pdf.CellFormat(width, 4.8, "Word Bank", "", 0, "L", false, 0, "")471552 472- pdf.SetFont("Helvetica", "", 8.3)473- pdf.SetTextColor(70, 70, 70)553+ pdf.SetFont(sansFontFamily, "", puzzleWordBankFontSize)554+ pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray)474555 pdf.SetXY(x, y+4.8)475556 pdf.CellFormat(width, 4.2, "Words may run in all 8 directions", "", 0, "L", false, 0, "")476557 477558 listY := y + 9.0478559 if len(words) == 0 {479- pdf.SetFont("Helvetica", "", 8.8)480- pdf.SetTextColor(95, 95, 95)560+ pdf.SetFont(sansFontFamily, "", puzzleWordBankHeadSize)561+ pdf.SetTextColor(secondaryTextGray, secondaryTextGray, secondaryTextGray)481562 pdf.SetXY(x, listY)482563 pdf.CellFormat(width, 4.6, "(word list unavailable)", "", 0, "L", false, 0, "")483564 return
@@ -500,8 +581,8 @@ func drawWordBank(pdf *fpdf.Fpdf, words []string, x, y, width, height float64, c
500581 return501582 }502583 503- pdf.SetTextColor(25, 25, 25)504- pdf.SetFont("Helvetica", "", 8.8)584+ pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)585+ pdf.SetFont(sansFontFamily, "B", puzzleWordBankHeadSize)505586 for c := range columns {506587 colX := x + float64(c)*(colWidth+columnGap)507588 curY := listY
@@ -540,7 +621,7 @@ func estimateWordBankLineCount(pdf *fpdf.Fpdf, words []string, columns int, avai
540621 colWidth = availW541622 }542623 543- pdf.SetFont("Helvetica", "", fontSize)624+ pdf.SetFont(sansFontFamily, "", fontSize)544625 lineCounts := make([]int, columns)545626 for _, word := range words {546627 text := strings.ToUpper(strings.TrimSpace(word))
@@ -610,9 +691,6 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) {
610691 }611692 612693 pageW, pageH := pdf.GetPageSize()613- marginX := 10.0614- top := 28.0615- bottom := 12.0616694 617695 rows := len(table.Rows)618696 cols := 0
@@ -625,23 +703,18 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) {
625703 return626704 }627705 628- availW := pageW - 2*marginX629- availH := pageH - top - bottom630- cellSize := math.Min(availW/float64(cols), availH/float64(rows))631- if cellSize > 11.2 {632- cellSize = 11.2633- }634- if cellSize < 3.3 {635- cellSize = 3.3706+ area := puzzleBoardRect(pageW, pageH, 0)707+ cellSize := fitBoardCellSize(cols, rows, area, boardFamilyTable)708+ if cellSize <= 0 {709+ return636710 }637711 638712 blockW := float64(cols) * cellSize639713 blockH := float64(rows) * cellSize640- startX := (pageW - blockW) / 2641- startY := top + (availH-blockH)/2714+ startX, startY := centeredOrigin(area, cols, rows, cellSize)642715 643716 pdf.SetDrawColor(55, 55, 55)644- pdf.SetLineWidth(0.16)717+ pdf.SetLineWidth(thinGridLineMM)645718 for r := 0; r < rows; r++ {646719 for c := 0; c < cols; c++ {647720 x := startX + float64(c)*cellSize
@@ -666,26 +739,24 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) {
666739 667740 if table.HasHeaderRow {668741 ySep := startY + cellSize669- pdf.SetLineWidth(0.42)742+ pdf.SetLineWidth(majorGridLineMM)670743 pdf.Line(startX, ySep, startX+blockW, ySep)671744 }672745 if table.HasHeaderCol {673746 xSep := startX + cellSize674- pdf.SetLineWidth(0.42)747+ pdf.SetLineWidth(majorGridLineMM)675748 pdf.Line(xSep, startY, xSep, startY+blockH)676749 }677750 678- pdf.SetLineWidth(0.6)751+ pdf.SetLineWidth(outerBorderLineMM)679752 pdf.Rect(startX, startY, blockW, blockH, "D")680753 }681754 682755 func renderFallbackPage(pdf *fpdf.Fpdf, puzzle Puzzle, pageH float64) {683756 pageW, _ := pdf.GetPageSize()684- marginX := 10.0685- top := 28.0686- bottom := 12.0687- availW := pageW - 2*marginX688- availH := pageH - top - bottom757+ area := puzzleBoardRect(pageW, pageH, 0)758+ availW := area.w759+ availH := area.h689760 690761 lines := sanitizeBody(puzzle.Body)691762 fontSize := 9.2
@@ -712,15 +783,15 @@ func renderFallbackPage(pdf *fpdf.Fpdf, puzzle Puzzle, pageH float64) {
712783 }713784 714785 blockH := float64(len(wrapped)) * lineHeight715- startY := top + (availH-blockH)/2786+ startY := area.y + (availH-blockH)/2716787 717788 pdf.SetTextColor(50, 50, 50)718789 y := startY719790 for _, line := range wrapped {720791 w := pdf.GetStringWidth(line)721792 x := (pageW - w) / 2722- if x < marginX {723- x = marginX793+ if x < area.x {794+ x = area.x724795 }725796 pdf.SetXY(x, y)726797 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) {
754825 return755826 }756827 if dim {757- pdf.SetTextColor(130, 130, 130)828+ pdf.SetTextColor(dimTextGray, dimTextGray, dimTextGray)758829 } else {759- pdf.SetTextColor(25, 25, 25)830+ pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)760831 }761832 762833 fontSize := standardCellFontSize(h, 0.63)763- pdf.SetFont("Helvetica", "", fontSize)834+ pdf.SetFont(sansFontFamily, "B", fontSize)764835 lineH := fontSize * 0.9765836 pdf.SetXY(x, y+(h-lineH)/2)766837 pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "")
@@ -828,7 +899,7 @@ func drawNonogramMajorLines(
828899 }829900 830901 pdf.SetDrawColor(45, 45, 45)831- pdf.SetLineWidth(0.30)902+ pdf.SetLineWidth(majorGridLineMM)832903 833904 for col := step; col < width; col += step {834905 x := puzzleStartX + float64(col)*cellSize
@@ -861,6 +932,27 @@ func summarizeCategories(puzzles []Puzzle) []string {
861932 return categories862933 }863934 935+func summarizeVersions(docs []PackDocument) []string {936+ set := map[string]struct{}{}937+ for _, doc := range docs {938+ version := strings.TrimSpace(doc.Metadata.Version)939+ if version == "" {940+ continue941+ }942+ set[version] = struct{}{}943+ }944+945+ versions := make([]string, 0, len(set))946+ for version := range set {947+ versions = append(versions, version)948+ }949+ sort.Strings(versions)950+ if len(versions) == 0 {951+ return []string{"Unknown"}952+ }953+ return versions954+}955+864956 func defaultTitle(docs []PackDocument) string {865957 if len(docs) == 1 {866958 category := strings.TrimSpace(docs[0].Metadata.Category)
@@ -907,10 +999,10 @@ func renderSourceExportsTable(
907999 }9081000 9091001 pdf.SetDrawColor(125, 125, 125)910- pdf.SetLineWidth(0.14)1002+ pdf.SetLineWidth(thinGridLineMM)9111003 pdf.SetFillColor(245, 245, 245)9121004 pdf.SetTextColor(45, 45, 45)913- pdf.SetFont("Helvetica", "B", 8.4)1005+ pdf.SetFont(sansFontFamily, "B", 8.9)9141006 9151007 curX := x9161008 for i, header := range headers {
@@ -919,8 +1011,8 @@ func renderSourceExportsTable(
9191011 curX += columnWidths[i]9201012 }9211013 922- pdf.SetFont("Helvetica", "", 8.1)923- pdf.SetTextColor(70, 70, 70)1014+ pdf.SetFont(sansFontFamily, "", 8.6)1015+ pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray)9241016 for i := 0; i < rowCount; i++ {9251017 rowY := y + headerHeight + float64(i)*rowHeight9261018 mode := ""
M pdfexport/render_hashi.go +43 -24
@@ -14,37 +14,43 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *HashiData) {
1414 }1515 1616 pageW, pageH := pdf.GetPageSize()17- marginX := 10.018- top := 28.019- bottom := 22.020-21- availW := pageW - 2*marginX22- availH := pageH - top - bottom2317 2418 spanX := max(data.Width-1, 1)2519 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))20+ area := puzzleBoardRect(pageW, pageH, 1)21+ step := fitBoardCellSize(spanX, spanY, area, boardFamilyHashi)22+ if step <= 0 {23+ return24+ }2825 2926 boardW := float64(spanX) * step3027 boardH := float64(spanY) * step31- originX := (pageW - boardW) / 232- originY := top + (availH-boardH)/228+ originX, originY := centeredOrigin(area, spanX, spanY, step)29+ islandRadius := hashiIslandRadius(step)3330 3431 drawHashiGuideDots(pdf, originX, originY, data.Width, data.Height, step)35- drawHashiIslands(pdf, originX, originY, step, data.Islands)32+ drawHashiBoardBorder(pdf, originX, originY, boardW, boardH, islandRadius)33+ drawHashiIslands(pdf, originX, originY, step, islandRadius, data.Islands)3634 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- }35+ // Add an explicit blank line before the Hashi hint text.36+ ruleY := instructionY(originY+boardH+instructionLineHMM, pageH, 1)37+ setInstructionStyle(pdf)38+ pdf.SetXY(pageMarginXMM, ruleY)39+ pdf.CellFormat(40+ pageW-2*pageMarginXMM,41+ instructionLineHMM,42+ "Connect islands horizontally/vertically with up to two bridges and no crossings.",43+ "",44+ 0,45+ "C",46+ false,47+ 0,48+ "",49+ )4450 }4551 4652 func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height int, step float64) {47- pdf.SetFillColor(235, 235, 235)53+ pdf.SetFillColor(230, 230, 230)4854 r := math.Max(0.20, math.Min(0.55, step*0.035))4955 for y := 0; y < height; y++ {5056 for x := 0; x < width; x++ {
@@ -55,11 +61,20 @@ func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height
5561 }5662 }5763 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))64+func drawHashiBoardBorder(pdf *fpdf.Fpdf, originX, originY, boardW, boardH, islandRadius float64) {65+ if boardW <= 0 || boardH <= 0 {66+ return67+ }68+ borderPad := islandRadius + 1.269+ pdf.SetDrawColor(35, 35, 35)70+ pdf.SetLineWidth(outerBorderLineMM)71+ pdf.Rect(originX-borderPad, originY-borderPad, boardW+2*borderPad, boardH+2*borderPad, "D")72+}73+74+func drawHashiIslands(pdf *fpdf.Fpdf, originX, originY, step, radius float64, islands []HashiIsland) {6075 pdf.SetDrawColor(20, 20, 20)6176 pdf.SetFillColor(255, 255, 255)62- pdf.SetLineWidth(0.26)77+ pdf.SetLineWidth(majorGridLineMM)6378 6479 for _, island := range islands {6580 cx := originX + float64(island.X)*step
@@ -69,6 +84,10 @@ func drawHashiIslands(pdf *fpdf.Fpdf, originX, originY, step float64, islands []
6984 }7085 }7186 87+func hashiIslandRadius(step float64) float64 {88+ return math.Max(1.4, math.Min(3.2, step*0.23))89+}90+7291 func drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int) {7392 text := strconv.Itoa(required)7493 fontSize := standardCellFontSize(radius*2.0, 0.95)
@@ -81,8 +100,8 @@ func drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int)
81100 }82101 fontSize = clampStandardCellFontSize(fontSize)83102 84- pdf.SetTextColor(18, 18, 18)85- pdf.SetFont("Helvetica", "B", fontSize)103+ pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)104+ pdf.SetFont(sansFontFamily, "B", fontSize)86105 lineH := fontSize * 0.8887106 pdf.SetXY(cx-radius, cy-lineH/2)88107 pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "")
M pdfexport/render_hitori_takuzu.go +62 -57
@@ -1,7 +1,6 @@
11 package pdfexport22 33 import (4- "math"54 "strings"65 "unicode/utf8"76
@@ -15,22 +14,18 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) {
1514 1615 size := data.Size1716 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))17+ area := puzzleBoardRect(pageW, pageH, 1)18+ cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact)19+ if cellSize <= 0 {20+ return21+ }2622 2723 blockW := float64(size) * cellSize2824 blockH := float64(size) * cellSize29- startX := (pageW - blockW) / 230- startY := top + (availH-blockH)/225+ startX, startY := centeredOrigin(area, size, size, cellSize)3126 3227 pdf.SetDrawColor(55, 55, 55)33- pdf.SetLineWidth(0.16)28+ pdf.SetLineWidth(thinGridLineMM)3429 for y := 0; y < size; y++ {3530 for x := 0; x < size; x++ {3631 cellX := startX + float64(x)*cellSize
@@ -49,26 +44,23 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) {
4944 }5045 5146 pdf.SetDrawColor(35, 35, 35)52- pdf.SetLineWidth(0.62)47+ pdf.SetLineWidth(outerBorderLineMM)5348 pdf.Rect(startX, startY, blockW, blockH, "D")5449 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- }50+ ruleY := instructionY(startY+blockH, pageH, 1)51+ setInstructionStyle(pdf)52+ pdf.SetXY(pageMarginXMM, ruleY)53+ pdf.CellFormat(54+ pageW-2*pageMarginXMM,55+ instructionLineHMM,56+ "Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected.",57+ "",58+ 0,59+ "C",60+ false,61+ 0,62+ "",63+ )7264 }7365 7466 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) {
8274 }8375 fontSize = clampStandardCellFontSize(fontSize)8476 85- pdf.SetTextColor(20, 20, 20)86- pdf.SetFont("Helvetica", "B", fontSize)77+ pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)78+ pdf.SetFont(sansFontFamily, "B", fontSize)8779 lineH := fontSize * 0.928880 pdf.SetXY(x, y+(cellSize-lineH)/2)8981 pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")
@@ -96,22 +88,18 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) {
9688 9789 size := data.Size9890 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))91+ area := puzzleBoardRect(pageW, pageH, 2)92+ cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact)93+ if cellSize <= 0 {94+ return95+ }10796 10897 blockW := float64(size) * cellSize10998 blockH := float64(size) * cellSize110- startX := (pageW - blockW) / 2111- startY := top + (availH-blockH)/299+ startX, startY := centeredOrigin(area, size, size, cellSize)112100 113101 pdf.SetDrawColor(60, 60, 60)114- pdf.SetLineWidth(0.14)102+ pdf.SetLineWidth(thinGridLineMM)115103 for y := 0; y < size; y++ {116104 for x := 0; x < size; x++ {117105 cellX := startX + float64(x)*cellSize
@@ -121,8 +109,8 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) {
121109 }122110 123111 if data.GroupEveryTwo {124- pdf.SetDrawColor(145, 145, 145)125- pdf.SetLineWidth(0.24)112+ pdf.SetDrawColor(130, 130, 130)113+ pdf.SetLineWidth(majorGridLineMM)126114 for i := 2; i < size; i += 2 {127115 x := startX + float64(i)*cellSize128116 y := startY + float64(i)*cellSize
@@ -148,25 +136,42 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) {
148136 }149137 150138 pdf.SetDrawColor(35, 35, 35)151- pdf.SetLineWidth(0.60)139+ pdf.SetLineWidth(outerBorderLineMM)152140 pdf.Rect(startX, startY, blockW, blockH, "D")153141 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- }142+ ruleY := instructionY(startY+blockH, pageH, 2)143+ setInstructionStyle(pdf)144+ pdf.SetXY(pageMarginXMM, ruleY)145+ pdf.CellFormat(146+ pageW-2*pageMarginXMM,147+ instructionLineHMM,148+ "No three equal adjacent in any row or column.",149+ "",150+ 0,151+ "C",152+ false,153+ 0,154+ "",155+ )156+ pdf.SetXY(pageMarginXMM, ruleY+instructionLineHMM)157+ pdf.CellFormat(158+ pageW-2*pageMarginXMM,159+ instructionLineHMM,160+ "Each row/column has equal 0 and 1 counts, and rows/columns are unique.",161+ "",162+ 0,163+ "C",164+ false,165+ 0,166+ "",167+ )163168 }164169 165170 func drawTakuzuGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, size int, text string) {166171 fontSize := takuzuGivenFontSize(cellSize, size)167172 168- pdf.SetTextColor(15, 15, 15)169- pdf.SetFont("Helvetica", "B", fontSize)173+ pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)174+ pdf.SetFont(sansFontFamily, "B", fontSize)170175 lineH := fontSize * 0.9171176 pdf.SetXY(x, y+(cellSize-lineH)/2)172177 pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")
A pdfexport/render_layout_test.go +104 -0
@@ -0,0 +1,104 @@
1+package pdfexport2+3+import (4+ "math"5+ "testing"6+)7+8+func TestThinGridLineFloor(t *testing.T) {9+ if thinGridLineMM < 0.22 {10+ t.Fatalf("thinGridLineMM = %.2f, want >= 0.22", thinGridLineMM)11+ }12+}13+14+func TestCenteredOriginKeepsBoardCentered(t *testing.T) {15+ pageArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, 1)16+ tests := []struct {17+ name string18+ cols int19+ rows int20+ family boardFamily21+ }{22+ {name: "compact", cols: 8, rows: 8, family: boardFamilyCompact},23+ {name: "sudoku", cols: 9, rows: 9, family: boardFamilySudoku},24+ {name: "hashi-span", cols: 6, rows: 6, family: boardFamilyHashi},25+ }26+27+ for _, tt := range tests {28+ t.Run(tt.name, func(t *testing.T) {29+ cell := fitBoardCellSize(tt.cols, tt.rows, pageArea, tt.family)30+ if cell <= 0 {31+ t.Fatal("expected positive cell size")32+ }33+ x, y := centeredOrigin(pageArea, tt.cols, tt.rows, cell)34+35+ centerX := x + float64(tt.cols)*cell/236+ centerY := y + float64(tt.rows)*cell/237+ wantX := pageArea.x + pageArea.w/238+ wantY := pageArea.y + pageArea.h/239+ if diff := math.Abs(centerX - wantX); diff > 0.01 {40+ t.Fatalf("centerX diff = %.4f, want <= 0.01", diff)41+ }42+ if diff := math.Abs(centerY - wantY); diff > 0.01 {43+ t.Fatalf("centerY diff = %.4f, want <= 0.01", diff)44+ }45+ })46+ }47+}48+49+func TestLayoutNonogramCentersGrid(t *testing.T) {50+ tests := []struct {51+ name string52+ rowHintCol int53+ colHintRow int54+ }{55+ {name: "shallow hints", rowHintCol: 1, colHintRow: 1},56+ {name: "deep hints", rowHintCol: 5, colHintRow: 4},57+ }58+59+ boardArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, 1)60+ for _, tt := range tests {61+ t.Run(tt.name, func(t *testing.T) {62+ layout := layoutNonogram(63+ halfLetterWidthMM,64+ halfLetterHeightMM,65+ 10,66+ 10,67+ tt.rowHintCol,68+ tt.colHintRow,69+ )70+ if layout.cellSize <= 0 {71+ t.Fatal("expected non-zero cell size")72+ }73+74+ gridW := 10.0 * layout.cellSize75+ gridH := 10.0 * layout.cellSize76+ centerX := layout.gridX + gridW/277+ centerY := layout.gridY + gridH/278+ wantX := boardArea.x + boardArea.w/279+ wantY := boardArea.y + boardArea.h/280+81+ if diff := math.Abs(centerX - wantX); diff > 0.8 {82+ t.Fatalf("grid centerX diff = %.3f, want <= 0.8", diff)83+ }84+ if diff := math.Abs(centerY - wantY); diff > 0.8 {85+ t.Fatalf("grid centerY diff = %.3f, want <= 0.8", diff)86+ }87+88+ fullW := float64(tt.rowHintCol+10) * layout.cellSize89+ fullH := float64(tt.colHintRow+10) * layout.cellSize90+ if layout.hintStartX < boardArea.x-0.01 {91+ t.Fatalf("hintStartX = %.3f, want >= %.3f", layout.hintStartX, boardArea.x)92+ }93+ if layout.hintStartY < boardArea.y-0.01 {94+ t.Fatalf("hintStartY = %.3f, want >= %.3f", layout.hintStartY, boardArea.y)95+ }96+ if right := layout.hintStartX + fullW; right > boardArea.x+boardArea.w+0.01 {97+ t.Fatalf("hint block right = %.3f, want <= %.3f", right, boardArea.x+boardArea.w)98+ }99+ if bottom := layout.hintStartY + fullH; bottom > boardArea.y+boardArea.h+0.01 {100+ t.Fatalf("hint block bottom = %.3f, want <= %.3f", bottom, boardArea.y+boardArea.h)101+ }102+ })103+ }104+}
M pdfexport/render_nurikabe_shikaku.go +46 -41
@@ -1,7 +1,6 @@
11 package pdfexport22 33 import (4- "math"54 "strconv"65 "unicode/utf8"76
@@ -14,22 +13,18 @@ func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) {
1413 }1514 1615 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))16+ area := puzzleBoardRect(pageW, pageH, 1)17+ cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact)18+ if cellSize <= 0 {19+ return20+ }2521 2622 blockW := float64(data.Width) * cellSize2723 blockH := float64(data.Height) * cellSize28- startX := (pageW - blockW) / 229- startY := top + (availH-blockH)/224+ startX, startY := centeredOrigin(area, data.Width, data.Height, cellSize)3025 3126 pdf.SetDrawColor(55, 55, 55)32- pdf.SetLineWidth(0.15)27+ pdf.SetLineWidth(thinGridLineMM)3328 for y := 0; y < data.Height; y++ {3429 for x := 0; x < data.Width; x++ {3530 cellX := startX + float64(x)*cellSize
@@ -44,16 +39,23 @@ func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) {
4439 }4540 4641 pdf.SetDrawColor(35, 35, 35)47- pdf.SetLineWidth(0.62)42+ pdf.SetLineWidth(outerBorderLineMM)4843 pdf.Rect(startX, startY, blockW, blockH, "D")4944 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- }45+ ruleY := instructionY(startY+blockH, pageH, 1)46+ setInstructionStyle(pdf)47+ pdf.SetXY(pageMarginXMM, ruleY)48+ pdf.CellFormat(49+ pageW-2*pageMarginXMM,50+ instructionLineHMM,51+ "Expand each numbered island to its size; connect all sea cells into one wall.",52+ "",53+ 0,54+ "C",55+ false,56+ 0,57+ "",58+ )5759 }5860 5961 func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) {
@@ -62,22 +64,18 @@ func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) {
6264 }6365 6466 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))67+ area := puzzleBoardRect(pageW, pageH, 1)68+ cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact)69+ if cellSize <= 0 {70+ return71+ }7372 7473 blockW := float64(data.Width) * cellSize7574 blockH := float64(data.Height) * cellSize76- startX := (pageW - blockW) / 277- startY := top + (availH-blockH)/275+ startX, startY := centeredOrigin(area, data.Width, data.Height, cellSize)7876 7977 pdf.SetDrawColor(55, 55, 55)80- pdf.SetLineWidth(0.15)78+ pdf.SetLineWidth(thinGridLineMM)8179 for y := 0; y < data.Height; y++ {8280 for x := 0; x < data.Width; x++ {8381 cellX := startX + float64(x)*cellSize
@@ -92,16 +90,23 @@ func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) {
9290 }9391 9492 pdf.SetDrawColor(35, 35, 35)95- pdf.SetLineWidth(0.62)93+ pdf.SetLineWidth(outerBorderLineMM)9694 pdf.Rect(startX, startY, blockW, blockH, "D")9795 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- }96+ ruleY := instructionY(startY+blockH, pageH, 1)97+ setInstructionStyle(pdf)98+ pdf.SetXY(pageMarginXMM, ruleY)99+ pdf.CellFormat(100+ pageW-2*pageMarginXMM,101+ instructionLineHMM,102+ "Partition into rectangles where each clue equals its rectangle area.",103+ "",104+ 0,105+ "C",106+ false,107+ 0,108+ "",109+ )105110 }106111 107112 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)
116121 }117122 fontSize = clampStandardCellFontSize(fontSize)118123 119- pdf.SetTextColor(20, 20, 20)120- pdf.SetFont("Helvetica", "B", fontSize)124+ pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)125+ pdf.SetFont(sansFontFamily, "B", fontSize)121126 lineH := fontSize * 0.92122127 pdf.SetXY(x, y+(cellSize-lineH)/2)123128 pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")
M pdfexport/render_takuzu_test.go +2 -2
A pdfexport/render_tokens.go +159 -0
@@ -0,0 +1,159 @@
1+package pdfexport2+3+import (4+ "math"5+6+ "github.com/go-pdf/fpdf"7+)8+9+const (10+ halfLetterWidthMM = 139.711+ halfLetterHeightMM = 215.912+13+ footerTextGray = 7814+ secondaryTextGray = 6015+ ruleTextGray = 5416+ dimTextGray = 9217+ primaryTextGray = 2018+19+ pageMarginXMM = 10.020+ puzzleTopMM = 28.021+ puzzleBottomInsetMM = 16.022+ instructionGapMM = 4.223+ instructionLineHMM = 4.624+ wordSearchGridListGap = 4.025+26+ thinGridLineMM = 0.2427+ majorGridLineMM = 0.3428+ outerBorderLineMM = 0.6229+)30+31+const (32+ puzzleTitleFontSize = 13.033+ puzzleSubtitleFontSize = 9.034+ puzzleInstructionFontSize = 8.235+ puzzleWordBankFontSize = 8.836+ puzzleWordBankHeadSize = 9.237+)38+39+type (40+ boardFamily int41+ rectMM struct {42+ x float6443+ y float6444+ w float6445+ h float6446+ }47+)48+49+const (50+ boardFamilyCompact boardFamily = iota51+ boardFamilySudoku52+ boardFamilyHashi53+ boardFamilyNonogram54+ boardFamilyWordSearch55+ boardFamilyTable56+)57+58+type boardSizing struct {59+ minCell float6460+ maxCell float6461+ targetFill float6462+}63+64+func puzzleBodyRect(pageW, pageH float64) rectMM {65+ return rectMM{66+ x: pageMarginXMM,67+ y: puzzleTopMM,68+ w: pageW - 2*pageMarginXMM,69+ h: pageH - puzzleTopMM - puzzleBottomInsetMM,70+ }71+}72+73+func puzzleBoardRect(pageW, pageH float64, ruleLines int) rectMM {74+ rect := puzzleBodyRect(pageW, pageH)75+ if ruleLines > 0 {76+ rect.h -= instructionGapMM + float64(ruleLines)*instructionLineHMM77+ if rect.h < 0 {78+ rect.h = 079+ }80+ }81+ return rect82+}83+84+func centeredOrigin(area rectMM, cols, rows int, cellSize float64) (float64, float64) {85+ boardW := float64(cols) * cellSize86+ boardH := float64(rows) * cellSize87+ return area.x + (area.w-boardW)/2, area.y + (area.h-boardH)/288+}89+90+func instructionY(boardBottom, pageH float64, lineCount int) float64 {91+ y := boardBottom + instructionGapMM92+ maxY := pageH - puzzleBottomInsetMM - float64(max(lineCount-1, 0))*instructionLineHMM93+ if y > maxY {94+ return maxY95+ }96+ return y97+}98+99+func fitBoardCellSize(cols, rows int, area rectMM, family boardFamily) float64 {100+ if cols <= 0 || rows <= 0 || area.w <= 0 || area.h <= 0 {101+ return 0102+ }103+104+ sizeCfg := boardSizingFor(family)105+ maxFit := math.Min(area.w/float64(cols), area.h/float64(rows))106+ size := clampFloat(maxFit, sizeCfg.minCell, sizeCfg.maxCell)107+ target := (area.h * sizeCfg.targetFill) / float64(rows)108+ if target > size {109+ size = math.Min(target, maxFit)110+ }111+112+ size = clampFloat(size, sizeCfg.minCell, sizeCfg.maxCell)113+ if size > maxFit {114+ return maxFit115+ }116+ return size117+}118+119+func boardSizingFor(family boardFamily) boardSizing {120+ switch family {121+ case boardFamilySudoku:122+ return boardSizing{minCell: 10.8, maxCell: 12.8, targetFill: 0.66}123+ case boardFamilyHashi:124+ return boardSizing{minCell: 6.0, maxCell: 16.2, targetFill: 0.58}125+ case boardFamilyNonogram:126+ return boardSizing{minCell: 3.6, maxCell: 8.8, targetFill: 0.64}127+ case boardFamilyWordSearch:128+ return boardSizing{minCell: 4.8, maxCell: 10.2, targetFill: 0.66}129+ case boardFamilyTable:130+ return boardSizing{minCell: 3.6, maxCell: 11.0, targetFill: 0.60}131+ default:132+ return boardSizing{minCell: 5.4, maxCell: 13.4, targetFill: 0.58}133+ }134+}135+136+func clampFloat(v, minV, maxV float64) float64 {137+ if v < minV {138+ return minV139+ }140+ if v > maxV {141+ return maxV142+ }143+ return v144+}145+146+func setPuzzleTitleStyle(pdf *fpdf.Fpdf) {147+ pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)148+ pdf.SetFont(sansFontFamily, "B", puzzleTitleFontSize)149+}150+151+func setPuzzleSubtitleStyle(pdf *fpdf.Fpdf) {152+ pdf.SetTextColor(secondaryTextGray, secondaryTextGray, secondaryTextGray)153+ pdf.SetFont(sansFontFamily, "", puzzleSubtitleFontSize)154+}155+156+func setInstructionStyle(pdf *fpdf.Fpdf) {157+ pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray)158+ pdf.SetFont(sansFontFamily, "", puzzleInstructionFontSize)159+}