- commit
- 8d700dec92f33d404c169028fa0b580df5c0c7c2
- parent
- c82d6433d7ca18173f959703aa2f43043ac7db28
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 9 | +- |
| M | cmd/ | 4 | +- |
| M | cmd/ | 76 | ------ |
| M | cmd/ | 63 | +----- |
| M | pdfexport/ | 4 | +- |
| M | pdfexport/ | 8 | +- |
| A | pdfexport/ | 27 | ++ |
| A | pdfexport/ | bin | |
| A | pdfexport/ | bin | |
| A | pdfexport/ | 93 | ++++++++ |
| M | pdfexport/ | 33 | +--- |
| M | pdfexport/ | 130 | ++--------- |
| M | pdfexport/ | 360 | +++++++++++++++++++----------- |
| M | pdfexport/ | 67 | ++++-- |
| M | pdfexport/ | 119 | +++++----- |
| A | pdfexport/ | 104 | +++++++++ |
| M | pdfexport/ | 87 | ++++--- |
| M | pdfexport/ | 4 | +- |
| A | pdfexport/ | 159 | +++++++++++++ |
@@ -119,12 +119,19 @@ puzzletea new nonogram mini -e 6 -o nonogram-mini-set.jsonl
119119 puzzletea new sudoku --export 10 -o sudoku-mixed.jsonl --with-seed zine-issue-01120120 ```121121 122-Render one or more JSONL packs into an A5 print PDF:122+Render one or more JSONL packs into a half-letter print PDF:123123 124124 ```bash125125 puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01126126 ```127127 128+Font license note (Atkinson Hyperlegible Next):129+130+- Follow the SIL OFL 1.1 requirements in `pdfexport/fonts/OFL.txt`.131+- Do not sell the font files by themselves.132+- If redistributing fonts with software, include the copyright notice and OFL text.133+- Modified font versions must keep OFL terms, and modified names must respect Reserved Font Name rules.134+128135 `Lights Out` is currently excluded from export because it does not translate cleanly to paper workflows.129136 130137 Override the color theme:@@ -22,8 +22,8 @@ var (
2222 2323 var exportPDFCmd = &cobra.Command{2424 Use: "export-pdf <input.jsonl> [more.jsonl ...]",25- Short: "Convert one or more PuzzleTea JSONL exports into an A5 printable PDF",26- Long: "Parse one or more JSONL export files, order puzzles by progressive difficulty with seeded mixing, and render an A5 PDF with a title page and one puzzle per page.",25+ Short: "Convert one or more PuzzleTea JSONL exports into a half-letter printable PDF",26+ Long: "Parse one or more JSONL export files, order puzzles by progressive difficulty with seeded mixing, and render a half-letter PDF with a title page and one puzzle per page.",2727 Args: cobra.MinimumNArgs(1),2828 RunE: runExportPDF,2929 }@@ -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 {@@ -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 @@ -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 @@ -0,0 +1,27 @@
1+package pdfexport2+3+import (4+ _ "embed"5+ "fmt"6+7+ "github.com/go-pdf/fpdf"8+)9+10+const sansFontFamily = "AtkinsonHyperlegibleNext"11+12+var (13+ //go:embed fonts/AtkinsonHyperlegibleNext-Regular.ttf14+ atkinsonRegularTTF []byte15+16+ //go:embed fonts/AtkinsonHyperlegibleNext-Bold.ttf17+ atkinsonBoldTTF []byte18+)19+20+func registerPDFFonts(pdf *fpdf.Fpdf) error {21+ pdf.AddUTF8FontFromBytes(sansFontFamily, "", atkinsonRegularTTF)22+ pdf.AddUTF8FontFromBytes(sansFontFamily, "B", atkinsonBoldTTF)23+ if pdf.Err() {24+ return fmt.Errorf("register pdf fonts: %w", pdf.Error())25+ }26+ return nil27+}Binary file.
Binary file.
@@ -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.
@@ -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 @@ -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+}@@ -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 := ""@@ -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, "")@@ -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, "")@@ -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+}@@ -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, "")@@ -14,8 +14,8 @@ func TestTakuzuGivenFontSize(t *testing.T) {
1414 name: "small cell keeps readable minimum",1515 cellSize: 3.0,1616 size: 14,17- wantMin: 4.2,18- wantMax: 4.2,17+ wantMin: 5.2,18+ wantMax: 5.2,1919 },2020 {2121 name: "12x12 remains comfortably readable",@@ -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+}