- commit
- a8f05ce31fbc1f820a85b057ee4618dd32c4e6b4
- parent
- 8d700dec92f33d404c169028fa0b580df5c0c7c2
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 4 | +- |
| M | cmd/ | 86 | ++++++- |
| A | cmd/ | 115 | ++++++++++ |
| M | pdfexport/ | 9 | +- |
| R | pdfexport/ | 0 | |
| A | pdfexport/ | 93 | ++++++++ |
| A | pdfexport/ | bin | |
| M | pdfexport/ | 80 | +++++- |
| A | pdfexport/ | 360 | ++++++++++++++++++++++++++++++ |
| A | pdfexport/ | 40 | +++ |
| M | pdfexport/ | 7 | +- |
| M | pdfexport/ | 18 | +- |
| M | pdfexport/ | 77 | ++++++- |
| M | pdfexport/ | 14 | +- |
| M | pdfexport/ | 28 | ++- |
| M | pdfexport/ | 13 | +- |
@@ -122,9 +122,11 @@ puzzletea new sudoku --export 10 -o sudoku-mixed.jsonl --with-seed zine-issue-01
122122 Render one or more JSONL packs into a half-letter print PDF:123123 124124 ```bash125-puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01125+puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01 --volume 1 --title "Catacombs & Pines"126126 ```127127 128+`--title` sets the cover subtitle, and `--volume` sets the cover volume number.129+128130 Font license note (Atkinson Hyperlegible Next):129131 130132 - Follow the SIL OFL 1.1 requirements in `pdfexport/fonts/OFL.txt`.@@ -3,6 +3,7 @@ package cmd
33 import (44 "fmt"55 "path/filepath"6+ "strconv"67 "strings"78 "time"89 @@ -16,8 +17,10 @@ import (
1617 var (1718 flagPDFOutput string1819 flagPDFTitle string20+ flagPDFVolume int1921 flagPDFAdvert string2022 flagPDFShuffleSeed string23+ flagPDFCoverColor string2124 )2225 2326 var exportPDFCmd = &cobra.Command{@@ -30,9 +33,11 @@ var exportPDFCmd = &cobra.Command{
3033 3134 func init() {3235 exportPDFCmd.Flags().StringVarP(&flagPDFOutput, "output", "o", "", "write output PDF path (defaults to <first-input>-print.pdf)")33- exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "title shown on the generated title page")36+ exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "subtitle shown on the cover")37+ exportPDFCmd.Flags().IntVar(&flagPDFVolume, "volume", 1, "volume number shown on the cover (must be >= 1)")3438 exportPDFCmd.Flags().StringVar(&flagPDFAdvert, "advert", "Find more puzzles at github.com/FelineStateMachine/puzzletea", "advert text shown on the title page")3539 exportPDFCmd.Flags().StringVar(&flagPDFShuffleSeed, "shuffle-seed", "", "seed for deterministic within-band difficulty mixing")40+ exportPDFCmd.Flags().StringVar(&flagPDFCoverColor, "cover-color", "", `accent color for cover page: hex "#RRGGBB", decimal "R,G,B", or omit for random vibrant nature tone`)3641 }3742 3843 func runExportPDF(cmd *cobra.Command, args []string) error {@@ -64,16 +69,9 @@ func runExportPDF(cmd *cobra.Command, args []string) error {
6469 return fmt.Errorf("--output must use a .pdf extension")6570 }6671 67- title := strings.TrimSpace(flagPDFTitle)68- if title == "" {69- title = defaultPDFTitle(docs)70- }71-72- cfg := pdfexport.RenderConfig{73- Title: title,74- AdvertText: flagPDFAdvert,75- GeneratedAt: time.Now(),76- ShuffleSeed: shuffleSeed,72+ cfg, err := buildRenderConfigForPDF(docs, shuffleSeed, time.Now())73+ if err != nil {74+ return err7775 }7876 if err := pdfexport.WritePDF(output, docs, ordered, cfg); err != nil {7977 return err@@ -101,6 +99,39 @@ func defaultPDFTitle(docs []pdfexport.PackDocument) string {
10199 return "PuzzleTea Mixed Puzzle Pack"102100 }103101 102+func validatePDFVolume(volume int) error {103+ if volume < 1 {104+ return fmt.Errorf("--volume must be >= 1")105+ }106+ return nil107+}108+109+func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string, now time.Time) (pdfexport.RenderConfig, error) {110+ if err := validatePDFVolume(flagPDFVolume); err != nil {111+ return pdfexport.RenderConfig{}, err112+ }113+114+ subtitle := strings.TrimSpace(flagPDFTitle)115+ if subtitle == "" {116+ subtitle = defaultPDFTitle(docs)117+ }118+119+ coverColor, err := parseCoverColor(flagPDFCoverColor)120+ if err != nil {121+ return pdfexport.RenderConfig{}, fmt.Errorf("--cover-color: %w", err)122+ }123+124+ cfg := pdfexport.RenderConfig{125+ CoverSubtitle: subtitle,126+ VolumeNumber: flagPDFVolume,127+ AdvertText: flagPDFAdvert,128+ GeneratedAt: now,129+ ShuffleSeed: shuffleSeed,130+ CoverColor: coverColor,131+ }132+ return cfg, nil133+}134+104135 func buildModeDifficultyLookup(categories []game.Category) map[string]map[string]float64 {105136 lookup := make(map[string]map[string]float64, len(categories))106137 @@ -176,3 +207,36 @@ func normalizeDifficultyToken(s string) string {
176207 s = strings.ReplaceAll(s, "_", " ")177208 return strings.Join(strings.Fields(s), " ")178209 }210+211+// parseCoverColor parses a cover color string in hex ("#RRGGBB") or212+// decimal ("R,G,B") format. Returns nil if s is empty (random vibrant nature tone).213+func parseCoverColor(s string) (*pdfexport.RGB, error) {214+ s = strings.TrimSpace(s)215+ if s == "" {216+ return nil, nil217+ }218+219+ // Hex format: #RRGGBB or RRGGBB220+ hex := strings.TrimPrefix(s, "#")221+ if len(hex) == 6 {222+ r, errR := strconv.ParseUint(hex[0:2], 16, 8)223+ g, errG := strconv.ParseUint(hex[2:4], 16, 8)224+ b, errB := strconv.ParseUint(hex[4:6], 16, 8)225+ if errR == nil && errG == nil && errB == nil {226+ return &pdfexport.RGB{R: uint8(r), G: uint8(g), B: uint8(b)}, nil227+ }228+ }229+230+ // Decimal format: R,G,B231+ parts := strings.Split(s, ",")232+ if len(parts) == 3 {233+ r, errR := strconv.ParseUint(strings.TrimSpace(parts[0]), 10, 8)234+ g, errG := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 8)235+ b, errB := strconv.ParseUint(strings.TrimSpace(parts[2]), 10, 8)236+ if errR == nil && errG == nil && errB == nil {237+ return &pdfexport.RGB{R: uint8(r), G: uint8(g), B: uint8(b)}, nil238+ }239+ }240+241+ return nil, fmt.Errorf("invalid color %q — use hex \"#RRGGBB\" or decimal \"R,G,B\"", s)242+}@@ -0,0 +1,115 @@
1+package cmd2+3+import (4+ "strings"5+ "testing"6+ "time"7+8+ "github.com/FelineStateMachine/puzzletea/pdfexport"9+)10+11+func TestExportPDFVolumeFlagDefault(t *testing.T) {12+ flag := exportPDFCmd.Flags().Lookup("volume")13+ if flag == nil {14+ t.Fatal("expected --volume flag")15+ }16+ if flag.DefValue != "1" {17+ t.Fatalf("default volume = %q, want %q", flag.DefValue, "1")18+ }19+}20+21+func TestValidatePDFVolume(t *testing.T) {22+ tests := []struct {23+ name string24+ volume int25+ wantOK bool26+ }{27+ {name: "default volume", volume: 1, wantOK: true},28+ {name: "positive volume", volume: 7, wantOK: true},29+ {name: "zero volume rejected", volume: 0, wantOK: false},30+ {name: "negative volume rejected", volume: -3, wantOK: false},31+ }32+33+ for _, tt := range tests {34+ t.Run(tt.name, func(t *testing.T) {35+ err := validatePDFVolume(tt.volume)36+ if tt.wantOK && err != nil {37+ t.Fatalf("validatePDFVolume(%d) error = %v, want nil", tt.volume, err)38+ }39+ if !tt.wantOK {40+ if err == nil {41+ t.Fatalf("validatePDFVolume(%d) expected error", tt.volume)42+ }43+ if !strings.Contains(err.Error(), "--volume") {44+ t.Fatalf("validatePDFVolume(%d) error = %q, want mention of --volume", tt.volume, err.Error())45+ }46+ }47+ })48+ }49+}50+51+func TestBuildRenderConfigForPDFUsesTitleAsCoverSubtitle(t *testing.T) {52+ reset := snapshotExportPDFFlags()53+ defer reset()54+55+ flagPDFTitle = "Catacombs & Pines"56+ flagPDFVolume = 757+ flagPDFAdvert = "Custom advert"58+ flagPDFCoverColor = ""59+60+ now := time.Date(2026, 2, 22, 11, 0, 0, 0, time.UTC)61+ docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Nonogram"}}}62+ cfg, err := buildRenderConfigForPDF(docs, "seed-1", now)63+ if err != nil {64+ t.Fatalf("buildRenderConfigForPDF error = %v", err)65+ }66+ if cfg.CoverSubtitle != "Catacombs & Pines" {67+ t.Fatalf("CoverSubtitle = %q, want %q", cfg.CoverSubtitle, "Catacombs & Pines")68+ }69+ if cfg.VolumeNumber != 7 {70+ t.Fatalf("VolumeNumber = %d, want %d", cfg.VolumeNumber, 7)71+ }72+ if cfg.AdvertText != "Custom advert" {73+ t.Fatalf("AdvertText = %q, want %q", cfg.AdvertText, "Custom advert")74+ }75+ if !cfg.GeneratedAt.Equal(now) {76+ t.Fatalf("GeneratedAt = %v, want %v", cfg.GeneratedAt, now)77+ }78+}79+80+func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) {81+ reset := snapshotExportPDFFlags()82+ defer reset()83+84+ flagPDFTitle = ""85+ flagPDFVolume = 186+ flagPDFAdvert = "Find more puzzles"87+ flagPDFCoverColor = ""88+89+ docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Sudoku"}}}90+ cfg, err := buildRenderConfigForPDF(docs, "seed-2", time.Now())91+ if err != nil {92+ t.Fatalf("buildRenderConfigForPDF error = %v", err)93+ }94+ if cfg.CoverSubtitle != "Sudoku Puzzle Pack" {95+ t.Fatalf("CoverSubtitle = %q, want %q", cfg.CoverSubtitle, "Sudoku Puzzle Pack")96+ }97+}98+99+func snapshotExportPDFFlags() func() {100+ oldTitle := flagPDFTitle101+ oldVolume := flagPDFVolume102+ oldAdvert := flagPDFAdvert103+ oldCoverColor := flagPDFCoverColor104+ oldOutput := flagPDFOutput105+ oldShuffle := flagPDFShuffleSeed106+107+ return func() {108+ flagPDFTitle = oldTitle109+ flagPDFVolume = oldVolume110+ flagPDFAdvert = oldAdvert111+ flagPDFCoverColor = oldCoverColor112+ flagPDFOutput = oldOutput113+ flagPDFShuffleSeed = oldShuffle114+ }115+}@@ -7,7 +7,10 @@ import (
77 "github.com/go-pdf/fpdf"88 )99 10-const sansFontFamily = "AtkinsonHyperlegibleNext"10+const (11+ sansFontFamily = "AtkinsonHyperlegibleNext"12+ coverFontFamily = "DMSerifDisplay"13+)1114 1215 var (1316 //go:embed fonts/AtkinsonHyperlegibleNext-Regular.ttf@@ -15,11 +18,15 @@ var (
1518 1619 //go:embed fonts/AtkinsonHyperlegibleNext-Bold.ttf1720 atkinsonBoldTTF []byte21+22+ //go:embed fonts/DMSerifDisplay-Regular.ttf23+ dmSerifDisplayTTF []byte1824 )1925 2026 func registerPDFFonts(pdf *fpdf.Fpdf) error {2127 pdf.AddUTF8FontFromBytes(sansFontFamily, "", atkinsonRegularTTF)2228 pdf.AddUTF8FontFromBytes(sansFontFamily, "B", atkinsonBoldTTF)29+ pdf.AddUTF8FontFromBytes(coverFontFamily, "", dmSerifDisplayTTF)2330 if pdf.Err() {2431 return fmt.Errorf("register pdf fonts: %w", pdf.Error())2532 }No content changes.
@@ -0,0 +1,93 @@
1+Copyright 2014-2018 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. Copyright 2019 Google LLC.
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+
5+This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
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.
Binary file.
@@ -24,8 +24,14 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
2424 if cfg.GeneratedAt.IsZero() {2525 cfg.GeneratedAt = time.Now()2626 }27+ if cfg.VolumeNumber < 1 {28+ cfg.VolumeNumber = 129+ }30+ if strings.TrimSpace(cfg.CoverSubtitle) == "" {31+ cfg.CoverSubtitle = defaultTitle(docs)32+ }2733 if strings.TrimSpace(cfg.Title) == "" {28- cfg.Title = defaultTitle(docs)34+ cfg.Title = fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber)2935 }3036 if strings.TrimSpace(cfg.AdvertText) == "" {3137 cfg.AdvertText = "Find more puzzles at github.com/FelineStateMachine/puzzletea"@@ -46,21 +52,37 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
4652 pdf.SetCreator("PuzzleTea", true)4753 pdf.SetAuthor("PuzzleTea", true)4854 pdf.SetTitle(cfg.Title, true)55+ footerExcludedPages := map[int]struct{}{}4956 pdf.SetFooterFunc(func() {50- if pdf.PageNo() <= 1 {57+ pageNo := pdf.PageNo()58+ if pageNo <= 2 {59+ return60+ }61+ if _, skip := footerExcludedPages[pageNo]; skip {5162 return5263 }5364 pdf.SetY(-8)5465 pdf.SetFont(sansFontFamily, "", 8)5566 pdf.SetTextColor(footerTextGray, footerTextGray, footerTextGray)56- pdf.CellFormat(0, 4, strconv.Itoa(pdf.PageNo()), "", 0, "C", false, 0, "")67+ pdf.CellFormat(0, 4, strconv.Itoa(pageNo), "", 0, "C", false, 0, "")5768 })5869 70+ coverColor := resolveCoverColor(cfg)71+ renderCoverPage(pdf, puzzles, cfg)5972 renderTitlePage(pdf, docs, puzzles, cfg)6073 for _, puzzle := range puzzles {6174 renderPuzzlePage(pdf, puzzle)6275 }6376 77+ totalPagesWithoutPadding := pdf.PageNo() + 1 // include upcoming back cover78+ for range saddleStitchPadCount(totalPagesWithoutPadding) {79+ renderPadPage(pdf)80+ footerExcludedPages[pdf.PageNo()] = struct{}{}81+ }82+83+ renderBackCoverPage(pdf, cfg, coverColor)84+ footerExcludedPages[pdf.PageNo()] = struct{}{}85+6486 dir := filepath.Dir(outputPath)6587 if dir != "" && dir != "." {6688 if err := os.MkdirAll(dir, 0o755); err != nil {@@ -74,6 +96,22 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
7496 return nil7597 }7698 99+func saddleStitchPadCount(totalPages int) int {100+ if totalPages <= 0 {101+ return 0102+ }103+104+ remainder := totalPages % 4105+ if remainder == 0 {106+ return 0107+ }108+ return 4 - remainder109+}110+111+func renderPadPage(pdf *fpdf.Fpdf) {112+ pdf.AddPage()113+}114+77115 func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) {78116 pdf.AddPage()79117 pageW, pageH := pdf.GetPageSize()@@ -82,14 +120,19 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
82120 pdf.SetTextColor(20, 20, 20)83121 pdf.SetFont(sansFontFamily, "B", 22)84122 pdf.SetXY(0, 24)85- pdf.CellFormat(pageW, 10, cfg.Title, "", 0, "C", false, 0, "")123+ pdf.CellFormat(pageW, 10, fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber), "", 0, "C", false, 0, "")124+125+ pdf.SetTextColor(50, 50, 50)126+ pdf.SetFont(coverFontFamily, "", 16)127+ pdf.SetXY(0, 35)128+ pdf.CellFormat(pageW, 8, cfg.CoverSubtitle, "", 0, "C", false, 0, "")86129 87130 pdf.SetFont(sansFontFamily, "", 11)88131 pdf.SetTextColor(70, 70, 70)89- pdf.SetXY(0, 36)132+ pdf.SetXY(0, 44)90133 pdf.CellFormat(pageW, 6, "PuzzleTea Puzzle Pack", "", 0, "C", false, 0, "")91134 92- metaY := 50.0135+ metaY := 56.093136 pdf.SetTextColor(25, 25, 25)94137 pdf.SetFont(sansFontFamily, "", 10)95138 pdf.SetXY(margin, metaY)@@ -205,6 +248,7 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {
205248 }206249 207250 pageW, pageH := pdf.GetPageSize()251+ pageNo := pdf.PageNo()208252 209253 rowHints := normalizeNonogramHintsForRender(data.RowHints, data.Height)210254 colHints := normalizeNonogramHintsForRender(data.ColHints, data.Width)@@ -221,6 +265,7 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {
221265 layout := layoutNonogram(222266 pageW,223267 pageH,268+ pageNo,224269 data.Width,225270 data.Height,226271 rowHintCols,@@ -267,10 +312,11 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {
267312 268313 ruleY := ySep + gridH + 3.5269314 ruleY = instructionY(ruleY-3.5, pageH, 1)315+ body := puzzleBodyRect(pageW, pageH, pageNo)270316 setInstructionStyle(pdf)271- pdf.SetXY(pageMarginXMM, ruleY)317+ pdf.SetXY(body.x, ruleY)272318 pdf.CellFormat(273- pageW-2*pageMarginXMM,319+ body.w,274320 instructionLineHMM,275321 "Use row/column hints to fill blocks in order; groups are separated by at least one blank cell.",276322 "",@@ -334,6 +380,7 @@ type nonogramLayout struct {
334380 func layoutNonogram(335381 pageW,336382 pageH float64,383+ pageNo,337384 gridCols,338385 gridRows,339386 rowHintCols,@@ -341,7 +388,7 @@ func layoutNonogram(
341388 ) nonogramLayout {342389 totalCols := rowHintCols + gridCols343390 totalRows := colHintRows + gridRows344- area := puzzleBoardRect(pageW, pageH, 1)391+ area := puzzleBoardRect(pageW, pageH, pageNo, 1)345392 cellSize := fitBoardCellSize(totalCols, totalRows, area, boardFamilyNonogram)346393 if cellSize <= 0 {347394 return nonogramLayout{}@@ -382,7 +429,8 @@ func renderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) {
382429 }383430 384431 pageW, pageH := pdf.GetPageSize()385- area := puzzleBoardRect(pageW, pageH, 1)432+ pageNo := pdf.PageNo()433+ area := puzzleBoardRect(pageW, pageH, pageNo, 1)386434 cellSize := fitBoardCellSize(9, 9, area, boardFamilySudoku)387435 if cellSize <= 0 {388436 return@@ -396,9 +444,9 @@ func renderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) {
396444 397445 ruleY := instructionY(startY+boardH, pageH, 1)398446 setInstructionStyle(pdf)399- pdf.SetXY(pageMarginXMM, ruleY)447+ pdf.SetXY(area.x, ruleY)400448 pdf.CellFormat(401- pageW-2*pageMarginXMM,449+ area.w,402450 instructionLineHMM,403451 "Fill rows, columns, and 3x3 boxes with 1-9",404452 "",@@ -463,7 +511,8 @@ func renderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) {
463511 }464512 465513 pageW, pageH := pdf.GetPageSize()466- body := puzzleBodyRect(pageW, pageH)514+ pageNo := pdf.PageNo()515+ body := puzzleBodyRect(pageW, pageH, pageNo)467516 availW := body.w468517 availH := body.h469518 @@ -691,6 +740,7 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) {
691740 }692741 693742 pageW, pageH := pdf.GetPageSize()743+ pageNo := pdf.PageNo()694744 695745 rows := len(table.Rows)696746 cols := 0@@ -703,7 +753,7 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) {
703753 return704754 }705755 706- area := puzzleBoardRect(pageW, pageH, 0)756+ area := puzzleBoardRect(pageW, pageH, pageNo, 0)707757 cellSize := fitBoardCellSize(cols, rows, area, boardFamilyTable)708758 if cellSize <= 0 {709759 return@@ -754,7 +804,7 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) {
754804 755805 func renderFallbackPage(pdf *fpdf.Fpdf, puzzle Puzzle, pageH float64) {756806 pageW, _ := pdf.GetPageSize()757- area := puzzleBoardRect(pageW, pageH, 0)807+ area := puzzleBoardRect(pageW, pageH, pdf.PageNo(), 0)758808 availW := area.w759809 availH := area.h760810 @@ -0,0 +1,360 @@
1+package pdfexport2+3+import (4+ "fmt"5+ "hash/fnv"6+ "math"7+ "math/rand"8+ "strings"9+10+ "github.com/go-pdf/fpdf"11+)12+13+var natureTonePalette = []RGB{14+ {250, 76, 56}, // riso red15+ {255, 112, 0}, // fluorescent orange16+ {255, 183, 3}, // sunflower17+ {53, 169, 255}, // sky blue18+ {0, 204, 160}, // seafoam19+ {152, 226, 68}, // neon moss20+ {255, 82, 133}, // hot pink21+ {104, 83, 255}, // ultramarine22+}23+24+func resolveCoverColor(cfg RenderConfig) RGB {25+ if cfg.CoverColor != nil {26+ return *cfg.CoverColor27+ }28+ seed := strings.TrimSpace(cfg.ShuffleSeed)29+ var idx int30+ if seed != "" {31+ h := fnv.New64a()32+ h.Write([]byte(seed))33+ idx = int(h.Sum64() % uint64(len(natureTonePalette)))34+ } else {35+ idx = rand.Intn(len(natureTonePalette))36+ }37+ return natureTonePalette[idx]38+}39+40+func splitCoverTextLines(pdf *fpdf.Fpdf, text string, maxW float64) []string {41+ trimmed := strings.TrimSpace(text)42+ if trimmed == "" {43+ return []string{"PuzzleTea Collection"}44+ }45+ chunks := pdf.SplitLines([]byte(trimmed), maxW)46+ if len(chunks) == 0 {47+ return []string{trimmed}48+ }49+ lines := make([]string, 0, len(chunks))50+ for _, c := range chunks {51+ lines = append(lines, string(c))52+ }53+ return lines54+}55+56+func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxLines int) []string {57+ if maxLines < 1 {58+ maxLines = 159+ }60+ lines := splitCoverTextLines(pdf, subtitle, maxW)61+ if len(lines) <= maxLines {62+ return lines63+ }64+65+ out := make([]string, 0, maxLines)66+ out = append(out, lines[:maxLines-1]...)67+ out = append(out, strings.Join(lines[maxLines-1:], " "))68+ return out69+}70+71+func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig) {72+ coverColor := resolveCoverColor(cfg)73+ ink := RGB{R: 8, G: 8, B: 8}74+75+ pdf.AddPage()76+ pageW, pageH := pdf.GetPageSize()77+78+ pdf.SetFillColor(int(coverColor.R), int(coverColor.G), int(coverColor.B))79+ pdf.Rect(0, 0, pageW, pageH, "F")80+81+ frameInset := 7.582+ drawCoverFrame(pdf, frameInset, pageW, pageH, ink)83+84+ scene := rectMM{x: frameInset + 4.0, y: frameInset + 10.0, w: pageW - (frameInset+4.0)*2, h: 132.0}85+ drawWoodcutScene(pdf, scene, coverColor, ink, cfg.ShuffleSeed)86+87+ subtitle := strings.TrimSpace(cfg.CoverSubtitle)88+ if subtitle == "" {89+ subtitle = "PuzzleTea Collection"90+ }91+92+ labelW := pageW - 2*(frameInset+6.0)93+ fontSize := 20.094+ for fontSize >= 13.0 {95+ pdf.SetFont(coverFontFamily, "", fontSize)96+ if len(splitCoverTextLines(pdf, subtitle, labelW)) <= 2 {97+ break98+ }99+ fontSize -= 1.0100+ }101+102+ pdf.SetFont(sansFontFamily, "B", 9.8)103+ pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B))104+ pdf.SetXY(frameInset+6.0, frameInset+2.8)105+ pdf.CellFormat(labelW, 5.0, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "")106+107+ pdf.SetFont(coverFontFamily, "", fontSize)108+ titleLines := splitCoverSubtitleLines(pdf, subtitle, labelW, 2)109+ lineH := fontSize * 0.45110+ y := scene.y + scene.h + 8.5111+ for _, line := range titleLines {112+ pdf.SetXY(frameInset+6.0, y)113+ pdf.CellFormat(labelW, lineH, line, "", 0, "L", false, 0, "")114+ y += lineH115+ }116+117+ pdf.SetFont(sansFontFamily, "B", 7.8)118+ pdf.SetXY(frameInset+6.0, pageH-frameInset-7.0)119+ pdf.CellFormat(labelW, 4.0, "PuzzleTea", "", 0, "L", false, 0, "")120+}121+122+func drawCoverFrame(pdf *fpdf.Fpdf, inset, pageW, pageH float64, ink RGB) {123+ pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))124+ pdf.SetLineWidth(1.1)125+ pdf.Rect(inset, inset, pageW-2*inset, pageH-2*inset, "D")126+127+ pdf.SetLineWidth(0.28)128+ inner := inset + 1.7129+ pdf.Rect(inner, inner, pageW-2*inner, pageH-2*inner, "D")130+}131+132+func drawWoodcutScene(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB, seed string) {133+ drawStippleTexture(pdf, scene, ink, seed)134+ drawHatchingBands(pdf, scene, ink)135+ drawPineForestMass(pdf, scene, ink)136+ drawIsometricRuins(pdf, scene, bg, ink)137+ drawSkullMotifs(pdf, scene, bg, ink)138+}139+140+func drawStippleTexture(pdf *fpdf.Fpdf, scene rectMM, ink RGB, seed string) {141+ h := fnv.New64a()142+ h.Write([]byte(strings.TrimSpace(seed) + "-stipple"))143+ rng := rand.New(rand.NewSource(int64(h.Sum64())))144+145+ pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B))146+ for x := scene.x + 1.0; x < scene.x+scene.w-1.0; x += 3.1 {147+ for y := scene.y + 2.0; y < scene.y+scene.h*0.58; y += 3.2 {148+ if rng.Float64() < 0.22 {149+ jitterX := (rng.Float64() - 0.5) * 0.9150+ jitterY := (rng.Float64() - 0.5) * 0.9151+ pdf.Circle(x+jitterX, y+jitterY, 0.11, "F")152+ }153+ }154+ }155+}156+157+func drawHatchingBands(pdf *fpdf.Fpdf, scene rectMM, ink RGB) {158+ pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))159+ pdf.SetLineWidth(0.28)160+161+ baseY := scene.y + scene.h*0.64162+ for i := -8; i < 34; i++ {163+ x0 := scene.x + float64(i)*4.4164+ y0 := baseY165+ x1 := x0 + scene.h*0.55166+ y1 := baseY + scene.h*0.30167+ pdf.Line(x0, y0, x1, y1)168+ }169+170+ for i := -8; i < 30; i++ {171+ x0 := scene.x + float64(i)*5.0172+ y0 := baseY + 7.5173+ x1 := x0 + scene.h*0.48174+ y1 := baseY + scene.h*0.33175+ pdf.Line(x0, y0, x1, y1)176+ }177+}178+179+func drawPineForestMass(pdf *fpdf.Fpdf, scene rectMM, ink RGB) {180+ pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B))181+ pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))182+183+ layers := []struct {184+ y float64185+ count int186+ w float64187+ h float64188+ offset float64189+ }{190+ {y: scene.y + scene.h*0.45, count: 22, w: 4.4, h: 13.0, offset: 0.8},191+ {y: scene.y + scene.h*0.53, count: 19, w: 5.4, h: 16.0, offset: 1.4},192+ {y: scene.y + scene.h*0.62, count: 16, w: 6.2, h: 18.4, offset: 2.0},193+ }194+195+ for _, layer := range layers {196+ step := scene.w / float64(layer.count)197+ for i := 0; i < layer.count; i++ {198+ cx := scene.x + float64(i)*step + layer.offset199+ pts := []fpdf.PointType{200+ {X: cx, Y: layer.y - layer.h},201+ {X: cx - layer.w, Y: layer.y},202+ {X: cx + layer.w, Y: layer.y},203+ }204+ pdf.Polygon(pts, "F")205+ pdf.SetLineWidth(0.22)206+ pdf.Line(cx, layer.y, cx, layer.y+2.4)207+ }208+ }209+}210+211+func drawIsometricRuins(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB) {212+ pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))213+ pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B))214+215+ centerX := scene.x + scene.w*0.52216+ baseY := scene.y + scene.h*0.80217+ drawIsometricGrid(pdf, centerX-44, baseY-17, 88, 34, ink)218+219+ drawStoneArch(pdf, centerX-22, baseY-35, 25, 27, 8, bg, ink)220+ drawStoneArch(pdf, centerX+8, baseY-48, 20, 23, 7, bg, ink)221+222+ drawRubbleBlock(pdf, centerX-39, baseY-6, 9, 5, 5, ink)223+ drawRubbleBlock(pdf, centerX+31, baseY-3, 10, 5, 6, ink)224+ drawRubbleBlock(pdf, centerX+12, baseY+5, 8, 4, 4, ink)225+}226+227+func drawIsometricGrid(pdf *fpdf.Fpdf, x, y, w, h float64, ink RGB) {228+ pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))229+ pdf.SetLineWidth(0.26)230+231+ for i := -8; i < 14; i++ {232+ sx := x + float64(i)*4233+ pdf.Line(sx, y, sx+w*0.5, y+h*0.5)234+ }235+ for i := -8; i < 14; i++ {236+ sx := x + w + float64(i)*4237+ pdf.Line(sx, y, sx-w*0.5, y+h*0.5)238+ }239+}240+241+func drawStoneArch(pdf *fpdf.Fpdf, x, y, w, h, depth float64, bg, ink RGB) {242+ frontLeft := []fpdf.PointType{{X: x, Y: y}, {X: x + w*0.18, Y: y - depth}, {X: x + w*0.18, Y: y + h - depth}, {X: x, Y: y + h}}243+ frontRight := []fpdf.PointType{{X: x + w*0.82, Y: y - depth}, {X: x + w, Y: y}, {X: x + w, Y: y + h}, {X: x + w*0.82, Y: y + h - depth}}244+245+ pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B))246+ pdf.Polygon(frontLeft, "F")247+ pdf.Polygon(frontRight, "F")248+249+ outerR := w * 0.50250+ innerR := w * 0.33251+ cx := x + w*0.5252+ cy := y + h*0.42253+ drawArcPolyline(pdf, cx, cy, outerR, math.Pi, 2*math.Pi, 24)254+255+ pdf.SetDrawColor(int(bg.R), int(bg.G), int(bg.B))256+ pdf.SetLineWidth(2.0)257+ drawArcPolyline(pdf, cx, cy, innerR, math.Pi, 2*math.Pi, 24)258+ pdf.Line(x+w*0.33, y+h, x+w*0.33, cy)259+ pdf.Line(x+w*0.67, y+h-depth*0.2, x+w*0.67, cy)260+261+ pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))262+ pdf.SetLineWidth(0.28)263+ for i := 0; i < 6; i++ {264+ sy := y + 3 + float64(i)*4.2265+ pdf.Line(x+1.5, sy, x+w*0.16, sy-0.6)266+ pdf.Line(x+w*0.84, sy-0.5, x+w-1.5, sy+0.4)267+ }268+}269+270+func drawArcPolyline(pdf *fpdf.Fpdf, cx, cy, r, start, end float64, segments int) {271+ if segments < 2 {272+ segments = 2273+ }274+ step := (end - start) / float64(segments)275+ px := cx + math.Cos(start)*r276+ py := cy + math.Sin(start)*r277+ for i := 1; i <= segments; i++ {278+ a := start + float64(i)*step279+ x := cx + math.Cos(a)*r280+ y := cy + math.Sin(a)*r281+ pdf.Line(px, py, x, y)282+ px, py = x, y283+ }284+}285+286+func drawRubbleBlock(pdf *fpdf.Fpdf, x, y, w, h, skew float64, ink RGB) {287+ pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B))288+ pts := []fpdf.PointType{289+ {X: x, Y: y},290+ {X: x + w, Y: y - skew*0.2},291+ {X: x + w + skew, Y: y + h},292+ {X: x + skew, Y: y + h + skew*0.2},293+ }294+ pdf.Polygon(pts, "F")295+}296+297+func drawSkullMotifs(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB) {298+ centers := []struct {299+ x float64300+ y float64301+ r float64302+ }{303+ {x: scene.x + scene.w*0.30, y: scene.y + scene.h*0.84, r: 2.9},304+ {x: scene.x + scene.w*0.69, y: scene.y + scene.h*0.87, r: 2.4},305+ }306+307+ for _, c := range centers {308+ pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))309+ pdf.SetFillColor(int(ink.R), int(ink.G), int(ink.B))310+ pdf.Circle(c.x, c.y, c.r, "F")311+312+ pdf.SetFillColor(int(bg.R), int(bg.G), int(bg.B))313+ pdf.Circle(c.x-c.r*0.35, c.y-c.r*0.1, c.r*0.22, "F")314+ pdf.Circle(c.x+c.r*0.35, c.y-c.r*0.08, c.r*0.20, "F")315+ jaw := []fpdf.PointType{{X: c.x - c.r*0.48, Y: c.y + c.r*0.2}, {X: c.x + c.r*0.48, Y: c.y + c.r*0.2}, {X: c.x, Y: c.y + c.r*0.72}}316+ pdf.Polygon(jaw, "F")317+318+ pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))319+ pdf.SetLineWidth(0.22)320+ pdf.Line(c.x-c.r*0.6, c.y-c.r*0.85, c.x-c.r*1.15, c.y-c.r*1.45)321+ pdf.Line(c.x+c.r*0.5, c.y-c.r*0.7, c.x+c.r*1.0, c.y-c.r*1.3)322+ }323+}324+325+func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) {326+ ink := RGB{R: 8, G: 8, B: 8}327+328+ pdf.AddPage()329+ pageW, pageH := pdf.GetPageSize()330+ pdf.SetFillColor(int(coverColor.R), int(coverColor.G), int(coverColor.B))331+ pdf.Rect(0, 0, pageW, pageH, "F")332+333+ frameInset := 7.5334+ drawCoverFrame(pdf, frameInset, pageW, pageH, ink)335+336+ motif := rectMM{x: frameInset + 5.5, y: frameInset + 14.0, w: pageW - 2*(frameInset+5.5), h: 96.0}337+ drawBackMotif(pdf, motif, coverColor, ink)338+339+ labelW := pageW - 2*(frameInset+6.0)340+ pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B))341+ pdf.SetFont(sansFontFamily, "B", 8.4)342+ pdf.SetXY(frameInset+6.0, pageH-frameInset-23.0)343+ pdf.CellFormat(labelW, 4.2, "PuzzleTea", "", 0, "L", false, 0, "")344+345+ pdf.SetFont(sansFontFamily, "", 8.0)346+ pdf.SetXY(frameInset+6.0, pageH-frameInset-17.0)347+ pdf.CellFormat(labelW, 4.2, cfg.AdvertText, "", 0, "L", false, 0, "")348+349+ pdf.SetFont(sansFontFamily, "B", 8.2)350+ pdf.SetXY(frameInset+6.0, pageH-frameInset-10.5)351+ pdf.CellFormat(labelW, 4.2, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "")352+}353+354+func drawBackMotif(pdf *fpdf.Fpdf, scene rectMM, bg, ink RGB) {355+ drawStippleTexture(pdf, scene, ink, "back-cover")356+ drawHatchingBands(pdf, scene, ink)357+ drawPineForestMass(pdf, scene, ink)358+ drawStoneArch(pdf, scene.x+scene.w*0.58, scene.y+scene.h*0.56, 22, 24, 7, bg, ink)359+ drawRubbleBlock(pdf, scene.x+scene.w*0.28, scene.y+scene.h*0.72, 8, 4, 4, ink)360+}@@ -0,0 +1,40 @@
1+package pdfexport2+3+import (4+ "reflect"5+ "testing"6+7+ "github.com/go-pdf/fpdf"8+)9+10+func TestResolveCoverColorDeterministicWithSeed(t *testing.T) {11+ cfgA := RenderConfig{ShuffleSeed: "zine-seed"}12+ cfgB := RenderConfig{ShuffleSeed: "zine-seed"}13+14+ colorA := resolveCoverColor(cfgA)15+ colorB := resolveCoverColor(cfgB)16+ if colorA != colorB {17+ t.Fatalf("resolveCoverColor mismatch for identical seed: %+v vs %+v", colorA, colorB)18+ }19+}20+21+func TestSplitCoverSubtitleLinesClampsToMaxLines(t *testing.T) {22+ pdf := fpdf.New("P", "mm", "A4", "")23+ if err := registerPDFFonts(pdf); err != nil {24+ t.Fatalf("registerPDFFonts error: %v", err)25+ }26+ pdf.AddPage()27+ pdf.SetFont(coverFontFamily, "", 18)28+29+ subtitle := "The Catacombs of the Last Bastion and Other Grim Architectural Delights"30+ maxW := 78.031+ got := splitCoverSubtitleLines(pdf, subtitle, maxW, 2)32+ if len(got) != 2 {33+ t.Fatalf("line count = %d, want 2 (%v)", len(got), got)34+ }35+36+ gotAgain := splitCoverSubtitleLines(pdf, subtitle, maxW, 2)37+ if !reflect.DeepEqual(got, gotAgain) {38+ t.Fatalf("splitCoverSubtitleLines not stable: %v vs %v", got, gotAgain)39+ }40+}@@ -14,10 +14,11 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *HashiData) {
1414 }1515 1616 pageW, pageH := pdf.GetPageSize()17+ pageNo := pdf.PageNo()1718 1819 spanX := max(data.Width-1, 1)1920 spanY := max(data.Height-1, 1)20- area := puzzleBoardRect(pageW, pageH, 1)21+ area := puzzleBoardRect(pageW, pageH, pageNo, 1)2122 step := fitBoardCellSize(spanX, spanY, area, boardFamilyHashi)2223 if step <= 0 {2324 return@@ -35,9 +36,9 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *HashiData) {
3536 // Add an explicit blank line before the Hashi hint text.3637 ruleY := instructionY(originY+boardH+instructionLineHMM, pageH, 1)3738 setInstructionStyle(pdf)38- pdf.SetXY(pageMarginXMM, ruleY)39+ pdf.SetXY(area.x, ruleY)3940 pdf.CellFormat(40- pageW-2*pageMarginXMM,41+ area.w,4142 instructionLineHMM,4243 "Connect islands horizontally/vertically with up to two bridges and no crossings.",4344 "",@@ -14,7 +14,8 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) {
1414 1515 size := data.Size1616 pageW, pageH := pdf.GetPageSize()17- area := puzzleBoardRect(pageW, pageH, 1)17+ pageNo := pdf.PageNo()18+ area := puzzleBoardRect(pageW, pageH, pageNo, 1)1819 cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact)1920 if cellSize <= 0 {2021 return@@ -49,9 +50,9 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) {
4950 5051 ruleY := instructionY(startY+blockH, pageH, 1)5152 setInstructionStyle(pdf)52- pdf.SetXY(pageMarginXMM, ruleY)53+ pdf.SetXY(area.x, ruleY)5354 pdf.CellFormat(54- pageW-2*pageMarginXMM,55+ area.w,5556 instructionLineHMM,5657 "Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected.",5758 "",@@ -88,7 +89,8 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) {
8889 8990 size := data.Size9091 pageW, pageH := pdf.GetPageSize()91- area := puzzleBoardRect(pageW, pageH, 2)92+ pageNo := pdf.PageNo()93+ area := puzzleBoardRect(pageW, pageH, pageNo, 2)9294 cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact)9395 if cellSize <= 0 {9496 return@@ -141,9 +143,9 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) {
141143 142144 ruleY := instructionY(startY+blockH, pageH, 2)143145 setInstructionStyle(pdf)144- pdf.SetXY(pageMarginXMM, ruleY)146+ pdf.SetXY(area.x, ruleY)145147 pdf.CellFormat(146- pageW-2*pageMarginXMM,148+ area.w,147149 instructionLineHMM,148150 "No three equal adjacent in any row or column.",149151 "",@@ -153,9 +155,9 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) {
153155 0,154156 "",155157 )156- pdf.SetXY(pageMarginXMM, ruleY+instructionLineHMM)158+ pdf.SetXY(area.x, ruleY+instructionLineHMM)157159 pdf.CellFormat(158- pageW-2*pageMarginXMM,160+ area.w,159161 instructionLineHMM,160162 "Each row/column has equal 0 and 1 counts, and rows/columns are unique.",161163 "",@@ -12,7 +12,7 @@ func TestThinGridLineFloor(t *testing.T) {
1212 }1313 1414 func TestCenteredOriginKeepsBoardCentered(t *testing.T) {15- pageArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, 1)15+ pageArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, 2, 1)1616 tests := []struct {1717 name string1818 cols int@@ -56,12 +56,14 @@ func TestLayoutNonogramCentersGrid(t *testing.T) {
5656 {name: "deep hints", rowHintCol: 5, colHintRow: 4},5757 }5858 59- boardArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, 1)59+ const pageNo = 360+ boardArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, pageNo, 1)6061 for _, tt := range tests {6162 t.Run(tt.name, func(t *testing.T) {6263 layout := layoutNonogram(6364 halfLetterWidthMM,6465 halfLetterHeightMM,66+ pageNo,6567 10,6668 10,6769 tt.rowHintCol,@@ -102,3 +104,74 @@ func TestLayoutNonogramCentersGrid(t *testing.T) {
102104 })103105 }104106 }107+108+func TestPuzzleBodyRectMirrorsGutterByParity(t *testing.T) {109+ even := puzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 2)110+ odd := puzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 3)111+112+ if even.w <= 0 || odd.w <= 0 {113+ t.Fatal("expected positive body widths")114+ }115+ if math.Abs(even.w-odd.w) > 0.001 {116+ t.Fatalf("body widths differ: even=%.3f odd=%.3f", even.w, odd.w)117+ }118+119+ evenCenterX := even.x + even.w/2120+ oddCenterX := odd.x + odd.w/2121+ pageCenterX := halfLetterWidthMM / 2122+123+ if evenCenterX >= pageCenterX {124+ t.Fatalf("even page center = %.3f, want left of %.3f", evenCenterX, pageCenterX)125+ }126+ if oddCenterX <= pageCenterX {127+ t.Fatalf("odd page center = %.3f, want right of %.3f", oddCenterX, pageCenterX)128+ }129+}130+131+func TestSaddleStitchPadCount(t *testing.T) {132+ tests := []struct {133+ name string134+ pages int135+ want int136+ }{137+ {name: "already multiple of four", pages: 36, want: 0},138+ {name: "remainder one", pages: 35, want: 1},139+ {name: "remainder two", pages: 34, want: 2},140+ {name: "remainder three", pages: 33, want: 3},141+ {name: "non-positive", pages: 0, want: 0},142+ }143+144+ for _, tt := range tests {145+ t.Run(tt.name, func(t *testing.T) {146+ got := saddleStitchPadCount(tt.pages)147+ if got != tt.want {148+ t.Fatalf("saddleStitchPadCount(%d) = %d, want %d", tt.pages, got, tt.want)149+ }150+ })151+ }152+}153+154+func TestSaddleStitchPadCountForStandardPackLayout(t *testing.T) {155+ tests := []struct {156+ name string157+ puzzleRows int158+ wantPad int159+ }{160+ {name: "single puzzle", puzzleRows: 1, wantPad: 0},161+ {name: "two puzzles", puzzleRows: 2, wantPad: 3},162+ {name: "thirty-two puzzles", puzzleRows: 32, wantPad: 1},163+ }164+165+ for _, tt := range tests {166+ t.Run(tt.name, func(t *testing.T) {167+ totalWithoutPad := tt.puzzleRows + 3 // cover + title + back cover168+ got := saddleStitchPadCount(totalWithoutPad)169+ if got != tt.wantPad {170+ t.Fatalf("pad pages = %d, want %d", got, tt.wantPad)171+ }172+ if (totalWithoutPad+got)%4 != 0 {173+ t.Fatalf("total pages = %d, want multiple of 4", totalWithoutPad+got)174+ }175+ })176+ }177+}@@ -13,7 +13,8 @@ func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) {
1313 }1414 1515 pageW, pageH := pdf.GetPageSize()16- area := puzzleBoardRect(pageW, pageH, 1)16+ pageNo := pdf.PageNo()17+ area := puzzleBoardRect(pageW, pageH, pageNo, 1)1718 cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact)1819 if cellSize <= 0 {1920 return@@ -44,9 +45,9 @@ func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) {
4445 4546 ruleY := instructionY(startY+blockH, pageH, 1)4647 setInstructionStyle(pdf)47- pdf.SetXY(pageMarginXMM, ruleY)48+ pdf.SetXY(area.x, ruleY)4849 pdf.CellFormat(49- pageW-2*pageMarginXMM,50+ area.w,5051 instructionLineHMM,5152 "Expand each numbered island to its size; connect all sea cells into one wall.",5253 "",@@ -64,7 +65,8 @@ func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) {
6465 }6566 6667 pageW, pageH := pdf.GetPageSize()67- area := puzzleBoardRect(pageW, pageH, 1)68+ pageNo := pdf.PageNo()69+ area := puzzleBoardRect(pageW, pageH, pageNo, 1)6870 cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact)6971 if cellSize <= 0 {7072 return@@ -95,9 +97,9 @@ func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) {
9597 9698 ruleY := instructionY(startY+blockH, pageH, 1)9799 setInstructionStyle(pdf)98- pdf.SetXY(pageMarginXMM, ruleY)100+ pdf.SetXY(area.x, ruleY)99101 pdf.CellFormat(100- pageW-2*pageMarginXMM,102+ area.w,101103 instructionLineHMM,102104 "Partition into rectangles where each clue equals its rectangle area.",103105 "",@@ -17,6 +17,7 @@ const (
1717 primaryTextGray = 201818 1919 pageMarginXMM = 10.020+ bindingGutterExtraMM = 2.62021 puzzleTopMM = 28.02122 puzzleBottomInsetMM = 16.02223 instructionGapMM = 4.2@@ -61,17 +62,18 @@ type boardSizing struct {
6162 targetFill float646263 }6364 64-func puzzleBodyRect(pageW, pageH float64) rectMM {65+func puzzleBodyRect(pageW, pageH float64, pageNo int) rectMM {66+ leftMargin, rightMargin := puzzleHorizontalMargins(pageNo)6567 return rectMM{66- x: pageMarginXMM,68+ x: leftMargin,6769 y: puzzleTopMM,68- w: pageW - 2*pageMarginXMM,70+ w: pageW - leftMargin - rightMargin,6971 h: pageH - puzzleTopMM - puzzleBottomInsetMM,7072 }7173 }7274 73-func puzzleBoardRect(pageW, pageH float64, ruleLines int) rectMM {74- rect := puzzleBodyRect(pageW, pageH)75+func puzzleBoardRect(pageW, pageH float64, pageNo, ruleLines int) rectMM {76+ rect := puzzleBodyRect(pageW, pageH, pageNo)7577 if ruleLines > 0 {7678 rect.h -= instructionGapMM + float64(ruleLines)*instructionLineHMM7779 if rect.h < 0 {@@ -81,6 +83,22 @@ func puzzleBoardRect(pageW, pageH float64, ruleLines int) rectMM {
8183 return rect8284 }8385 86+func puzzleHorizontalMargins(pageNo int) (left, right float64) {87+ left = pageMarginXMM88+ right = pageMarginXMM89+ if pageNo <= 1 {90+ return left, right91+ }92+ if pageNo%2 == 0 {93+ // Even pages sit on the left side in a spread, so inside edge is right.94+ right += bindingGutterExtraMM95+ return left, right96+ }97+ // Odd pages sit on the right side in a spread, so inside edge is left.98+ left += bindingGutterExtraMM99+ return left, right100+}101+84102 func centeredOrigin(area rectMM, cols, rows int, cellSize float64) (float64, float64) {85103 boardW := float64(cols) * cellSize86104 boardH := float64(rows) * cellSize@@ -110,9 +110,14 @@ type GridTable struct {
110110 HasHeaderCol bool111111 }112112 113+type RGB struct{ R, G, B uint8 }114+113115 type RenderConfig struct {114- Title string115- AdvertText string116- GeneratedAt time.Time117- ShuffleSeed string116+ Title string117+ CoverSubtitle string118+ VolumeNumber int119+ AdvertText string120+ GeneratedAt time.Time121+ ShuffleSeed string122+ CoverColor *RGB // nil = random vibrant nature tone118123 }