clone

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

complete domain split to game for printadapter.

Dami committed 5ec53c4
commit
5ec53c4185febd7a127ba3fb6b590b016a11af59
parent
b8d1623a90f09a600fc2ad5b4376445f6e0d8006
author
Dami <dami.etoile@gmail.com>
20 files changed, +1558 -1548
Mhashiwokakero/PrintAdapter.go112++++-
Mhitori/PrintAdapter.go80+++-
Mnonogram/PrintAdapter.go258++++++++-
Anonogram/print_adapter_test.go139++++
Mnurikabe/PrintAdapter.go76++-
Mpdfexport/render.go940------------------------------
Dpdfexport/render_hashi.go109---
Dpdfexport/render_hitori_takuzu.go191------
Apdfexport/render_kit.go92+++
Mpdfexport/render_layout_test.go59--
Apdfexport/render_metadata.go17+
Dpdfexport/render_nonogram_test.go70--
Dpdfexport/render_nurikabe_shikaku.go131----
Dpdfexport/render_public.go39-
Apdfexport/render_title.go284+++++++++
Mshikaku/PrintAdapter.go76++-
Msudoku/PrintAdapter.go86+++-
Mtakuzu/PrintAdapter.go112++++-
Rpdfexport/render_takuzu_test.go → takuzu/print_adapter_test.go2+-
Mwordsearch/PrintAdapter.go233+++++++-
M hashiwokakero/PrintAdapter.go +111 -1
@@ -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 }
M hitori/PrintAdapter.go +79 -1
@@ -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 }
M nonogram/PrintAdapter.go +257 -1
@@ -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 }
A nonogram/print_adapter_test.go +139 -0
@@ -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+}
M nurikabe/PrintAdapter.go +75 -1
@@ -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 }
M pdfexport/render.go +0 -940
@@ -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-}
D pdfexport/render_hashi.go +0 -109
@@ -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-}
D pdfexport/render_hitori_takuzu.go +0 -191
@@ -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-}
A pdfexport/render_kit.go +92 -0
@@ -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+}
M pdfexport/render_layout_test.go +0 -59
@@ -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)
A pdfexport/render_metadata.go +17 -0
D pdfexport/render_nonogram_test.go +0 -70
@@ -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-}
D pdfexport/render_nurikabe_shikaku.go +0 -131
@@ -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-}
D pdfexport/render_public.go +0 -39
A pdfexport/render_title.go +284 -0
@@ -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+}
M shikaku/PrintAdapter.go +75 -1
@@ -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 }
M sudoku/PrintAdapter.go +85 -1
@@ -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 }
M takuzu/PrintAdapter.go +111 -1
@@ -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 }
R pdfexport/render_takuzu_test.go → takuzu/print_adapter_test.go +1 -1
M wordsearch/PrintAdapter.go +232 -1
@@ -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 }