- commit
- 5ec53c4185febd7a127ba3fb6b590b016a11af59
- parent
- b8d1623a90f09a600fc2ad5b4376445f6e0d8006
- author
- Dami <dami.etoile@gmail.com>
| M | hashiwokakero/ | 112 | ++++- |
| M | hitori/ | 80 | +++- |
| M | nonogram/ | 258 | ++++++++- |
| A | nonogram/ | 139 | ++++ |
| M | nurikabe/ | 76 | ++- |
| M | pdfexport/ | 940 | ------------------------------ |
| D | pdfexport/ | 109 | --- |
| D | pdfexport/ | 191 | ------ |
| A | pdfexport/ | 92 | +++ |
| M | pdfexport/ | 59 | -- |
| A | pdfexport/ | 17 | + |
| D | pdfexport/ | 70 | -- |
| D | pdfexport/ | 131 | ---- |
| D | pdfexport/ | 39 | - |
| A | pdfexport/ | 284 | +++++++++ |
| M | shikaku/ | 76 | ++- |
| M | sudoku/ | 86 | +++- |
| M | takuzu/ | 112 | ++++- |
| R | pdfexport/ | 2 | +- |
| M | wordsearch/ | 233 | +++++++- |
@@ -1,6 +1,10 @@
11 package hashiwokakero22 33 import (4+ "math"5+ "strconv"6+ "unicode/utf8"7+48 "codeberg.org/go-pdf/fpdf"59 "github.com/FelineStateMachine/puzzletea/game"610 "github.com/FelineStateMachine/puzzletea/pdfexport"@@ -20,11 +24,117 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) {
2024 func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {2125 switch data := payload.(type) {2226 case *pdfexport.HashiData:23- pdfexport.RenderHashiPage(pdf, data)27+ renderHashiPage(pdf, data)2428 }2529 return nil2630 }2731 32+func renderHashiPage(pdf *fpdf.Fpdf, data *pdfexport.HashiData) {33+ if data == nil || data.Width <= 0 || data.Height <= 0 {34+ return35+ }36+37+ pageW, pageH := pdf.GetPageSize()38+ pageNo := pdf.PageNo()39+40+ spanX := max(data.Width-1, 1)41+ spanY := max(data.Height-1, 1)42+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)43+ step := pdfexport.FitHashiCellSize(spanX, spanY, area)44+ if step <= 0 {45+ return46+ }47+48+ boardW := float64(spanX) * step49+ boardH := float64(spanY) * step50+ originX, originY := pdfexport.CenteredOrigin(area, spanX, spanY, step)51+ islandRadius := hashiIslandRadius(step)52+53+ drawHashiGuideDots(pdf, originX, originY, data.Width, data.Height, step)54+ drawHashiBoardBorder(pdf, originX, originY, boardW, boardH, islandRadius)55+ drawHashiIslands(pdf, originX, originY, step, islandRadius, data.Islands)56+57+ ruleY := pdfexport.InstructionY(originY+boardH+pdfexport.InstructionLineHMM, pageH, 1)58+ pdfexport.SetInstructionStyle(pdf)59+ pdf.SetXY(area.X, ruleY)60+ pdf.CellFormat(61+ area.W,62+ pdfexport.InstructionLineHMM,63+ "Connect islands horizontally/vertically with up to two bridges and no crossings.",64+ "",65+ 0,66+ "C",67+ false,68+ 0,69+ "",70+ )71+}72+73+func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height int, step float64) {74+ pdf.SetFillColor(230, 230, 230)75+ r := math.Max(0.20, math.Min(0.55, step*0.035))76+ for y := 0; y < height; y++ {77+ for x := 0; x < width; x++ {78+ cx := originX + float64(x)*step79+ cy := originY + float64(y)*step80+ pdf.Circle(cx, cy, r, "F")81+ }82+ }83+}84+85+func drawHashiBoardBorder(pdf *fpdf.Fpdf, originX, originY, boardW, boardH, islandRadius float64) {86+ if boardW <= 0 || boardH <= 0 {87+ return88+ }89+ borderPad := islandRadius + 1.290+ pdf.SetDrawColor(35, 35, 35)91+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)92+ pdf.Rect(originX-borderPad, originY-borderPad, boardW+2*borderPad, boardH+2*borderPad, "D")93+}94+95+func drawHashiIslands(96+ pdf *fpdf.Fpdf,97+ originX,98+ originY,99+ step,100+ radius float64,101+ islands []pdfexport.HashiIsland,102+) {103+ pdf.SetDrawColor(20, 20, 20)104+ pdf.SetFillColor(255, 255, 255)105+ pdf.SetLineWidth(pdfexport.MajorGridLineMM)106+107+ for _, island := range islands {108+ cx := originX + float64(island.X)*step109+ cy := originY + float64(island.Y)*step110+ pdf.Circle(cx, cy, radius, "DF")111+ drawHashiIslandNumber(pdf, cx, cy, radius, island.Required)112+ }113+}114+115+func hashiIslandRadius(step float64) float64 {116+ return math.Max(1.4, math.Min(3.2, step*0.23))117+}118+119+func drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int) {120+ text := strconv.Itoa(required)121+ fontSize := pdfexport.StandardCellFontSize(radius*2.0, 0.95)122+ runeCount := utf8.RuneCountInString(text)123+ switch {124+ case runeCount >= 3:125+ fontSize *= 0.70126+ case runeCount == 2:127+ fontSize *= 0.82128+ }129+ fontSize = pdfexport.ClampStandardCellFontSize(fontSize)130+131+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)132+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)133+ lineH := fontSize * 0.88134+ pdf.SetXY(cx-radius, cy-lineH/2)135+ pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "")136+}137+28138 func init() {29139 game.RegisterPrintAdapter(printAdapter{})30140 }@@ -1,6 +1,9 @@
11 package hitori22 33 import (4+ "strings"5+ "unicode/utf8"6+47 "codeberg.org/go-pdf/fpdf"58 "github.com/FelineStateMachine/puzzletea/game"69 "github.com/FelineStateMachine/puzzletea/pdfexport"@@ -18,11 +21,86 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) {
1821 func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {1922 switch data := payload.(type) {2023 case *pdfexport.HitoriData:21- pdfexport.RenderHitoriPage(pdf, data)24+ renderHitoriPage(pdf, data)2225 }2326 return nil2427 }2528 29+func renderHitoriPage(pdf *fpdf.Fpdf, data *pdfexport.HitoriData) {30+ if data == nil || data.Size <= 0 {31+ return32+ }33+34+ size := data.Size35+ pageW, pageH := pdf.GetPageSize()36+ pageNo := pdf.PageNo()37+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)38+ cellSize := pdfexport.FitCompactCellSize(size, size, area)39+ if cellSize <= 0 {40+ return41+ }42+43+ blockW := float64(size) * cellSize44+ blockH := float64(size) * cellSize45+ startX, startY := pdfexport.CenteredOrigin(area, size, size, cellSize)46+47+ pdf.SetDrawColor(55, 55, 55)48+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)49+ for y := 0; y < size; y++ {50+ for x := 0; x < size; x++ {51+ cellX := startX + float64(x)*cellSize52+ cellY := startY + float64(y)*cellSize53+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")54+55+ text := ""56+ if y < len(data.Numbers) && x < len(data.Numbers[y]) {57+ text = strings.TrimSpace(data.Numbers[y][x])58+ }59+ if text == "" || text == "." {60+ continue61+ }62+ drawHitoriCellNumber(pdf, cellX, cellY, cellSize, text)63+ }64+ }65+66+ pdf.SetDrawColor(35, 35, 35)67+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)68+ pdf.Rect(startX, startY, blockW, blockH, "D")69+70+ ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1)71+ pdfexport.SetInstructionStyle(pdf)72+ pdf.SetXY(area.X, ruleY)73+ pdf.CellFormat(74+ area.W,75+ pdfexport.InstructionLineHMM,76+ "Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected.",77+ "",78+ 0,79+ "C",80+ false,81+ 0,82+ "",83+ )84+}85+86+func drawHitoriCellNumber(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) {87+ fontSize := pdfexport.StandardCellFontSize(cellSize, 0.58)88+ runeCount := utf8.RuneCountInString(text)89+ switch {90+ case runeCount >= 3:91+ fontSize *= 0.792+ case runeCount == 2:93+ fontSize *= 0.8294+ }95+ fontSize = pdfexport.ClampStandardCellFontSize(fontSize)96+97+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)98+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)99+ lineH := fontSize * 0.92100+ pdf.SetXY(x, y+(cellSize-lineH)/2)101+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")102+}103+26104 func init() {27105 game.RegisterPrintAdapter(printAdapter{})28106 }@@ -1,6 +1,9 @@
11 package nonogram22 33 import (4+ "strconv"5+ "strings"6+47 "codeberg.org/go-pdf/fpdf"58 "github.com/FelineStateMachine/puzzletea/game"69 "github.com/FelineStateMachine/puzzletea/pdfexport"@@ -18,11 +21,264 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) {
1821 func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {1922 switch data := payload.(type) {2023 case *pdfexport.NonogramData:21- pdfexport.RenderNonogramPage(pdf, data)24+ renderNonogramPage(pdf, data)2225 }2326 return nil2427 }2528 29+func renderNonogramPage(pdf *fpdf.Fpdf, data *pdfexport.NonogramData) {30+ if data == nil || data.Width <= 0 || data.Height <= 0 {31+ return32+ }33+34+ pageW, pageH := pdf.GetPageSize()35+ pageNo := pdf.PageNo()36+37+ rowHints := normalizeNonogramHintsForRender(data.RowHints, data.Height)38+ colHints := normalizeNonogramHintsForRender(data.ColHints, data.Width)39+40+ rowHintCols := maxHintDepth(rowHints)41+ colHintRows := maxHintDepth(colHints)42+ if rowHintCols < 1 {43+ rowHintCols = 144+ }45+ if colHintRows < 1 {46+ colHintRows = 147+ }48+49+ layout := layoutNonogram(50+ pageW,51+ pageH,52+ pageNo,53+ data.Width,54+ data.Height,55+ rowHintCols,56+ colHintRows,57+ )58+ cellSize := layout.cellSize59+ if cellSize <= 0 {60+ return61+ }62+63+ gridW := float64(data.Width) * cellSize64+ gridH := float64(data.Height) * cellSize65+ startX := layout.hintStartX66+ startY := layout.hintStartY67+ xSep := layout.gridX68+ ySep := layout.gridY69+70+ for row := 0; row < colHintRows; row++ {71+ for col := 0; col < data.Width; col++ {72+ cellX := xSep + float64(col)*cellSize73+ cellY := startY + float64(row)*cellSize74+ if text := colHintText(colHints[col], colHintRows, row); text != "" {75+ drawNonogramHintText(pdf, cellX, cellY, cellSize, cellSize, text)76+ }77+ }78+ }79+80+ for row := 0; row < data.Height; row++ {81+ for col := 0; col < rowHintCols; col++ {82+ cellX := startX + float64(col)*cellSize83+ cellY := ySep + float64(row)*cellSize84+ if text := rowHintText(rowHints[row], rowHintCols, col); text != "" {85+ drawNonogramHintText(pdf, cellX, cellY, cellSize, cellSize, text)86+ }87+ }88+ }89+90+ drawNonogramPuzzleGrid(pdf, xSep, ySep, data.Width, data.Height, cellSize)91+ drawNonogramMajorLines(pdf, xSep, ySep, cellSize, data.Width, data.Height, 5)92+93+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)94+ pdf.Rect(xSep, ySep, gridW, gridH, "D")95+96+ ruleY := ySep + gridH + 3.597+ ruleY = pdfexport.InstructionY(ruleY-3.5, pageH, 1)98+ body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo)99+ pdfexport.SetInstructionStyle(pdf)100+ pdf.SetXY(body.X, ruleY)101+ pdf.CellFormat(102+ body.W,103+ pdfexport.InstructionLineHMM,104+ "Use row/column hints to fill blocks in order; groups are separated by at least one blank cell.",105+ "",106+ 0,107+ "C",108+ false,109+ 0,110+ "",111+ )112+}113+114+func drawNonogramPuzzleGrid(115+ pdf *fpdf.Fpdf,116+ startX,117+ startY float64,118+ width,119+ height int,120+ cellSize float64,121+) {122+ if width <= 0 || height <= 0 || cellSize <= 0 {123+ return124+ }125+126+ gridW := float64(width) * cellSize127+ gridH := float64(height) * cellSize128+129+ pdf.SetDrawColor(45, 45, 45)130+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)131+ for col := 0; col <= width; col++ {132+ x := startX + float64(col)*cellSize133+ pdf.Line(x, startY, x, startY+gridH)134+ }135+ for row := 0; row <= height; row++ {136+ y := startY + float64(row)*cellSize137+ pdf.Line(startX, y, startX+gridW, y)138+ }139+}140+141+func drawNonogramHintText(pdf *fpdf.Fpdf, x, y, w, h float64, text string) {142+ if strings.TrimSpace(text) == "" {143+ return144+ }145+146+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)147+ fontSize := pdfexport.StandardCellFontSize(h, 0.70)148+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)149+ lineH := fontSize * 0.86150+ pdf.SetXY(x, y+(h-lineH)/2)151+ pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "")152+}153+154+type nonogramLayout struct {155+ cellSize float64156+ hintStartX float64157+ hintStartY float64158+ gridX float64159+ gridY float64160+}161+162+func layoutNonogram(163+ pageW,164+ pageH float64,165+ pageNo,166+ gridCols,167+ gridRows,168+ rowHintCols,169+ colHintRows int,170+) nonogramLayout {171+ totalCols := rowHintCols + gridCols172+ totalRows := colHintRows + gridRows173+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)174+ cellSize := pdfexport.FitNonogramCellSize(totalCols, totalRows, area)175+ if cellSize <= 0 {176+ return nonogramLayout{}177+ }178+179+ if rowHintCols > 0 {180+ centeredCapW := area.W / float64(gridCols+2*rowHintCols)181+ if centeredCapW > 0 && centeredCapW < cellSize {182+ cellSize = centeredCapW183+ }184+ }185+ if colHintRows > 0 {186+ centeredCapH := area.H / float64(gridRows+2*colHintRows)187+ if centeredCapH > 0 && centeredCapH < cellSize {188+ cellSize = centeredCapH189+ }190+ }191+192+ gridW := float64(gridCols) * cellSize193+ gridH := float64(gridRows) * cellSize194+ gridX := area.X + (area.W-gridW)/2195+ gridY := area.Y + (area.H-gridH)/2196+ hintStartX := gridX - float64(rowHintCols)*cellSize197+ hintStartY := gridY - float64(colHintRows)*cellSize198+199+ return nonogramLayout{200+ cellSize: cellSize,201+ hintStartX: hintStartX,202+ hintStartY: hintStartY,203+ gridX: gridX,204+ gridY: gridY,205+ }206+}207+208+func colHintText(hints []int, depth, row int) string {209+ if len(hints) == 0 {210+ return ""211+ }212+ start := depth - len(hints)213+ if row < start {214+ return ""215+ }216+ return strconv.Itoa(hints[row-start])217+}218+219+func rowHintText(hints []int, depth, col int) string {220+ if len(hints) == 0 {221+ return ""222+ }223+ start := depth - len(hints)224+ if col < start {225+ return ""226+ }227+ return strconv.Itoa(hints[col-start])228+}229+230+func maxHintDepth(hints [][]int) int {231+ maxDepth := 0232+ for _, h := range hints {233+ if len(h) > maxDepth {234+ maxDepth = len(h)235+ }236+ }237+ return maxDepth238+}239+240+func normalizeNonogramHintsForRender(hints [][]int, size int) [][]int {241+ if size <= 0 {242+ return nil243+ }244+245+ normalized := make([][]int, size)246+ for i := 0; i < size; i++ {247+ if i >= len(hints) || len(hints[i]) == 0 {248+ normalized[i] = []int{0}249+ continue250+ }251+ normalized[i] = append([]int(nil), hints[i]...)252+ }253+ return normalized254+}255+256+func drawNonogramMajorLines(257+ pdf *fpdf.Fpdf,258+ puzzleStartX,259+ puzzleStartY,260+ cellSize float64,261+ width,262+ height,263+ step int,264+) {265+ if step <= 0 || width <= 0 || height <= 0 {266+ return267+ }268+269+ pdf.SetDrawColor(45, 45, 45)270+ pdf.SetLineWidth(pdfexport.MajorGridLineMM)271+272+ for col := step; col < width; col += step {273+ x := puzzleStartX + float64(col)*cellSize274+ pdf.Line(x, puzzleStartY, x, puzzleStartY+float64(height)*cellSize)275+ }276+ for row := step; row < height; row += step {277+ y := puzzleStartY + float64(row)*cellSize278+ pdf.Line(puzzleStartX, y, puzzleStartX+float64(width)*cellSize, y)279+ }280+}281+26282 func init() {27283 game.RegisterPrintAdapter(printAdapter{})28284 }@@ -0,0 +1,139 @@
1+package nonogram2+3+import (4+ "math"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/pdfexport"8+)9+10+func TestNormalizeNonogramHintsForRender(t *testing.T) {11+ tests := []struct {12+ name string13+ hints [][]int14+ size int15+ want [][]int16+ }{17+ {18+ name: "empty source defaults to zeros",19+ hints: nil,20+ size: 3,21+ want: [][]int{{0}, {0}, {0}},22+ },23+ {24+ name: "preserves provided hint rows",25+ hints: [][]int{{3, 1}, {}, {2}},26+ size: 3,27+ want: [][]int{{3, 1}, {0}, {2}},28+ },29+ {30+ name: "pads beyond provided rows",31+ hints: [][]int{{1}},32+ size: 3,33+ want: [][]int{{1}, {0}, {0}},34+ },35+ {36+ name: "non-positive size returns nil",37+ hints: [][]int{{1}},38+ size: 0,39+ want: nil,40+ },41+ }42+43+ for _, tt := range tests {44+ t.Run(tt.name, func(t *testing.T) {45+ got := normalizeNonogramHintsForRender(tt.hints, tt.size)46+ if len(got) != len(tt.want) {47+ t.Fatalf("rows = %d, want %d", len(got), len(tt.want))48+ }49+ for row := range len(tt.want) {50+ if len(got[row]) != len(tt.want[row]) {51+ t.Fatalf("row %d len = %d, want %d", row, len(got[row]), len(tt.want[row]))52+ }53+ for col := range len(tt.want[row]) {54+ if got[row][col] != tt.want[row][col] {55+ t.Fatalf("row %d col %d = %d, want %d", row, col, got[row][col], tt.want[row][col])56+ }57+ }58+ }59+ })60+ }61+}62+63+func TestNormalizeNonogramHintsForRenderCopiesRows(t *testing.T) {64+ src := [][]int{{2, 1}}65+66+ got := normalizeNonogramHintsForRender(src, 1)67+ if got == nil || len(got) != 1 {68+ t.Fatalf("unexpected normalized hints: %#v", got)69+ }70+71+ src[0][0] = 972+ if got[0][0] != 2 {73+ t.Fatalf("normalized hint should be copied, got %d want 2", got[0][0])74+ }75+}76+77+func TestLayoutNonogramCentersGrid(t *testing.T) {78+ tests := []struct {79+ name string80+ rowHintCol int81+ colHintRow int82+ }{83+ {name: "shallow hints", rowHintCol: 1, colHintRow: 1},84+ {name: "deep hints", rowHintCol: 5, colHintRow: 4},85+ }86+87+ const (88+ pageNo = 389+ pageW = 139.790+ pageH = 215.991+ )92+93+ boardArea := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)94+ for _, tt := range tests {95+ t.Run(tt.name, func(t *testing.T) {96+ layout := layoutNonogram(97+ pageW,98+ pageH,99+ pageNo,100+ 10,101+ 10,102+ tt.rowHintCol,103+ tt.colHintRow,104+ )105+ if layout.cellSize <= 0 {106+ t.Fatal("expected non-zero cell size")107+ }108+109+ gridW := 10.0 * layout.cellSize110+ gridH := 10.0 * layout.cellSize111+ centerX := layout.gridX + gridW/2112+ centerY := layout.gridY + gridH/2113+ wantX := boardArea.X + boardArea.W/2114+ wantY := boardArea.Y + boardArea.H/2115+116+ if diff := math.Abs(centerX - wantX); diff > 0.8 {117+ t.Fatalf("grid centerX diff = %.3f, want <= 0.8", diff)118+ }119+ if diff := math.Abs(centerY - wantY); diff > 0.8 {120+ t.Fatalf("grid centerY diff = %.3f, want <= 0.8", diff)121+ }122+123+ fullW := float64(tt.rowHintCol+10) * layout.cellSize124+ fullH := float64(tt.colHintRow+10) * layout.cellSize125+ if layout.hintStartX < boardArea.X-0.01 {126+ t.Fatalf("hintStartX = %.3f, want >= %.3f", layout.hintStartX, boardArea.X)127+ }128+ if layout.hintStartY < boardArea.Y-0.01 {129+ t.Fatalf("hintStartY = %.3f, want >= %.3f", layout.hintStartY, boardArea.Y)130+ }131+ if right := layout.hintStartX + fullW; right > boardArea.X+boardArea.W+0.01 {132+ t.Fatalf("hint block right = %.3f, want <= %.3f", right, boardArea.X+boardArea.W)133+ }134+ if bottom := layout.hintStartY + fullH; bottom > boardArea.Y+boardArea.H+0.01 {135+ t.Fatalf("hint block bottom = %.3f, want <= %.3f", bottom, boardArea.Y+boardArea.H)136+ }137+ })138+ }139+}@@ -1,6 +1,9 @@
11 package nurikabe22 33 import (4+ "strconv"5+ "unicode/utf8"6+47 "codeberg.org/go-pdf/fpdf"58 "github.com/FelineStateMachine/puzzletea/game"69 "github.com/FelineStateMachine/puzzletea/pdfexport"@@ -18,11 +21,82 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) {
1821 func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {1922 switch data := payload.(type) {2023 case *pdfexport.NurikabeData:21- pdfexport.RenderNurikabePage(pdf, data)24+ renderNurikabePage(pdf, data)2225 }2326 return nil2427 }2528 29+func renderNurikabePage(pdf *fpdf.Fpdf, data *pdfexport.NurikabeData) {30+ if data == nil || data.Width <= 0 || data.Height <= 0 {31+ return32+ }33+34+ pageW, pageH := pdf.GetPageSize()35+ pageNo := pdf.PageNo()36+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)37+ cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area)38+ if cellSize <= 0 {39+ return40+ }41+42+ blockW := float64(data.Width) * cellSize43+ blockH := float64(data.Height) * cellSize44+ startX, startY := pdfexport.CenteredOrigin(area, data.Width, data.Height, cellSize)45+46+ pdf.SetDrawColor(55, 55, 55)47+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)48+ for y := 0; y < data.Height; y++ {49+ for x := 0; x < data.Width; x++ {50+ cellX := startX + float64(x)*cellSize51+ cellY := startY + float64(y)*cellSize52+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")53+54+ if y >= len(data.Clues) || x >= len(data.Clues[y]) || data.Clues[y][x] <= 0 {55+ continue56+ }57+ drawNurikabeClue(pdf, cellX, cellY, cellSize, data.Clues[y][x])58+ }59+ }60+61+ pdf.SetDrawColor(35, 35, 35)62+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)63+ pdf.Rect(startX, startY, blockW, blockH, "D")64+65+ ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1)66+ pdfexport.SetInstructionStyle(pdf)67+ pdf.SetXY(area.X, ruleY)68+ pdf.CellFormat(69+ area.W,70+ pdfexport.InstructionLineHMM,71+ "Expand each numbered island to its size; connect all sea cells into one wall.",72+ "",73+ 0,74+ "C",75+ false,76+ 0,77+ "",78+ )79+}80+81+func drawNurikabeClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) {82+ text := strconv.Itoa(value)83+ fontSize := pdfexport.StandardCellFontSize(cellSize, 0.58)84+ runeCount := utf8.RuneCountInString(text)85+ switch {86+ case runeCount >= 3:87+ fontSize *= 0.7288+ case runeCount == 2:89+ fontSize *= 0.8490+ }91+ fontSize = pdfexport.ClampStandardCellFontSize(fontSize)92+93+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)94+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)95+ lineH := fontSize * 0.9296+ pdf.SetXY(x, y+(cellSize-lineH)/2)97+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")98+}99+26100 func init() {27101 game.RegisterPrintAdapter(printAdapter{})28102 }@@ -2,10 +2,8 @@ package pdfexport
22 33 import (44 "fmt"5- "math"65 "os"76 "path/filepath"8- "sort"97 "strconv"108 "strings"119 "time"@@ -131,132 +129,6 @@ func renderPadPage(pdf *fpdf.Fpdf) {
131129 pdf.AddPage()132130 }133131 134-func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) {135- pdf.AddPage()136- pageW, pageH := pdf.GetPageSize()137- margin := 12.0138- contentWidth := pageW - 2*margin139-140- pdf.SetTextColor(20, 20, 20)141- pdf.SetFont(sansFontFamily, "B", 22)142- pdf.SetXY(0, 24)143- pdf.CellFormat(pageW, 10, fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber), "", 0, "C", false, 0, "")144-145- pdf.SetTextColor(50, 50, 50)146- pdf.SetFont(coverFontFamily, "", 16)147- pdf.SetXY(0, 35)148- pdf.CellFormat(pageW, 8, cfg.CoverSubtitle, "", 0, "C", false, 0, "")149-150- pdf.SetFont(sansFontFamily, "", 11)151- pdf.SetTextColor(70, 70, 70)152- pdf.SetXY(0, 44)153- pdf.CellFormat(pageW, 6, "PuzzleTea Puzzle Pack", "", 0, "C", false, 0, "")154-155- versionLine := fmt.Sprintf("PuzzleTea Version: %s", strings.Join(summarizeVersions(docs), ", "))156- pdf.SetFont(sansFontFamily, "", 10)157- wrappedVersions := pdf.SplitLines([]byte(versionLine), contentWidth)158- if len(wrappedVersions) == 0 {159- wrappedVersions = [][]byte{[]byte(versionLine)}160- }161-162- headerLineH := 4.8163- headerGap := 1.2164- headerStartY := 54.8165- metaY := 56.0166- if header := strings.TrimSpace(cfg.HeaderText); header != "" {167- pdf.SetFont(sansFontFamily, "", 9.2)168- pdf.SetTextColor(74, 74, 74)169- wrappedHeader := pdf.SplitLines([]byte(header), contentWidth)170- if len(wrappedHeader) == 0 {171- wrappedHeader = [][]byte{[]byte(header)}172- }173-174- metaY = headerStartY + float64(len(wrappedHeader))*headerLineH + headerGap175- sourceStartY := titlePageSourceTableStartY(metaY, len(wrappedVersions))176- sourceMaxY := pageH - 45177- if spare := titlePageSourceTableWhitespace(sourceMaxY, sourceStartY, len(docs)); spare > 0 {178- headerGap += spare179- }180-181- headerY := headerStartY182- for _, line := range wrappedHeader {183- pdf.SetXY(margin, headerY)184- pdf.CellFormat(contentWidth, headerLineH, string(line), "", 0, "C", false, 0, "")185- headerY += headerLineH186- }187- metaY = headerY + headerGap188- }189-190- pdf.SetTextColor(25, 25, 25)191- pdf.SetFont(sansFontFamily, "", 10)192- pdf.SetXY(margin, metaY)193- pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format("January 2, 2006")), "", 0, "L", false, 0, "")194- metaY += 6195- for _, line := range wrappedVersions {196- pdf.SetXY(margin, metaY)197- pdf.CellFormat(contentWidth, 5.2, string(line), "", 0, "L", false, 0, "")198- metaY += 5.2199- }200- metaY += 0.8201-202- pdf.SetXY(margin, metaY)203- pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Puzzles: %d", len(puzzles)), "", 0, "L", false, 0, "")204- metaY += 6205- metaY += 1.8206-207- pdf.SetFont(sansFontFamily, "B", 10)208- pdf.SetTextColor(45, 45, 45)209- pdf.SetXY(margin, metaY)210- pdf.CellFormat(contentWidth, 6, "Source Exports", "", 0, "L", false, 0, "")211- metaY += 7212-213- renderSourceExportsTable(pdf, docs, margin, metaY, contentWidth, pageH-45)214-215- pdf.SetTextColor(50, 50, 50)216- pdf.SetFont(sansFontFamily, "B", 12)217- pdf.SetXY(margin, pageH-30)218- pdf.CellFormat(contentWidth, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "")219-220- pdf.SetFont(sansFontFamily, "", 10)221- pdf.SetXY(margin, pageH-22)222- pdf.CellFormat(contentWidth, 6, cfg.AdvertText, "", 0, "C", false, 0, "")223-}224-225-func titlePageSourceTableStartY(metaY float64, versionLineCount int) float64 {226- y := metaY227- y += 6228- y += float64(max(versionLineCount, 1)) * 5.2229- y += 0.8230- y += 6231- y += 1.8232- y += 7233- return y234-}235-236-func titlePageSourceTableWhitespace(maxY, sourceStartY float64, docCount int) float64 {237- const (238- headerHeight = 5.2239- rowHeight = 4.8240- )241-242- availableRowsHeight := maxY - sourceStartY - headerHeight243- if availableRowsHeight < rowHeight {244- return 0245- }246-247- maxRows := int(math.Floor(availableRowsHeight / rowHeight))248- if maxRows < 1 {249- return 0250- }251-252- rowCount := min(docCount, maxRows)253- used := headerHeight + float64(rowCount)*rowHeight254- if spare := maxY - sourceStartY - used; spare > 0 {255- return spare256- }257- return 0258-}259-260132 func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error {261133 if game.IsNilPrintPayload(puzzle.PrintPayload) {262134 return nil@@ -287,815 +159,3 @@ func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error {
287159 }288160 return nil289161 }290-291-func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {292- if data == nil || data.Width <= 0 || data.Height <= 0 {293- return294- }295-296- pageW, pageH := pdf.GetPageSize()297- pageNo := pdf.PageNo()298-299- rowHints := normalizeNonogramHintsForRender(data.RowHints, data.Height)300- colHints := normalizeNonogramHintsForRender(data.ColHints, data.Width)301-302- rowHintCols := maxHintDepth(rowHints)303- colHintRows := maxHintDepth(colHints)304- if rowHintCols < 1 {305- rowHintCols = 1306- }307- if colHintRows < 1 {308- colHintRows = 1309- }310-311- layout := layoutNonogram(312- pageW,313- pageH,314- pageNo,315- data.Width,316- data.Height,317- rowHintCols,318- colHintRows,319- )320- cellSize := layout.cellSize321- if cellSize <= 0 {322- return323- }324-325- gridW := float64(data.Width) * cellSize326- gridH := float64(data.Height) * cellSize327- startX := layout.hintStartX328- startY := layout.hintStartY329- xSep := layout.gridX330- ySep := layout.gridY331-332- for row := 0; row < colHintRows; row++ {333- for col := 0; col < data.Width; col++ {334- cellX := xSep + float64(col)*cellSize335- cellY := startY + float64(row)*cellSize336- if text := colHintText(colHints[col], colHintRows, row); text != "" {337- drawNonogramHintText(pdf, cellX, cellY, cellSize, cellSize, text)338- }339- }340- }341-342- for row := 0; row < data.Height; row++ {343- for col := 0; col < rowHintCols; col++ {344- cellX := startX + float64(col)*cellSize345- cellY := ySep + float64(row)*cellSize346- if text := rowHintText(rowHints[row], rowHintCols, col); text != "" {347- drawNonogramHintText(pdf, cellX, cellY, cellSize, cellSize, text)348- }349- }350- }351-352- drawNonogramPuzzleGrid(pdf, xSep, ySep, data.Width, data.Height, cellSize)353-354- drawNonogramMajorLines(pdf, xSep, ySep, cellSize, data.Width, data.Height, 5)355-356- pdf.SetLineWidth(outerBorderLineMM)357- pdf.Rect(xSep, ySep, gridW, gridH, "D")358-359- ruleY := ySep + gridH + 3.5360- ruleY = instructionY(ruleY-3.5, pageH, 1)361- body := puzzleBodyRect(pageW, pageH, pageNo)362- setInstructionStyle(pdf)363- pdf.SetXY(body.x, ruleY)364- pdf.CellFormat(365- body.w,366- instructionLineHMM,367- "Use row/column hints to fill blocks in order; groups are separated by at least one blank cell.",368- "",369- 0,370- "C",371- false,372- 0,373- "",374- )375-}376-377-func drawNonogramPuzzleGrid(378- pdf *fpdf.Fpdf,379- startX,380- startY float64,381- width,382- height int,383- cellSize float64,384-) {385- if width <= 0 || height <= 0 || cellSize <= 0 {386- return387- }388-389- gridW := float64(width) * cellSize390- gridH := float64(height) * cellSize391-392- pdf.SetDrawColor(45, 45, 45)393- pdf.SetLineWidth(thinGridLineMM)394- for col := 0; col <= width; col++ {395- x := startX + float64(col)*cellSize396- pdf.Line(x, startY, x, startY+gridH)397- }398- for row := 0; row <= height; row++ {399- y := startY + float64(row)*cellSize400- pdf.Line(startX, y, startX+gridW, y)401- }402-}403-404-func drawNonogramHintText(pdf *fpdf.Fpdf, x, y, w, h float64, text string) {405- if strings.TrimSpace(text) == "" {406- return407- }408-409- // Hints are puzzle-critical, so keep them bold and centered.410- pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)411- fontSize := standardCellFontSize(h, 0.70)412- pdf.SetFont(sansFontFamily, "B", fontSize)413- lineH := fontSize * 0.86414- pdf.SetXY(x, y+(h-lineH)/2)415- pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "")416-}417-418-type nonogramLayout struct {419- cellSize float64420- hintStartX float64421- hintStartY float64422- gridX float64423- gridY float64424-}425-426-func layoutNonogram(427- pageW,428- pageH float64,429- pageNo,430- gridCols,431- gridRows,432- rowHintCols,433- colHintRows int,434-) nonogramLayout {435- totalCols := rowHintCols + gridCols436- totalRows := colHintRows + gridRows437- area := puzzleBoardRect(pageW, pageH, pageNo, 1)438- cellSize := fitBoardCellSize(totalCols, totalRows, area, boardFamilyNonogram)439- if cellSize <= 0 {440- return nonogramLayout{}441- }442-443- if rowHintCols > 0 {444- centeredCapW := area.w / float64(gridCols+2*rowHintCols)445- if centeredCapW > 0 && centeredCapW < cellSize {446- cellSize = centeredCapW447- }448- }449- if colHintRows > 0 {450- centeredCapH := area.h / float64(gridRows+2*colHintRows)451- if centeredCapH > 0 && centeredCapH < cellSize {452- cellSize = centeredCapH453- }454- }455-456- gridW := float64(gridCols) * cellSize457- gridH := float64(gridRows) * cellSize458- gridX := area.x + (area.w-gridW)/2459- gridY := area.y + (area.h-gridH)/2460- hintStartX := gridX - float64(rowHintCols)*cellSize461- hintStartY := gridY - float64(colHintRows)*cellSize462-463- return nonogramLayout{464- cellSize: cellSize,465- hintStartX: hintStartX,466- hintStartY: hintStartY,467- gridX: gridX,468- gridY: gridY,469- }470-}471-472-func renderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) {473- if data == nil {474- return475- }476-477- pageW, pageH := pdf.GetPageSize()478- pageNo := pdf.PageNo()479- area := puzzleBoardRect(pageW, pageH, pageNo, 1)480- cellSize := fitBoardCellSize(9, 9, area, boardFamilySudoku)481- if cellSize <= 0 {482- return483- }484-485- boardH := 9.0 * cellSize486- startX, startY := centeredOrigin(area, 9, 9, cellSize)487-488- drawSudokuGridLines(pdf, startX, startY, cellSize)489- drawSudokuGivens(pdf, startX, startY, cellSize, data.Givens)490-491- ruleY := instructionY(startY+boardH, pageH, 1)492- setInstructionStyle(pdf)493- pdf.SetXY(area.x, ruleY)494- pdf.CellFormat(495- area.w,496- instructionLineHMM,497- "Fill rows, columns, and 3x3 boxes with 1-9",498- "",499- 0,500- "C",501- false,502- 0,503- "",504- )505-}506-507-func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) {508- pdf.SetDrawColor(25, 25, 25)509-510- for i := range 10 {511- x := startX + float64(i)*cellSize512- pdf.SetLineWidth(sudokuLineWidthFor(i))513- pdf.Line(x, startY, x, startY+9.0*cellSize)514- }515-516- for i := range 10 {517- y := startY + float64(i)*cellSize518- pdf.SetLineWidth(sudokuLineWidthFor(i))519- pdf.Line(startX, y, startX+9.0*cellSize, y)520- }521-}522-523-func sudokuLineWidthFor(index int) float64 {524- switch {525- case index == 0 || index == 9:526- return 0.72527- case index%3 == 0:528- return 0.56529- default:530- return thinGridLineMM531- }532-}533-534-func drawSudokuGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [9][9]int) {535- fontSize := standardCellFontSize(cellSize, 0.62)536- lineH := fontSize * 0.85537- pdf.SetFont(sansFontFamily, "B", fontSize)538- pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)539-540- for y := range 9 {541- for x := range 9 {542- value := givens[y][x]543- if value < 1 || value > 9 {544- continue545- }546- cellX := startX + float64(x)*cellSize547- cellY := startY + float64(y)*cellSize548- pdf.SetXY(cellX, cellY+(cellSize-lineH)/2)549- pdf.CellFormat(cellSize, lineH, strconv.Itoa(value), "", 0, "C", false, 0, "")550- }551- }552-}553-554-func renderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) {555- if data == nil || data.Width <= 0 || data.Height <= 0 {556- return557- }558-559- pageW, pageH := pdf.GetPageSize()560- pageNo := pdf.PageNo()561- body := puzzleBodyRect(pageW, pageH, pageNo)562- availW := body.w563- availH := body.h564-565- columnCount := wordSearchColumnCount(data.Width, len(data.Words))566- wordFontSize := puzzleWordBankFontSize567- wordLineHeight := 4.2568- gridListGap := wordSearchGridListGap569-570- estimatedWordLines := estimateWordBankLineCount(pdf, data.Words, columnCount, availW, wordFontSize)571- wordBankHeight := 7.0 + float64(estimatedWordLines)*wordLineHeight572- maxWordBankHeight := availH * 0.42573- if wordBankHeight > maxWordBankHeight {574- wordBankHeight = maxWordBankHeight575- }576- if wordBankHeight < 16 {577- wordBankHeight = 16578- }579-580- gridAreaH := availH - wordBankHeight - gridListGap581- if gridAreaH < availH*0.5 {582- gridAreaH = availH * 0.5583- }584-585- gridArea := rectMM{x: body.x, y: body.y, w: availW, h: gridAreaH}586- cellSize := fitBoardCellSize(data.Width, data.Height, gridArea, boardFamilyWordSearch)587- if cellSize <= 0 {588- return589- }590-591- gridW := float64(data.Width) * cellSize592- gridH := float64(data.Height) * cellSize593- gridX := body.x + (gridArea.w-gridW)/2594- gridY := body.y + (gridArea.h-gridH)/2595-596- drawWordSearchGrid(pdf, data, gridX, gridY, cellSize)597- drawWordBank(598- pdf,599- data.Words,600- body.x,601- gridY+gridH+gridListGap,602- availW,603- pageH-puzzleBottomInsetMM-(gridY+gridH+gridListGap),604- columnCount,605- )606-}607-608-func drawWordSearchGrid(pdf *fpdf.Fpdf, data *WordSearchData, startX, startY, cellSize float64) {609- pdf.SetDrawColor(45, 45, 45)610- pdf.SetLineWidth(thinGridLineMM)611- for y := range data.Height {612- for x := range data.Width {613- cellX := startX + float64(x)*cellSize614- cellY := startY + float64(y)*cellSize615- pdf.Rect(cellX, cellY, cellSize, cellSize, "D")616-617- cellText := " "618- if y < len(data.Grid) && x < len(data.Grid[y]) {619- cellText = strings.TrimSpace(strings.ToUpper(data.Grid[y][x]))620- }621- if cellText == "" || cellText == "." {622- continue623- }624-625- fontSize := standardCellFontSize(cellSize, 0.74)626- lineH := fontSize * 0.86627- pdf.SetFont(sansFontFamily, "B", fontSize)628- pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)629- pdf.SetXY(cellX, cellY+(cellSize-lineH)/2)630- pdf.CellFormat(cellSize, lineH, cellText, "", 0, "C", false, 0, "")631- }632- }633-634- pdf.SetLineWidth(outerBorderLineMM)635- pdf.Rect(startX, startY, float64(data.Width)*cellSize, float64(data.Height)*cellSize, "D")636-}637-638-func drawWordBank(pdf *fpdf.Fpdf, words []string, x, y, width, height float64, columns int) {639- if height <= 0 {640- return641- }642-643- pdf.SetTextColor(40, 40, 40)644- pdf.SetFont(sansFontFamily, "B", puzzleWordBankHeadSize)645- pdf.SetXY(x, y)646- pdf.CellFormat(width, 4.8, "Word Bank", "", 0, "L", false, 0, "")647-648- pdf.SetFont(sansFontFamily, "", puzzleWordBankFontSize)649- pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray)650- pdf.SetXY(x, y+4.8)651- pdf.CellFormat(width, 4.2, "Words may run in all 8 directions", "", 0, "L", false, 0, "")652-653- listY := y + 9.0654- if len(words) == 0 {655- pdf.SetFont(sansFontFamily, "", puzzleWordBankHeadSize)656- pdf.SetTextColor(secondaryTextGray, secondaryTextGray, secondaryTextGray)657- pdf.SetXY(x, listY)658- pdf.CellFormat(width, 4.6, "(word list unavailable)", "", 0, "L", false, 0, "")659- return660- }661-662- columnGap := 4.0663- if columns < 1 {664- columns = 1665- }666- colWidth := (width - float64(columns-1)*columnGap) / float64(columns)667- if colWidth <= 0 {668- colWidth = width669- columns = 1670- }671-672- colLines := layoutWordBankColumns(pdf, words, columns, colWidth)673- lineHeight := 4.1674- maxLines := int(height / lineHeight)675- if maxLines <= 0 {676- return677- }678-679- pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)680- pdf.SetFont(sansFontFamily, "B", puzzleWordBankHeadSize)681- for c := range columns {682- colX := x + float64(c)*(colWidth+columnGap)683- curY := listY684- lines := colLines[c]685- for _, line := range lines {686- if int((curY-listY)/lineHeight) >= maxLines {687- break688- }689- pdf.SetXY(colX, curY)690- pdf.CellFormat(colWidth, lineHeight, line, "", 0, "L", false, 0, "")691- curY += lineHeight692- }693- }694-}695-696-func wordSearchColumnCount(width, wordCount int) int {697- if width >= 20 || wordCount > 18 {698- return 3699- }700- if wordCount <= 0 {701- return 1702- }703- return 2704-}705-706-func estimateWordBankLineCount(pdf *fpdf.Fpdf, words []string, columns int, availW, fontSize float64) int {707- if len(words) == 0 {708- return 1709- }710- if columns < 1 {711- columns = 1712- }713-714- colWidth := (availW - float64(columns-1)*4.0) / float64(columns)715- if colWidth <= 0 {716- colWidth = availW717- }718-719- pdf.SetFont(sansFontFamily, "", fontSize)720- lineCounts := make([]int, columns)721- for _, word := range words {722- text := strings.ToUpper(strings.TrimSpace(word))723- if text == "" {724- continue725- }726- lines := pdf.SplitLines([]byte(text), colWidth)727- if len(lines) == 0 {728- lines = [][]byte{[]byte(text)}729- }730- idx := minLineCountColumn(lineCounts)731- lineCounts[idx] += len(lines)732- }733-734- maxLines := 0735- for _, lineCount := range lineCounts {736- if lineCount > maxLines {737- maxLines = lineCount738- }739- }740- if maxLines == 0 {741- return 1742- }743- return maxLines744-}745-746-func layoutWordBankColumns(pdf *fpdf.Fpdf, words []string, columns int, colWidth float64) [][]string {747- colLines := make([][]string, columns)748- if len(words) == 0 || columns <= 0 {749- return colLines750- }751-752- lineCounts := make([]int, columns)753- for _, word := range words {754- text := strings.ToUpper(strings.TrimSpace(word))755- if text == "" {756- continue757- }758- wrapped := pdf.SplitLines([]byte(text), colWidth)759- if len(wrapped) == 0 {760- wrapped = [][]byte{[]byte(text)}761- }762-763- idx := minLineCountColumn(lineCounts)764- for _, line := range wrapped {765- colLines[idx] = append(colLines[idx], string(line))766- }767- lineCounts[idx] += len(wrapped)768- }769-770- return colLines771-}772-773-func minLineCountColumn(lineCounts []int) int {774- idx := 0775- for i := 1; i < len(lineCounts); i++ {776- if lineCounts[i] < lineCounts[idx] {777- idx = i778- }779- }780- return idx781-}782-783-func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) {784- if table == nil || len(table.Rows) == 0 {785- return786- }787-788- pageW, pageH := pdf.GetPageSize()789- pageNo := pdf.PageNo()790-791- rows := len(table.Rows)792- cols := 0793- for _, row := range table.Rows {794- if len(row) > cols {795- cols = len(row)796- }797- }798- if cols == 0 {799- return800- }801-802- area := puzzleBoardRect(pageW, pageH, pageNo, 0)803- cellSize := fitBoardCellSize(cols, rows, area, boardFamilyTable)804- if cellSize <= 0 {805- return806- }807-808- blockW := float64(cols) * cellSize809- blockH := float64(rows) * cellSize810- startX, startY := centeredOrigin(area, cols, rows, cellSize)811-812- pdf.SetDrawColor(55, 55, 55)813- pdf.SetLineWidth(thinGridLineMM)814- for r := 0; r < rows; r++ {815- for c := 0; c < cols; c++ {816- x := startX + float64(c)*cellSize817- y := startY + float64(r)*cellSize818- pdf.Rect(x, y, cellSize, cellSize, "D")819-820- var text string821- if c < len(table.Rows[r]) {822- text = strings.TrimSpace(table.Rows[r][c])823- }824- if text == "." {825- text = " "826- }827- if text == "" {828- continue829- }830-831- dim := (table.HasHeaderRow && r == 0) || (table.HasHeaderCol && c == 0)832- drawCellText(pdf, x, y, cellSize, cellSize, text, dim)833- }834- }835-836- if table.HasHeaderRow {837- ySep := startY + cellSize838- pdf.SetLineWidth(majorGridLineMM)839- pdf.Line(startX, ySep, startX+blockW, ySep)840- }841- if table.HasHeaderCol {842- xSep := startX + cellSize843- pdf.SetLineWidth(majorGridLineMM)844- pdf.Line(xSep, startY, xSep, startY+blockH)845- }846-847- pdf.SetLineWidth(outerBorderLineMM)848- pdf.Rect(startX, startY, blockW, blockH, "D")849-}850-851-func drawCellText(pdf *fpdf.Fpdf, x, y, w, h float64, text string, dim bool) {852- if strings.TrimSpace(text) == "" {853- return854- }855- if dim {856- pdf.SetTextColor(dimTextGray, dimTextGray, dimTextGray)857- } else {858- pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)859- }860-861- fontSize := standardCellFontSize(h, 0.63)862- pdf.SetFont(sansFontFamily, "B", fontSize)863- lineH := fontSize * 0.9864- pdf.SetXY(x, y+(h-lineH)/2)865- pdf.CellFormat(w, lineH, text, "", 0, "C", false, 0, "")866-}867-868-func colHintText(hints []int, depth, row int) string {869- if len(hints) == 0 {870- return ""871- }872- start := depth - len(hints)873- if row < start {874- return ""875- }876- return strconv.Itoa(hints[row-start])877-}878-879-func rowHintText(hints []int, depth, col int) string {880- if len(hints) == 0 {881- return ""882- }883- start := depth - len(hints)884- if col < start {885- return ""886- }887- return strconv.Itoa(hints[col-start])888-}889-890-func maxHintDepth(hints [][]int) int {891- maxDepth := 0892- for _, h := range hints {893- if len(h) > maxDepth {894- maxDepth = len(h)895- }896- }897- return maxDepth898-}899-900-func normalizeNonogramHintsForRender(hints [][]int, size int) [][]int {901- if size <= 0 {902- return nil903- }904-905- normalized := make([][]int, size)906- for i := 0; i < size; i++ {907- if i >= len(hints) || len(hints[i]) == 0 {908- normalized[i] = []int{0}909- continue910- }911- normalized[i] = append([]int(nil), hints[i]...)912- }913- return normalized914-}915-916-func drawNonogramMajorLines(917- pdf *fpdf.Fpdf,918- puzzleStartX,919- puzzleStartY,920- cellSize float64,921- width,922- height,923- step int,924-) {925- if step <= 0 || width <= 0 || height <= 0 {926- return927- }928-929- pdf.SetDrawColor(45, 45, 45)930- pdf.SetLineWidth(majorGridLineMM)931-932- for col := step; col < width; col += step {933- x := puzzleStartX + float64(col)*cellSize934- pdf.Line(x, puzzleStartY, x, puzzleStartY+float64(height)*cellSize)935- }936- for row := step; row < height; row += step {937- y := puzzleStartY + float64(row)*cellSize938- pdf.Line(puzzleStartX, y, puzzleStartX+float64(width)*cellSize, y)939- }940-}941-942-func summarizeVersions(docs []PackDocument) []string {943- set := map[string]struct{}{}944- for _, doc := range docs {945- version := strings.TrimSpace(doc.Metadata.Version)946- if version == "" {947- continue948- }949- set[version] = struct{}{}950- }951-952- versions := make([]string, 0, len(set))953- for version := range set {954- versions = append(versions, version)955- }956- sort.Strings(versions)957- if len(versions) == 0 {958- return []string{"Unknown"}959- }960- return versions961-}962-963-func defaultTitle(docs []PackDocument) string {964- if len(docs) == 1 {965- category := strings.TrimSpace(docs[0].Metadata.Category)966- if category != "" {967- return fmt.Sprintf("%s Puzzle Pack", category)968- }969- }970- return "PuzzleTea Mixed Puzzle Pack"971-}972-973-func renderSourceExportsTable(974- pdf *fpdf.Fpdf,975- docs []PackDocument,976- x, y, width, maxY float64,977-) float64 {978- if width <= 0 || y >= maxY {979- return y980- }981-982- headers := []string{"Source", "Category", "Mode", "Count", "Seed"}983- columnRatios := []float64{0.33, 0.20, 0.22, 0.10, 0.15}984- columnWidths := make([]float64, len(columnRatios))985- usedWidth := 0.0986- for i := 0; i < len(columnRatios)-1; i++ {987- columnWidths[i] = width * columnRatios[i]988- usedWidth += columnWidths[i]989- }990- columnWidths[len(columnWidths)-1] = width - usedWidth991-992- headerHeight := 5.2993- rowHeight := 4.8994- availableRowsHeight := maxY - y - headerHeight995- if availableRowsHeight < rowHeight {996- return y997- }998- maxRows := int(math.Floor(availableRowsHeight / rowHeight))999- if maxRows < 1 {1000- return y1001- }1002-1003- rowCount := len(docs)1004- if rowCount > maxRows {1005- rowCount = maxRows1006- }1007-1008- pdf.SetDrawColor(125, 125, 125)1009- pdf.SetLineWidth(thinGridLineMM)1010- pdf.SetFillColor(245, 245, 245)1011- pdf.SetTextColor(45, 45, 45)1012- pdf.SetFont(sansFontFamily, "B", 8.9)1013-1014- curX := x1015- for i, header := range headers {1016- pdf.SetXY(curX, y)1017- pdf.CellFormat(columnWidths[i], headerHeight, header, "1", 0, "C", true, 0, "")1018- curX += columnWidths[i]1019- }1020-1021- pdf.SetFont(sansFontFamily, "", 8.6)1022- pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray)1023- for i := 0; i < rowCount; i++ {1024- rowY := y + headerHeight + float64(i)*rowHeight1025- mode := ""1026- if !isMixedModes(docs[i].Metadata.ModeSelection) {1027- mode = docs[i].Metadata.ModeSelection1028- }1029-1030- values := []string{1031- docs[i].Metadata.SourceFileName,1032- docs[i].Metadata.Category,1033- mode,1034- strconv.Itoa(docs[i].Metadata.Count),1035- emptyAs(docs[i].Metadata.Seed, "none"),1036- }1037-1038- curX = x1039- for col := range values {1040- cellText := fitTableCellText(pdf, values[col], columnWidths[col]-1.6)1041- align := "L"1042- if col == 3 {1043- align = "C"1044- }1045- pdf.SetXY(curX, rowY)1046- pdf.CellFormat(columnWidths[col], rowHeight, cellText, "1", 0, align, false, 0, "")1047- curX += columnWidths[col]1048- }1049- }1050-1051- return y + headerHeight + float64(rowCount)*rowHeight1052-}1053-1054-func fitTableCellText(pdf *fpdf.Fpdf, text string, maxWidth float64) string {1055- text = strings.TrimSpace(text)1056- if text == "" {1057- return ""1058- }1059- if maxWidth <= 0 {1060- return ""1061- }1062- if pdf.GetStringWidth(text) <= maxWidth {1063- return text1064- }1065-1066- ellipsis := "..."1067- if pdf.GetStringWidth(ellipsis) > maxWidth {1068- return ellipsis1069- }1070-1071- runes := []rune(text)1072- for len(runes) > 0 {1073- candidate := string(runes) + ellipsis1074- if pdf.GetStringWidth(candidate) <= maxWidth {1075- return candidate1076- }1077- runes = runes[:len(runes)-1]1078- }1079- return ellipsis1080-}1081-1082-func emptyAs(v, fallback string) string {1083- if strings.TrimSpace(v) == "" {1084- return fallback1085- }1086- return v1087-}1088-1089-func difficultyScoreOutOfTen(score float64) int {1090- if score < 0 {1091- score = 01092- }1093- if score > 1 {1094- score = 11095- }1096- return int(math.Round(score * 10))1097-}1098-1099-func isMixedModes(mode string) bool {1100- return normalizeToken(mode) == "mixed modes"1101-}@@ -1,109 +0,0 @@
1-package pdfexport2-3-import (4- "math"5- "strconv"6- "unicode/utf8"7-8- "codeberg.org/go-pdf/fpdf"9-)10-11-func renderHashiPage(pdf *fpdf.Fpdf, data *HashiData) {12- if data == nil || data.Width <= 0 || data.Height <= 0 {13- return14- }15-16- pageW, pageH := pdf.GetPageSize()17- pageNo := pdf.PageNo()18-19- spanX := max(data.Width-1, 1)20- spanY := max(data.Height-1, 1)21- area := puzzleBoardRect(pageW, pageH, pageNo, 1)22- step := fitBoardCellSize(spanX, spanY, area, boardFamilyHashi)23- if step <= 0 {24- return25- }26-27- boardW := float64(spanX) * step28- boardH := float64(spanY) * step29- originX, originY := centeredOrigin(area, spanX, spanY, step)30- islandRadius := hashiIslandRadius(step)31-32- drawHashiGuideDots(pdf, originX, originY, data.Width, data.Height, step)33- drawHashiBoardBorder(pdf, originX, originY, boardW, boardH, islandRadius)34- drawHashiIslands(pdf, originX, originY, step, islandRadius, data.Islands)35-36- // Add an explicit blank line before the Hashi hint text.37- ruleY := instructionY(originY+boardH+instructionLineHMM, pageH, 1)38- setInstructionStyle(pdf)39- pdf.SetXY(area.x, ruleY)40- pdf.CellFormat(41- area.w,42- instructionLineHMM,43- "Connect islands horizontally/vertically with up to two bridges and no crossings.",44- "",45- 0,46- "C",47- false,48- 0,49- "",50- )51-}52-53-func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height int, step float64) {54- pdf.SetFillColor(230, 230, 230)55- r := math.Max(0.20, math.Min(0.55, step*0.035))56- for y := 0; y < height; y++ {57- for x := 0; x < width; x++ {58- cx := originX + float64(x)*step59- cy := originY + float64(y)*step60- pdf.Circle(cx, cy, r, "F")61- }62- }63-}64-65-func drawHashiBoardBorder(pdf *fpdf.Fpdf, originX, originY, boardW, boardH, islandRadius float64) {66- if boardW <= 0 || boardH <= 0 {67- return68- }69- borderPad := islandRadius + 1.270- pdf.SetDrawColor(35, 35, 35)71- pdf.SetLineWidth(outerBorderLineMM)72- pdf.Rect(originX-borderPad, originY-borderPad, boardW+2*borderPad, boardH+2*borderPad, "D")73-}74-75-func drawHashiIslands(pdf *fpdf.Fpdf, originX, originY, step, radius float64, islands []HashiIsland) {76- pdf.SetDrawColor(20, 20, 20)77- pdf.SetFillColor(255, 255, 255)78- pdf.SetLineWidth(majorGridLineMM)79-80- for _, island := range islands {81- cx := originX + float64(island.X)*step82- cy := originY + float64(island.Y)*step83- pdf.Circle(cx, cy, radius, "DF")84- drawHashiIslandNumber(pdf, cx, cy, radius, island.Required)85- }86-}87-88-func hashiIslandRadius(step float64) float64 {89- return math.Max(1.4, math.Min(3.2, step*0.23))90-}91-92-func drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int) {93- text := strconv.Itoa(required)94- fontSize := standardCellFontSize(radius*2.0, 0.95)95- runeCount := utf8.RuneCountInString(text)96- switch {97- case runeCount >= 3:98- fontSize *= 0.7099- case runeCount == 2:100- fontSize *= 0.82101- }102- fontSize = clampStandardCellFontSize(fontSize)103-104- pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)105- pdf.SetFont(sansFontFamily, "B", fontSize)106- lineH := fontSize * 0.88107- pdf.SetXY(cx-radius, cy-lineH/2)108- pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "")109-}@@ -1,191 +0,0 @@
1-package pdfexport2-3-import (4- "strings"5- "unicode/utf8"6-7- "codeberg.org/go-pdf/fpdf"8-)9-10-func renderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) {11- if data == nil || data.Size <= 0 {12- return13- }14-15- size := data.Size16- pageW, pageH := pdf.GetPageSize()17- pageNo := pdf.PageNo()18- area := puzzleBoardRect(pageW, pageH, pageNo, 1)19- cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact)20- if cellSize <= 0 {21- return22- }23-24- blockW := float64(size) * cellSize25- blockH := float64(size) * cellSize26- startX, startY := centeredOrigin(area, size, size, cellSize)27-28- pdf.SetDrawColor(55, 55, 55)29- pdf.SetLineWidth(thinGridLineMM)30- for y := 0; y < size; y++ {31- for x := 0; x < size; x++ {32- cellX := startX + float64(x)*cellSize33- cellY := startY + float64(y)*cellSize34- pdf.Rect(cellX, cellY, cellSize, cellSize, "D")35-36- text := ""37- if y < len(data.Numbers) && x < len(data.Numbers[y]) {38- text = strings.TrimSpace(data.Numbers[y][x])39- }40- if text == "" || text == "." {41- continue42- }43- drawHitoriCellNumber(pdf, cellX, cellY, cellSize, text)44- }45- }46-47- pdf.SetDrawColor(35, 35, 35)48- pdf.SetLineWidth(outerBorderLineMM)49- pdf.Rect(startX, startY, blockW, blockH, "D")50-51- ruleY := instructionY(startY+blockH, pageH, 1)52- setInstructionStyle(pdf)53- pdf.SetXY(area.x, ruleY)54- pdf.CellFormat(55- area.w,56- instructionLineHMM,57- "Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected.",58- "",59- 0,60- "C",61- false,62- 0,63- "",64- )65-}66-67-func drawHitoriCellNumber(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) {68- fontSize := standardCellFontSize(cellSize, 0.58)69- runeCount := utf8.RuneCountInString(text)70- switch {71- case runeCount >= 3:72- fontSize *= 0.773- case runeCount == 2:74- fontSize *= 0.8275- }76- fontSize = clampStandardCellFontSize(fontSize)77-78- pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)79- pdf.SetFont(sansFontFamily, "B", fontSize)80- lineH := fontSize * 0.9281- pdf.SetXY(x, y+(cellSize-lineH)/2)82- pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")83-}84-85-func renderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) {86- if data == nil || data.Size <= 0 {87- return88- }89-90- size := data.Size91- pageW, pageH := pdf.GetPageSize()92- pageNo := pdf.PageNo()93- area := puzzleBoardRect(pageW, pageH, pageNo, 2)94- cellSize := fitBoardCellSize(size, size, area, boardFamilyCompact)95- if cellSize <= 0 {96- return97- }98-99- blockW := float64(size) * cellSize100- blockH := float64(size) * cellSize101- startX, startY := centeredOrigin(area, size, size, cellSize)102-103- pdf.SetDrawColor(60, 60, 60)104- pdf.SetLineWidth(thinGridLineMM)105- for y := 0; y < size; y++ {106- for x := 0; x < size; x++ {107- cellX := startX + float64(x)*cellSize108- cellY := startY + float64(y)*cellSize109- pdf.Rect(cellX, cellY, cellSize, cellSize, "D")110- }111- }112-113- if data.GroupEveryTwo {114- pdf.SetDrawColor(130, 130, 130)115- pdf.SetLineWidth(majorGridLineMM)116- for i := 2; i < size; i += 2 {117- x := startX + float64(i)*cellSize118- y := startY + float64(i)*cellSize119- pdf.Line(x, startY, x, startY+blockH)120- pdf.Line(startX, y, startX+blockW, y)121- }122- }123-124- for y := 0; y < size; y++ {125- for x := 0; x < size; x++ {126- text := ""127- if y < len(data.Givens) && x < len(data.Givens[y]) {128- text = strings.TrimSpace(data.Givens[y][x])129- }130- if text == "" {131- continue132- }133-134- cellX := startX + float64(x)*cellSize135- cellY := startY + float64(y)*cellSize136- drawTakuzuGiven(pdf, cellX, cellY, cellSize, size, text)137- }138- }139-140- pdf.SetDrawColor(35, 35, 35)141- pdf.SetLineWidth(outerBorderLineMM)142- pdf.Rect(startX, startY, blockW, blockH, "D")143-144- ruleY := instructionY(startY+blockH, pageH, 2)145- setInstructionStyle(pdf)146- pdf.SetXY(area.x, ruleY)147- pdf.CellFormat(148- area.w,149- instructionLineHMM,150- "No three equal adjacent in any row or column.",151- "",152- 0,153- "C",154- false,155- 0,156- "",157- )158- pdf.SetXY(area.x, ruleY+instructionLineHMM)159- pdf.CellFormat(160- area.w,161- instructionLineHMM,162- "Each row/column has equal 0 and 1 counts, and rows/columns are unique.",163- "",164- 0,165- "C",166- false,167- 0,168- "",169- )170-}171-172-func drawTakuzuGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, size int, text string) {173- fontSize := takuzuGivenFontSize(cellSize, size)174-175- pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)176- pdf.SetFont(sansFontFamily, "B", fontSize)177- lineH := fontSize * 0.9178- pdf.SetXY(x, y+(cellSize-lineH)/2)179- pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")180-}181-182-func takuzuGivenFontSize(cellSize float64, size int) float64 {183- fontSize := standardCellFontSize(cellSize, 0.68)184- switch {185- case size >= 14:186- fontSize *= 0.94187- case size >= 12:188- fontSize *= 0.97189- }190- return clampStandardCellFontSize(fontSize)191-}@@ -0,0 +1,92 @@
1+package pdfexport2+3+import "codeberg.org/go-pdf/fpdf"4+5+type Rect struct {6+ X float647+ Y float648+ W float649+ H float6410+}11+12+const (13+ SansFontFamily = sansFontFamily14+15+ PrimaryTextGray = primaryTextGray16+ SecondaryTextGray = secondaryTextGray17+ RuleTextGray = ruleTextGray18+ DimTextGray = dimTextGray19+20+ ThinGridLineMM = thinGridLineMM21+ MajorGridLineMM = majorGridLineMM22+ OuterBorderLineMM = outerBorderLineMM23+24+ InstructionLineHMM = instructionLineHMM25+26+ PuzzleBottomInsetMM = puzzleBottomInsetMM27+ WordSearchGridGapMM = wordSearchGridListGap28+29+ PuzzleWordBankFontSize = puzzleWordBankFontSize30+ PuzzleWordBankHeadSize = puzzleWordBankHeadSize31+)32+33+func PuzzleBodyRect(pageW, pageH float64, pageNo int) Rect {34+ area := puzzleBodyRect(pageW, pageH, pageNo)35+ return Rect{X: area.x, Y: area.y, W: area.w, H: area.h}36+}37+38+func PuzzleBoardRect(pageW, pageH float64, pageNo, ruleLines int) Rect {39+ area := puzzleBoardRect(pageW, pageH, pageNo, ruleLines)40+ return Rect{X: area.x, Y: area.y, W: area.w, H: area.h}41+}42+43+func CenteredOrigin(area Rect, cols, rows int, cellSize float64) (float64, float64) {44+ internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H}45+ return centeredOrigin(internal, cols, rows, cellSize)46+}47+48+func InstructionY(boardBottom, pageH float64, lineCount int) float64 {49+ return instructionY(boardBottom, pageH, lineCount)50+}51+52+func FitCompactCellSize(cols, rows int, area Rect) float64 {53+ internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H}54+ return fitBoardCellSize(cols, rows, internal, boardFamilyCompact)55+}56+57+func FitSudokuCellSize(cols, rows int, area Rect) float64 {58+ internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H}59+ return fitBoardCellSize(cols, rows, internal, boardFamilySudoku)60+}61+62+func FitHashiCellSize(cols, rows int, area Rect) float64 {63+ internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H}64+ return fitBoardCellSize(cols, rows, internal, boardFamilyHashi)65+}66+67+func FitNonogramCellSize(cols, rows int, area Rect) float64 {68+ internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H}69+ return fitBoardCellSize(cols, rows, internal, boardFamilyNonogram)70+}71+72+func FitWordSearchCellSize(cols, rows int, area Rect) float64 {73+ internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H}74+ return fitBoardCellSize(cols, rows, internal, boardFamilyWordSearch)75+}76+77+func FitTableCellSize(cols, rows int, area Rect) float64 {78+ internal := rectMM{x: area.X, y: area.Y, w: area.W, h: area.H}79+ return fitBoardCellSize(cols, rows, internal, boardFamilyTable)80+}81+82+func StandardCellFontSize(cellSize, scale float64) float64 {83+ return standardCellFontSize(cellSize, scale)84+}85+86+func ClampStandardCellFontSize(fontSize float64) float64 {87+ return clampStandardCellFontSize(fontSize)88+}89+90+func SetInstructionStyle(pdf *fpdf.Fpdf) {91+ setInstructionStyle(pdf)92+}@@ -46,65 +46,6 @@ func TestCenteredOriginKeepsBoardCentered(t *testing.T) {
4646 }4747 }4848 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- const pageNo = 360- boardArea := puzzleBoardRect(halfLetterWidthMM, halfLetterHeightMM, pageNo, 1)61- for _, tt := range tests {62- t.Run(tt.name, func(t *testing.T) {63- layout := layoutNonogram(64- halfLetterWidthMM,65- halfLetterHeightMM,66- pageNo,67- 10,68- 10,69- tt.rowHintCol,70- tt.colHintRow,71- )72- if layout.cellSize <= 0 {73- t.Fatal("expected non-zero cell size")74- }75-76- gridW := 10.0 * layout.cellSize77- gridH := 10.0 * layout.cellSize78- centerX := layout.gridX + gridW/279- centerY := layout.gridY + gridH/280- wantX := boardArea.x + boardArea.w/281- wantY := boardArea.y + boardArea.h/282-83- if diff := math.Abs(centerX - wantX); diff > 0.8 {84- t.Fatalf("grid centerX diff = %.3f, want <= 0.8", diff)85- }86- if diff := math.Abs(centerY - wantY); diff > 0.8 {87- t.Fatalf("grid centerY diff = %.3f, want <= 0.8", diff)88- }89-90- fullW := float64(tt.rowHintCol+10) * layout.cellSize91- fullH := float64(tt.colHintRow+10) * layout.cellSize92- if layout.hintStartX < boardArea.x-0.01 {93- t.Fatalf("hintStartX = %.3f, want >= %.3f", layout.hintStartX, boardArea.x)94- }95- if layout.hintStartY < boardArea.y-0.01 {96- t.Fatalf("hintStartY = %.3f, want >= %.3f", layout.hintStartY, boardArea.y)97- }98- if right := layout.hintStartX + fullW; right > boardArea.x+boardArea.w+0.01 {99- t.Fatalf("hint block right = %.3f, want <= %.3f", right, boardArea.x+boardArea.w)100- }101- if bottom := layout.hintStartY + fullH; bottom > boardArea.y+boardArea.h+0.01 {102- t.Fatalf("hint block bottom = %.3f, want <= %.3f", bottom, boardArea.y+boardArea.h)103- }104- })105- }106-}107-10849 func TestPuzzleBodyRectMirrorsGutterByParity(t *testing.T) {10950 even := puzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 2)11051 odd := puzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 3)@@ -0,0 +1,17 @@
1+package pdfexport2+3+import "math"4+5+func difficultyScoreOutOfTen(score float64) int {6+ if score < 0 {7+ score = 08+ }9+ if score > 1 {10+ score = 111+ }12+ return int(math.Round(score * 10))13+}14+15+func isMixedModes(mode string) bool {16+ return normalizeToken(mode) == "mixed modes"17+}@@ -1,70 +0,0 @@
1-package pdfexport2-3-import "testing"4-5-func TestNormalizeNonogramHintsForRender(t *testing.T) {6- tests := []struct {7- name string8- hints [][]int9- size int10- want [][]int11- }{12- {13- name: "empty source defaults to zeros",14- hints: nil,15- size: 3,16- want: [][]int{{0}, {0}, {0}},17- },18- {19- name: "preserves provided hint rows",20- hints: [][]int{{3, 1}, {}, {2}},21- size: 3,22- want: [][]int{{3, 1}, {0}, {2}},23- },24- {25- name: "pads beyond provided rows",26- hints: [][]int{{1}},27- size: 3,28- want: [][]int{{1}, {0}, {0}},29- },30- {31- name: "non-positive size returns nil",32- hints: [][]int{{1}},33- size: 0,34- want: nil,35- },36- }37-38- for _, tt := range tests {39- t.Run(tt.name, func(t *testing.T) {40- got := normalizeNonogramHintsForRender(tt.hints, tt.size)41- if len(got) != len(tt.want) {42- t.Fatalf("rows = %d, want %d", len(got), len(tt.want))43- }44- for row := range len(tt.want) {45- if len(got[row]) != len(tt.want[row]) {46- t.Fatalf("row %d len = %d, want %d", row, len(got[row]), len(tt.want[row]))47- }48- for col := range len(tt.want[row]) {49- if got[row][col] != tt.want[row][col] {50- t.Fatalf("row %d col %d = %d, want %d", row, col, got[row][col], tt.want[row][col])51- }52- }53- }54- })55- }56-}57-58-func TestNormalizeNonogramHintsForRenderCopiesRows(t *testing.T) {59- src := [][]int{{2, 1}}60-61- got := normalizeNonogramHintsForRender(src, 1)62- if got == nil || len(got) != 1 {63- t.Fatalf("unexpected normalized hints: %#v", got)64- }65-66- src[0][0] = 967- if got[0][0] != 2 {68- t.Fatalf("normalized hint should be copied, got %d want 2", got[0][0])69- }70-}@@ -1,131 +0,0 @@
1-package pdfexport2-3-import (4- "strconv"5- "unicode/utf8"6-7- "codeberg.org/go-pdf/fpdf"8-)9-10-func renderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) {11- if data == nil || data.Width <= 0 || data.Height <= 0 {12- return13- }14-15- pageW, pageH := pdf.GetPageSize()16- pageNo := pdf.PageNo()17- area := puzzleBoardRect(pageW, pageH, pageNo, 1)18- cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact)19- if cellSize <= 0 {20- return21- }22-23- blockW := float64(data.Width) * cellSize24- blockH := float64(data.Height) * cellSize25- startX, startY := centeredOrigin(area, data.Width, data.Height, cellSize)26-27- pdf.SetDrawColor(55, 55, 55)28- pdf.SetLineWidth(thinGridLineMM)29- for y := 0; y < data.Height; y++ {30- for x := 0; x < data.Width; x++ {31- cellX := startX + float64(x)*cellSize32- cellY := startY + float64(y)*cellSize33- pdf.Rect(cellX, cellY, cellSize, cellSize, "D")34-35- if y >= len(data.Clues) || x >= len(data.Clues[y]) || data.Clues[y][x] <= 0 {36- continue37- }38- drawRectanglePuzzleClue(pdf, cellX, cellY, cellSize, data.Clues[y][x])39- }40- }41-42- pdf.SetDrawColor(35, 35, 35)43- pdf.SetLineWidth(outerBorderLineMM)44- pdf.Rect(startX, startY, blockW, blockH, "D")45-46- ruleY := instructionY(startY+blockH, pageH, 1)47- setInstructionStyle(pdf)48- pdf.SetXY(area.x, ruleY)49- pdf.CellFormat(50- area.w,51- instructionLineHMM,52- "Expand each numbered island to its size; connect all sea cells into one wall.",53- "",54- 0,55- "C",56- false,57- 0,58- "",59- )60-}61-62-func renderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) {63- if data == nil || data.Width <= 0 || data.Height <= 0 {64- return65- }66-67- pageW, pageH := pdf.GetPageSize()68- pageNo := pdf.PageNo()69- area := puzzleBoardRect(pageW, pageH, pageNo, 1)70- cellSize := fitBoardCellSize(data.Width, data.Height, area, boardFamilyCompact)71- if cellSize <= 0 {72- return73- }74-75- blockW := float64(data.Width) * cellSize76- blockH := float64(data.Height) * cellSize77- startX, startY := centeredOrigin(area, data.Width, data.Height, cellSize)78-79- pdf.SetDrawColor(55, 55, 55)80- pdf.SetLineWidth(thinGridLineMM)81- for y := 0; y < data.Height; y++ {82- for x := 0; x < data.Width; x++ {83- cellX := startX + float64(x)*cellSize84- cellY := startY + float64(y)*cellSize85- pdf.Rect(cellX, cellY, cellSize, cellSize, "D")86-87- if y >= len(data.Clues) || x >= len(data.Clues[y]) || data.Clues[y][x] <= 0 {88- continue89- }90- drawRectanglePuzzleClue(pdf, cellX, cellY, cellSize, data.Clues[y][x])91- }92- }93-94- pdf.SetDrawColor(35, 35, 35)95- pdf.SetLineWidth(outerBorderLineMM)96- pdf.Rect(startX, startY, blockW, blockH, "D")97-98- ruleY := instructionY(startY+blockH, pageH, 1)99- setInstructionStyle(pdf)100- pdf.SetXY(area.x, ruleY)101- pdf.CellFormat(102- area.w,103- instructionLineHMM,104- "Partition into rectangles where each clue equals its rectangle area.",105- "",106- 0,107- "C",108- false,109- 0,110- "",111- )112-}113-114-func drawRectanglePuzzleClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) {115- text := strconv.Itoa(value)116- fontSize := standardCellFontSize(cellSize, 0.58)117- runeCount := utf8.RuneCountInString(text)118- switch {119- case runeCount >= 3:120- fontSize *= 0.72121- case runeCount == 2:122- fontSize *= 0.84123- }124- fontSize = clampStandardCellFontSize(fontSize)125-126- pdf.SetTextColor(primaryTextGray, primaryTextGray, primaryTextGray)127- pdf.SetFont(sansFontFamily, "B", fontSize)128- lineH := fontSize * 0.92129- pdf.SetXY(x, y+(cellSize-lineH)/2)130- pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")131-}@@ -1,39 +0,0 @@
1-package pdfexport2-3-import "codeberg.org/go-pdf/fpdf"4-5-func RenderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {6- renderNonogramPage(pdf, data)7-}8-9-func RenderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) {10- renderNurikabePage(pdf, data)11-}12-13-func RenderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) {14- renderShikakuPage(pdf, data)15-}16-17-func RenderHashiPage(pdf *fpdf.Fpdf, data *HashiData) {18- renderHashiPage(pdf, data)19-}20-21-func RenderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) {22- renderHitoriPage(pdf, data)23-}24-25-func RenderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) {26- renderTakuzuPage(pdf, data)27-}28-29-func RenderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) {30- renderSudokuPage(pdf, data)31-}32-33-func RenderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) {34- renderWordSearchPage(pdf, data)35-}36-37-func RenderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) {38- renderGridTablePage(pdf, table)39-}@@ -0,0 +1,284 @@
1+package pdfexport2+3+import (4+ "fmt"5+ "math"6+ "sort"7+ "strconv"8+ "strings"9+10+ "codeberg.org/go-pdf/fpdf"11+)12+13+func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) {14+ pdf.AddPage()15+ pageW, pageH := pdf.GetPageSize()16+ margin := 12.017+ contentWidth := pageW - 2*margin18+19+ pdf.SetTextColor(20, 20, 20)20+ pdf.SetFont(sansFontFamily, "B", 22)21+ pdf.SetXY(0, 24)22+ pdf.CellFormat(pageW, 10, fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber), "", 0, "C", false, 0, "")23+24+ pdf.SetTextColor(50, 50, 50)25+ pdf.SetFont(coverFontFamily, "", 16)26+ pdf.SetXY(0, 35)27+ pdf.CellFormat(pageW, 8, cfg.CoverSubtitle, "", 0, "C", false, 0, "")28+29+ pdf.SetFont(sansFontFamily, "", 11)30+ pdf.SetTextColor(70, 70, 70)31+ pdf.SetXY(0, 44)32+ pdf.CellFormat(pageW, 6, "PuzzleTea Puzzle Pack", "", 0, "C", false, 0, "")33+34+ versionLine := fmt.Sprintf("PuzzleTea Version: %s", strings.Join(summarizeVersions(docs), ", "))35+ pdf.SetFont(sansFontFamily, "", 10)36+ wrappedVersions := pdf.SplitLines([]byte(versionLine), contentWidth)37+ if len(wrappedVersions) == 0 {38+ wrappedVersions = [][]byte{[]byte(versionLine)}39+ }40+41+ headerLineH := 4.842+ headerGap := 1.243+ headerStartY := 54.844+ metaY := 56.045+ if header := strings.TrimSpace(cfg.HeaderText); header != "" {46+ pdf.SetFont(sansFontFamily, "", 9.2)47+ pdf.SetTextColor(74, 74, 74)48+ wrappedHeader := pdf.SplitLines([]byte(header), contentWidth)49+ if len(wrappedHeader) == 0 {50+ wrappedHeader = [][]byte{[]byte(header)}51+ }52+53+ metaY = headerStartY + float64(len(wrappedHeader))*headerLineH + headerGap54+ sourceStartY := titlePageSourceTableStartY(metaY, len(wrappedVersions))55+ sourceMaxY := pageH - 4556+ if spare := titlePageSourceTableWhitespace(sourceMaxY, sourceStartY, len(docs)); spare > 0 {57+ headerGap += spare58+ }59+60+ headerY := headerStartY61+ for _, line := range wrappedHeader {62+ pdf.SetXY(margin, headerY)63+ pdf.CellFormat(contentWidth, headerLineH, string(line), "", 0, "C", false, 0, "")64+ headerY += headerLineH65+ }66+ metaY = headerY + headerGap67+ }68+69+ pdf.SetTextColor(25, 25, 25)70+ pdf.SetFont(sansFontFamily, "", 10)71+ pdf.SetXY(margin, metaY)72+ pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format("January 2, 2006")), "", 0, "L", false, 0, "")73+ metaY += 674+ for _, line := range wrappedVersions {75+ pdf.SetXY(margin, metaY)76+ pdf.CellFormat(contentWidth, 5.2, string(line), "", 0, "L", false, 0, "")77+ metaY += 5.278+ }79+ metaY += 0.880+81+ pdf.SetXY(margin, metaY)82+ pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Puzzles: %d", len(puzzles)), "", 0, "L", false, 0, "")83+ metaY += 684+ metaY += 1.885+86+ pdf.SetFont(sansFontFamily, "B", 10)87+ pdf.SetTextColor(45, 45, 45)88+ pdf.SetXY(margin, metaY)89+ pdf.CellFormat(contentWidth, 6, "Source Exports", "", 0, "L", false, 0, "")90+ metaY += 791+92+ renderSourceExportsTable(pdf, docs, margin, metaY, contentWidth, pageH-45)93+94+ pdf.SetTextColor(50, 50, 50)95+ pdf.SetFont(sansFontFamily, "B", 12)96+ pdf.SetXY(margin, pageH-30)97+ pdf.CellFormat(contentWidth, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "")98+99+ pdf.SetFont(sansFontFamily, "", 10)100+ pdf.SetXY(margin, pageH-22)101+ pdf.CellFormat(contentWidth, 6, cfg.AdvertText, "", 0, "C", false, 0, "")102+}103+104+func titlePageSourceTableStartY(metaY float64, versionLineCount int) float64 {105+ y := metaY106+ y += 6107+ y += float64(max(versionLineCount, 1)) * 5.2108+ y += 0.8109+ y += 6110+ y += 1.8111+ y += 7112+ return y113+}114+115+func titlePageSourceTableWhitespace(maxY, sourceStartY float64, docCount int) float64 {116+ const (117+ headerHeight = 5.2118+ rowHeight = 4.8119+ )120+121+ availableRowsHeight := maxY - sourceStartY - headerHeight122+ if availableRowsHeight < rowHeight {123+ return 0124+ }125+126+ maxRows := int(math.Floor(availableRowsHeight / rowHeight))127+ if maxRows < 1 {128+ return 0129+ }130+131+ rowCount := min(docCount, maxRows)132+ used := headerHeight + float64(rowCount)*rowHeight133+ if spare := maxY - sourceStartY - used; spare > 0 {134+ return spare135+ }136+ return 0137+}138+139+func summarizeVersions(docs []PackDocument) []string {140+ set := map[string]struct{}{}141+ for _, doc := range docs {142+ version := strings.TrimSpace(doc.Metadata.Version)143+ if version == "" {144+ continue145+ }146+ set[version] = struct{}{}147+ }148+149+ versions := make([]string, 0, len(set))150+ for version := range set {151+ versions = append(versions, version)152+ }153+ sort.Strings(versions)154+ if len(versions) == 0 {155+ return []string{"Unknown"}156+ }157+ return versions158+}159+160+func defaultTitle(docs []PackDocument) string {161+ if len(docs) == 1 {162+ category := strings.TrimSpace(docs[0].Metadata.Category)163+ if category != "" {164+ return fmt.Sprintf("%s Puzzle Pack", category)165+ }166+ }167+ return "PuzzleTea Mixed Puzzle Pack"168+}169+170+func renderSourceExportsTable(171+ pdf *fpdf.Fpdf,172+ docs []PackDocument,173+ x, y, width, maxY float64,174+) float64 {175+ if width <= 0 || y >= maxY {176+ return y177+ }178+179+ headers := []string{"Source", "Category", "Mode", "Count", "Seed"}180+ columnRatios := []float64{0.33, 0.20, 0.22, 0.10, 0.15}181+ columnWidths := make([]float64, len(columnRatios))182+ usedWidth := 0.0183+ for i := 0; i < len(columnRatios)-1; i++ {184+ columnWidths[i] = width * columnRatios[i]185+ usedWidth += columnWidths[i]186+ }187+ columnWidths[len(columnWidths)-1] = width - usedWidth188+189+ headerHeight := 5.2190+ rowHeight := 4.8191+ availableRowsHeight := maxY - y - headerHeight192+ if availableRowsHeight < rowHeight {193+ return y194+ }195+ maxRows := int(math.Floor(availableRowsHeight / rowHeight))196+ if maxRows < 1 {197+ return y198+ }199+200+ rowCount := len(docs)201+ if rowCount > maxRows {202+ rowCount = maxRows203+ }204+205+ pdf.SetDrawColor(125, 125, 125)206+ pdf.SetLineWidth(thinGridLineMM)207+ pdf.SetFillColor(245, 245, 245)208+ pdf.SetTextColor(45, 45, 45)209+ pdf.SetFont(sansFontFamily, "B", 8.9)210+211+ curX := x212+ for i, header := range headers {213+ pdf.SetXY(curX, y)214+ pdf.CellFormat(columnWidths[i], headerHeight, header, "1", 0, "C", true, 0, "")215+ curX += columnWidths[i]216+ }217+218+ pdf.SetFont(sansFontFamily, "", 8.6)219+ pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray)220+ for i := 0; i < rowCount; i++ {221+ rowY := y + headerHeight + float64(i)*rowHeight222+ mode := ""223+ if !isMixedModes(docs[i].Metadata.ModeSelection) {224+ mode = docs[i].Metadata.ModeSelection225+ }226+227+ values := []string{228+ docs[i].Metadata.SourceFileName,229+ docs[i].Metadata.Category,230+ mode,231+ strconv.Itoa(docs[i].Metadata.Count),232+ emptyAs(docs[i].Metadata.Seed, "none"),233+ }234+235+ curX = x236+ for col := range values {237+ cellText := fitTableCellText(pdf, values[col], columnWidths[col]-1.6)238+ align := "L"239+ if col == 3 {240+ align = "C"241+ }242+ pdf.SetXY(curX, rowY)243+ pdf.CellFormat(columnWidths[col], rowHeight, cellText, "1", 0, align, false, 0, "")244+ curX += columnWidths[col]245+ }246+ }247+248+ return y + headerHeight + float64(rowCount)*rowHeight249+}250+251+func fitTableCellText(pdf *fpdf.Fpdf, text string, maxWidth float64) string {252+ text = strings.TrimSpace(text)253+ if text == "" {254+ return ""255+ }256+ if maxWidth <= 0 {257+ return ""258+ }259+ if pdf.GetStringWidth(text) <= maxWidth {260+ return text261+ }262+263+ ellipsis := "..."264+ if pdf.GetStringWidth(ellipsis) > maxWidth {265+ return ellipsis266+ }267+268+ runes := []rune(text)269+ for len(runes) > 0 {270+ candidate := string(runes) + ellipsis271+ if pdf.GetStringWidth(candidate) <= maxWidth {272+ return candidate273+ }274+ runes = runes[:len(runes)-1]275+ }276+ return ellipsis277+}278+279+func emptyAs(v, fallback string) string {280+ if strings.TrimSpace(v) == "" {281+ return fallback282+ }283+ return v284+}@@ -1,6 +1,9 @@
11 package shikaku22 33 import (4+ "strconv"5+ "unicode/utf8"6+47 "codeberg.org/go-pdf/fpdf"58 "github.com/FelineStateMachine/puzzletea/game"69 "github.com/FelineStateMachine/puzzletea/pdfexport"@@ -18,11 +21,82 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) {
1821 func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {1922 switch data := payload.(type) {2023 case *pdfexport.ShikakuData:21- pdfexport.RenderShikakuPage(pdf, data)24+ renderShikakuPage(pdf, data)2225 }2326 return nil2427 }2528 29+func renderShikakuPage(pdf *fpdf.Fpdf, data *pdfexport.ShikakuData) {30+ if data == nil || data.Width <= 0 || data.Height <= 0 {31+ return32+ }33+34+ pageW, pageH := pdf.GetPageSize()35+ pageNo := pdf.PageNo()36+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)37+ cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area)38+ if cellSize <= 0 {39+ return40+ }41+42+ blockW := float64(data.Width) * cellSize43+ blockH := float64(data.Height) * cellSize44+ startX, startY := pdfexport.CenteredOrigin(area, data.Width, data.Height, cellSize)45+46+ pdf.SetDrawColor(55, 55, 55)47+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)48+ for y := 0; y < data.Height; y++ {49+ for x := 0; x < data.Width; x++ {50+ cellX := startX + float64(x)*cellSize51+ cellY := startY + float64(y)*cellSize52+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")53+54+ if y >= len(data.Clues) || x >= len(data.Clues[y]) || data.Clues[y][x] <= 0 {55+ continue56+ }57+ drawShikakuClue(pdf, cellX, cellY, cellSize, data.Clues[y][x])58+ }59+ }60+61+ pdf.SetDrawColor(35, 35, 35)62+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)63+ pdf.Rect(startX, startY, blockW, blockH, "D")64+65+ ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1)66+ pdfexport.SetInstructionStyle(pdf)67+ pdf.SetXY(area.X, ruleY)68+ pdf.CellFormat(69+ area.W,70+ pdfexport.InstructionLineHMM,71+ "Partition into rectangles where each clue equals its rectangle area.",72+ "",73+ 0,74+ "C",75+ false,76+ 0,77+ "",78+ )79+}80+81+func drawShikakuClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) {82+ text := strconv.Itoa(value)83+ fontSize := pdfexport.StandardCellFontSize(cellSize, 0.58)84+ runeCount := utf8.RuneCountInString(text)85+ switch {86+ case runeCount >= 3:87+ fontSize *= 0.7288+ case runeCount == 2:89+ fontSize *= 0.8490+ }91+ fontSize = pdfexport.ClampStandardCellFontSize(fontSize)92+93+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)94+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)95+ lineH := fontSize * 0.9296+ pdf.SetXY(x, y+(cellSize-lineH)/2)97+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")98+}99+26100 func init() {27101 game.RegisterPrintAdapter(printAdapter{})28102 }@@ -1,6 +1,8 @@
11 package sudoku22 33 import (4+ "strconv"5+46 "codeberg.org/go-pdf/fpdf"57 "github.com/FelineStateMachine/puzzletea/game"68 "github.com/FelineStateMachine/puzzletea/pdfexport"@@ -18,11 +20,93 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) {
1820 func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {1921 switch data := payload.(type) {2022 case *pdfexport.SudokuData:21- pdfexport.RenderSudokuPage(pdf, data)23+ renderSudokuPage(pdf, data)2224 }2325 return nil2426 }2527 28+func renderSudokuPage(pdf *fpdf.Fpdf, data *pdfexport.SudokuData) {29+ if data == nil {30+ return31+ }32+33+ pageW, pageH := pdf.GetPageSize()34+ pageNo := pdf.PageNo()35+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)36+ cellSize := pdfexport.FitSudokuCellSize(9, 9, area)37+ if cellSize <= 0 {38+ return39+ }40+41+ boardH := 9.0 * cellSize42+ startX, startY := pdfexport.CenteredOrigin(area, 9, 9, cellSize)43+44+ drawSudokuGridLines(pdf, startX, startY, cellSize)45+ drawSudokuGivens(pdf, startX, startY, cellSize, data.Givens)46+47+ ruleY := pdfexport.InstructionY(startY+boardH, pageH, 1)48+ pdfexport.SetInstructionStyle(pdf)49+ pdf.SetXY(area.X, ruleY)50+ pdf.CellFormat(51+ area.W,52+ pdfexport.InstructionLineHMM,53+ "Fill rows, columns, and 3x3 boxes with 1-9",54+ "",55+ 0,56+ "C",57+ false,58+ 0,59+ "",60+ )61+}62+63+func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) {64+ pdf.SetDrawColor(25, 25, 25)65+66+ for i := range 10 {67+ x := startX + float64(i)*cellSize68+ pdf.SetLineWidth(sudokuLineWidthFor(i))69+ pdf.Line(x, startY, x, startY+9.0*cellSize)70+ }71+72+ for i := range 10 {73+ y := startY + float64(i)*cellSize74+ pdf.SetLineWidth(sudokuLineWidthFor(i))75+ pdf.Line(startX, y, startX+9.0*cellSize, y)76+ }77+}78+79+func sudokuLineWidthFor(index int) float64 {80+ switch {81+ case index == 0 || index == 9:82+ return 0.7283+ case index%3 == 0:84+ return 0.5685+ default:86+ return pdfexport.ThinGridLineMM87+ }88+}89+90+func drawSudokuGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [9][9]int) {91+ fontSize := pdfexport.StandardCellFontSize(cellSize, 0.62)92+ lineH := fontSize * 0.8593+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)94+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)95+96+ for y := range 9 {97+ for x := range 9 {98+ value := givens[y][x]99+ if value < 1 || value > 9 {100+ continue101+ }102+ cellX := startX + float64(x)*cellSize103+ cellY := startY + float64(y)*cellSize104+ pdf.SetXY(cellX, cellY+(cellSize-lineH)/2)105+ pdf.CellFormat(cellSize, lineH, strconv.Itoa(value), "", 0, "C", false, 0, "")106+ }107+ }108+}109+26110 func init() {27111 game.RegisterPrintAdapter(printAdapter{})28112 }@@ -1,6 +1,8 @@
11 package takuzu22 33 import (4+ "strings"5+46 "codeberg.org/go-pdf/fpdf"57 "github.com/FelineStateMachine/puzzletea/game"68 "github.com/FelineStateMachine/puzzletea/pdfexport"@@ -18,11 +20,119 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) {
1820 func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {1921 switch data := payload.(type) {2022 case *pdfexport.TakuzuData:21- pdfexport.RenderTakuzuPage(pdf, data)23+ renderTakuzuPage(pdf, data)2224 }2325 return nil2426 }2527 28+func renderTakuzuPage(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData) {29+ if data == nil || data.Size <= 0 {30+ return31+ }32+33+ size := data.Size34+ pageW, pageH := pdf.GetPageSize()35+ pageNo := pdf.PageNo()36+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 2)37+ cellSize := pdfexport.FitCompactCellSize(size, size, area)38+ if cellSize <= 0 {39+ return40+ }41+42+ blockW := float64(size) * cellSize43+ blockH := float64(size) * cellSize44+ startX, startY := pdfexport.CenteredOrigin(area, size, size, cellSize)45+46+ pdf.SetDrawColor(60, 60, 60)47+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)48+ for y := 0; y < size; y++ {49+ for x := 0; x < size; x++ {50+ cellX := startX + float64(x)*cellSize51+ cellY := startY + float64(y)*cellSize52+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")53+ }54+ }55+56+ if data.GroupEveryTwo {57+ pdf.SetDrawColor(130, 130, 130)58+ pdf.SetLineWidth(pdfexport.MajorGridLineMM)59+ for i := 2; i < size; i += 2 {60+ x := startX + float64(i)*cellSize61+ y := startY + float64(i)*cellSize62+ pdf.Line(x, startY, x, startY+blockH)63+ pdf.Line(startX, y, startX+blockW, y)64+ }65+ }66+67+ for y := 0; y < size; y++ {68+ for x := 0; x < size; x++ {69+ text := ""70+ if y < len(data.Givens) && x < len(data.Givens[y]) {71+ text = strings.TrimSpace(data.Givens[y][x])72+ }73+ if text == "" {74+ continue75+ }76+77+ cellX := startX + float64(x)*cellSize78+ cellY := startY + float64(y)*cellSize79+ drawTakuzuGiven(pdf, cellX, cellY, cellSize, size, text)80+ }81+ }82+83+ pdf.SetDrawColor(35, 35, 35)84+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)85+ pdf.Rect(startX, startY, blockW, blockH, "D")86+87+ ruleY := pdfexport.InstructionY(startY+blockH, pageH, 2)88+ pdfexport.SetInstructionStyle(pdf)89+ pdf.SetXY(area.X, ruleY)90+ pdf.CellFormat(91+ area.W,92+ pdfexport.InstructionLineHMM,93+ "No three equal adjacent in any row or column.",94+ "",95+ 0,96+ "C",97+ false,98+ 0,99+ "",100+ )101+ pdf.SetXY(area.X, ruleY+pdfexport.InstructionLineHMM)102+ pdf.CellFormat(103+ area.W,104+ pdfexport.InstructionLineHMM,105+ "Each row/column has equal 0 and 1 counts, and rows/columns are unique.",106+ "",107+ 0,108+ "C",109+ false,110+ 0,111+ "",112+ )113+}114+115+func drawTakuzuGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, size int, text string) {116+ fontSize := takuzuGivenFontSize(cellSize, size)117+118+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)119+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)120+ lineH := fontSize * 0.9121+ pdf.SetXY(x, y+(cellSize-lineH)/2)122+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")123+}124+125+func takuzuGivenFontSize(cellSize float64, size int) float64 {126+ fontSize := pdfexport.StandardCellFontSize(cellSize, 0.68)127+ switch {128+ case size >= 14:129+ fontSize *= 0.94130+ case size >= 12:131+ fontSize *= 0.97132+ }133+ return pdfexport.ClampStandardCellFontSize(fontSize)134+}135+26136 func init() {27137 game.RegisterPrintAdapter(printAdapter{})28138 }@@ -1,6 +1,8 @@
11 package wordsearch22 33 import (4+ "strings"5+46 "codeberg.org/go-pdf/fpdf"57 "github.com/FelineStateMachine/puzzletea/game"68 "github.com/FelineStateMachine/puzzletea/pdfexport"@@ -20,11 +22,240 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) {
2022 func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {2123 switch data := payload.(type) {2224 case *pdfexport.WordSearchData:23- pdfexport.RenderWordSearchPage(pdf, data)25+ renderWordSearchPage(pdf, data)2426 }2527 return nil2628 }2729 30+func renderWordSearchPage(pdf *fpdf.Fpdf, data *pdfexport.WordSearchData) {31+ if data == nil || data.Width <= 0 || data.Height <= 0 {32+ return33+ }34+35+ pageW, pageH := pdf.GetPageSize()36+ pageNo := pdf.PageNo()37+ body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo)38+ availW := body.W39+ availH := body.H40+41+ columnCount := wordSearchColumnCount(data.Width, len(data.Words))42+ wordFontSize := pdfexport.PuzzleWordBankFontSize43+ wordLineHeight := 4.244+ gridListGap := pdfexport.WordSearchGridGapMM45+46+ estimatedWordLines := estimateWordBankLineCount(pdf, data.Words, columnCount, availW, wordFontSize)47+ wordBankHeight := 7.0 + float64(estimatedWordLines)*wordLineHeight48+ maxWordBankHeight := availH * 0.4249+ if wordBankHeight > maxWordBankHeight {50+ wordBankHeight = maxWordBankHeight51+ }52+ if wordBankHeight < 16 {53+ wordBankHeight = 1654+ }55+56+ gridAreaH := availH - wordBankHeight - gridListGap57+ if gridAreaH < availH*0.5 {58+ gridAreaH = availH * 0.559+ }60+61+ gridArea := pdfexport.Rect{X: body.X, Y: body.Y, W: availW, H: gridAreaH}62+ cellSize := pdfexport.FitWordSearchCellSize(data.Width, data.Height, gridArea)63+ if cellSize <= 0 {64+ return65+ }66+67+ gridW := float64(data.Width) * cellSize68+ gridH := float64(data.Height) * cellSize69+ gridX := body.X + (gridArea.W-gridW)/270+ gridY := body.Y + (gridArea.H-gridH)/271+72+ drawWordSearchGrid(pdf, data, gridX, gridY, cellSize)73+ drawWordBank(74+ pdf,75+ data.Words,76+ body.X,77+ gridY+gridH+gridListGap,78+ availW,79+ pageH-pdfexport.PuzzleBottomInsetMM-(gridY+gridH+gridListGap),80+ columnCount,81+ )82+}83+84+func drawWordSearchGrid(pdf *fpdf.Fpdf, data *pdfexport.WordSearchData, startX, startY, cellSize float64) {85+ pdf.SetDrawColor(45, 45, 45)86+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)87+ for y := range data.Height {88+ for x := range data.Width {89+ cellX := startX + float64(x)*cellSize90+ cellY := startY + float64(y)*cellSize91+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")92+93+ cellText := " "94+ if y < len(data.Grid) && x < len(data.Grid[y]) {95+ cellText = strings.TrimSpace(strings.ToUpper(data.Grid[y][x]))96+ }97+ if cellText == "" || cellText == "." {98+ continue99+ }100+101+ fontSize := pdfexport.StandardCellFontSize(cellSize, 0.74)102+ lineH := fontSize * 0.86103+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)104+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)105+ pdf.SetXY(cellX, cellY+(cellSize-lineH)/2)106+ pdf.CellFormat(cellSize, lineH, cellText, "", 0, "C", false, 0, "")107+ }108+ }109+110+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)111+ pdf.Rect(startX, startY, float64(data.Width)*cellSize, float64(data.Height)*cellSize, "D")112+}113+114+func drawWordBank(pdf *fpdf.Fpdf, words []string, x, y, width, height float64, columns int) {115+ if height <= 0 {116+ return117+ }118+119+ pdf.SetTextColor(40, 40, 40)120+ pdf.SetFont(pdfexport.SansFontFamily, "B", pdfexport.PuzzleWordBankHeadSize)121+ pdf.SetXY(x, y)122+ pdf.CellFormat(width, 4.8, "Word Bank", "", 0, "L", false, 0, "")123+124+ pdf.SetFont(pdfexport.SansFontFamily, "", pdfexport.PuzzleWordBankFontSize)125+ pdf.SetTextColor(pdfexport.RuleTextGray, pdfexport.RuleTextGray, pdfexport.RuleTextGray)126+ pdf.SetXY(x, y+4.8)127+ pdf.CellFormat(width, 4.2, "Words may run in all 8 directions", "", 0, "L", false, 0, "")128+129+ listY := y + 9.0130+ if len(words) == 0 {131+ pdf.SetFont(pdfexport.SansFontFamily, "", pdfexport.PuzzleWordBankHeadSize)132+ pdf.SetTextColor(pdfexport.SecondaryTextGray, pdfexport.SecondaryTextGray, pdfexport.SecondaryTextGray)133+ pdf.SetXY(x, listY)134+ pdf.CellFormat(width, 4.6, "(word list unavailable)", "", 0, "L", false, 0, "")135+ return136+ }137+138+ columnGap := 4.0139+ if columns < 1 {140+ columns = 1141+ }142+ colWidth := (width - float64(columns-1)*columnGap) / float64(columns)143+ if colWidth <= 0 {144+ colWidth = width145+ columns = 1146+ }147+148+ colLines := layoutWordBankColumns(pdf, words, columns, colWidth)149+ lineHeight := 4.1150+ maxLines := int(height / lineHeight)151+ if maxLines <= 0 {152+ return153+ }154+155+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)156+ pdf.SetFont(pdfexport.SansFontFamily, "B", pdfexport.PuzzleWordBankHeadSize)157+ for c := range columns {158+ colX := x + float64(c)*(colWidth+columnGap)159+ curY := listY160+ lines := colLines[c]161+ for _, line := range lines {162+ if int((curY-listY)/lineHeight) >= maxLines {163+ break164+ }165+ pdf.SetXY(colX, curY)166+ pdf.CellFormat(colWidth, lineHeight, line, "", 0, "L", false, 0, "")167+ curY += lineHeight168+ }169+ }170+}171+172+func wordSearchColumnCount(width, wordCount int) int {173+ if width >= 20 || wordCount > 18 {174+ return 3175+ }176+ if wordCount <= 0 {177+ return 1178+ }179+ return 2180+}181+182+func estimateWordBankLineCount(pdf *fpdf.Fpdf, words []string, columns int, availW, fontSize float64) int {183+ if len(words) == 0 {184+ return 1185+ }186+ if columns < 1 {187+ columns = 1188+ }189+190+ colWidth := (availW - float64(columns-1)*4.0) / float64(columns)191+ if colWidth <= 0 {192+ colWidth = availW193+ }194+195+ pdf.SetFont(pdfexport.SansFontFamily, "", fontSize)196+ lineCounts := make([]int, columns)197+ for _, word := range words {198+ text := strings.ToUpper(strings.TrimSpace(word))199+ if text == "" {200+ continue201+ }202+ lines := pdf.SplitLines([]byte(text), colWidth)203+ if len(lines) == 0 {204+ lines = [][]byte{[]byte(text)}205+ }206+ idx := minLineCountColumn(lineCounts)207+ lineCounts[idx] += len(lines)208+ }209+210+ maxLines := 0211+ for _, lineCount := range lineCounts {212+ if lineCount > maxLines {213+ maxLines = lineCount214+ }215+ }216+ if maxLines == 0 {217+ return 1218+ }219+ return maxLines220+}221+222+func layoutWordBankColumns(pdf *fpdf.Fpdf, words []string, columns int, colWidth float64) [][]string {223+ colLines := make([][]string, columns)224+ if len(words) == 0 || columns <= 0 {225+ return colLines226+ }227+228+ lineCounts := make([]int, columns)229+ for _, word := range words {230+ text := strings.ToUpper(strings.TrimSpace(word))231+ if text == "" {232+ continue233+ }234+ wrapped := pdf.SplitLines([]byte(text), colWidth)235+ if len(wrapped) == 0 {236+ wrapped = [][]byte{[]byte(text)}237+ }238+239+ idx := minLineCountColumn(lineCounts)240+ for _, line := range wrapped {241+ colLines[idx] = append(colLines[idx], string(line))242+ }243+ lineCounts[idx] += len(wrapped)244+ }245+246+ return colLines247+}248+249+func minLineCountColumn(lineCounts []int) int {250+ idx := 0251+ for i := 1; i < len(lineCounts); i++ {252+ if lineCounts[i] < lineCounts[idx] {253+ idx = i254+ }255+ }256+ return idx257+}258+28259 func init() {29260 game.RegisterPrintAdapter(printAdapter{})30261 }