clone

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

pdf export wrapin up

Dami committed a8f05ce
commit
a8f05ce31fbc1f820a85b057ee4618dd32c4e6b4
parent
8d700dec92f33d404c169028fa0b580df5c0c7c2
author
Dami <dami.etoile@gmail.com>
16 files changed, +888 -56
MREADME.md4+-
Mcmd/export_pdf.go86++++++-
Acmd/export_pdf_test.go115++++++++++
Mpdfexport/fonts.go9+-
Rpdfexport/fonts/OFL.txt → pdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt0
Apdfexport/fonts/DMSerifDisplay-OFL.txt93++++++++
Apdfexport/fonts/DMSerifDisplay-Regular.ttfbin
Mpdfexport/render.go80+++++-
Apdfexport/render_cover.go360++++++++++++++++++++++++++++++
Apdfexport/render_cover_test.go40+++
Mpdfexport/render_hashi.go7+-
Mpdfexport/render_hitori_takuzu.go18+-
Mpdfexport/render_layout_test.go77++++++-
Mpdfexport/render_nurikabe_shikaku.go14+-
Mpdfexport/render_tokens.go28++-
Mpdfexport/types.go13+-
M README.md +3 -1
M cmd/export_pdf.go +75 -11
@@ -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+}
A cmd/export_pdf_test.go +115 -0
@@ -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+}
M pdfexport/fonts.go +8 -1
R pdfexport/fonts/OFL.txt → pdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt +0 -0

No content changes.

A pdfexport/fonts/DMSerifDisplay-OFL.txt +93 -0
@@ -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.
A pdfexport/fonts/DMSerifDisplay-Regular.ttf +0 -0

Binary file.

M pdfexport/render.go +65 -15
@@ -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
A pdfexport/render_cover.go +360 -0
@@ -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+}
A pdfexport/render_cover_test.go +40 -0
M pdfexport/render_hashi.go +4 -3
M pdfexport/render_hitori_takuzu.go +10 -8
@@ -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 "",
M pdfexport/render_layout_test.go +75 -2
@@ -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+}
M pdfexport/render_nurikabe_shikaku.go +8 -6
@@ -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 "",
M pdfexport/render_tokens.go +23 -5
@@ -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
M pdfexport/types.go +9 -4