- commit
- df406aaeeecdbeb12576827cd4baeb79eb2d62be
- parent
- 4349d9129e7bfc2c598803daa905255aaff79463
- author
- Dami <dami.etoile@gmail.com>
| A | spellpuzzle/ | 232 | ++++++++++++++++++++++++++++++ |
| A | spellpuzzle/ | 201 | ++++++++++++++++++++++++++ |
@@ -0,0 +1,232 @@
1+package spellpuzzle2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+8+ "codeberg.org/go-pdf/fpdf"9+ "github.com/FelineStateMachine/puzzletea/pdfexport"10+)11+12+type printAdapter struct{}13+14+type printPayload struct {15+ Board board16+ Placements []WordPlacement17+ Bank []rune18+}19+20+type printLayout struct {21+ cellSize float6422+ boardX float6423+ boardY float6424+ tileSize float6425+ tileGap float6426+ bankX float6427+ bankY float6428+}29+30+func (printAdapter) CanonicalGameType() string { return "Spell Puzzle" }31+func (printAdapter) Aliases() []string {32+ return []string{"spell", "spellpuzzle"}33+}34+35+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {36+ if len(strings.TrimSpace(string(save))) == 0 {37+ return nil, nil38+ }39+40+ var exported Save41+ if err := json.Unmarshal(save, &exported); err != nil {42+ return nil, fmt.Errorf("decode spell puzzle save: %w", err)43+ }44+45+ placements := append([]WordPlacement(nil), exported.Placements...)46+ for i := range placements {47+ placements[i].Found = false48+ }49+ placements = normalizePlacements(placements)50+ if len(placements) == 0 {51+ return nil, nil52+ }53+54+ board := buildBoard(placements)55+ if board.Width <= 0 || board.Height <= 0 {56+ return nil, nil57+ }58+59+ return &printPayload{60+ Board: board,61+ Placements: placements,62+ Bank: []rune(exported.Bank),63+ }, nil64+}65+66+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {67+ switch data := payload.(type) {68+ case *printPayload:69+ renderSpellPuzzlePage(pdf, data)70+ }71+ return nil72+}73+74+func renderSpellPuzzlePage(pdf *fpdf.Fpdf, data *printPayload) {75+ if data == nil || data.Board.Width <= 0 || data.Board.Height <= 0 {76+ return77+ }78+79+ pageW, pageH := pdf.GetPageSize()80+ pageNo := pdf.PageNo()81+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)82+ layout, ok := computePrintLayout(area, data)83+ if !ok {84+ return85+ }86+87+ drawSpellPuzzleBoard(pdf, data.Board, layout)88+ drawSpellPuzzleBank(pdf, data.Bank, layout)89+90+ contentBottom := layout.bankY + layout.tileSize91+ ruleY := pdfexport.InstructionY(contentBottom, pageH, 1)92+ pdfexport.SetInstructionStyle(pdf)93+ pdf.SetXY(area.X, ruleY)94+ pdf.CellFormat(95+ area.W,96+ pdfexport.InstructionLineHMM,97+ "Form words from the letter bank to fill the crossword",98+ "",99+ 0,100+ "C",101+ false,102+ 0,103+ "",104+ )105+}106+107+func computePrintLayout(area pdfexport.Rect, data *printPayload) (printLayout, bool) {108+ if data == nil || data.Board.Width <= 0 || data.Board.Height <= 0 {109+ return printLayout{}, false110+ }111+112+ letterCount := max(len(data.Bank), 1)113+ tileGap := 2.5114+ tileSize := min(9.5, (area.W-float64(letterCount-1)*tileGap)/float64(letterCount))115+ if tileSize < 5.2 {116+ tileGap = 1.4117+ tileSize = (area.W - float64(letterCount-1)*tileGap) / float64(letterCount)118+ }119+ if tileSize <= 0 {120+ return printLayout{}, false121+ }122+123+ bankSectionHeight := tileSize + 6.0124+ boardArea := pdfexport.Rect{125+ X: area.X,126+ Y: area.Y,127+ W: area.W,128+ H: area.H - bankSectionHeight - 4.0,129+ }130+ if boardArea.H <= 0 {131+ return printLayout{}, false132+ }133+134+ cellSize := pdfexport.FitCompactCellSize(data.Board.Width, data.Board.Height, boardArea)135+ if cellSize <= 0 {136+ return printLayout{}, false137+ }138+139+ boardX, boardY := pdfexport.CenteredOrigin(boardArea, data.Board.Width, data.Board.Height, cellSize)140+ bankWidth := float64(len(data.Bank))*tileSize + float64(max(len(data.Bank)-1, 0))*tileGap141+ bankX := area.X + (area.W-bankWidth)/2142+ bankY := boardArea.Y + boardArea.H + 4.0 + (bankSectionHeight-tileSize)/2143+144+ return printLayout{145+ cellSize: cellSize,146+ boardX: boardX,147+ boardY: boardY,148+ tileSize: tileSize,149+ tileGap: tileGap,150+ bankX: bankX,151+ bankY: bankY,152+ }, true153+}154+155+func drawSpellPuzzleBoard(pdf *fpdf.Fpdf, b board, layout printLayout) {156+ pdf.SetFillColor(255, 255, 255)157+ for y := range b.Height {158+ for x := range b.Width {159+ cell := b.Cells[y][x]160+ if !cell.Occupied {161+ continue162+ }163+ cellX := layout.boardX + float64(x)*layout.cellSize164+ cellY := layout.boardY + float64(y)*layout.cellSize165+ pdf.Rect(cellX, cellY, layout.cellSize, layout.cellSize, "F")166+ }167+ }168+169+ pdf.SetDrawColor(150, 150, 150)170+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)171+ for y := range b.Height {172+ for x := range b.Width {173+ cell := b.Cells[y][x]174+ if !cell.Occupied {175+ continue176+ }177+ cellX := layout.boardX + float64(x)*layout.cellSize178+ cellY := layout.boardY + float64(y)*layout.cellSize179+ pdf.Rect(cellX, cellY, layout.cellSize, layout.cellSize, "D")180+ }181+ }182+183+ pdf.SetDrawColor(25, 25, 25)184+ pdf.SetLineWidth(pdfexport.MajorGridLineMM)185+ for y := range b.Height {186+ for x := range b.Width {187+ cell := b.Cells[y][x]188+ if !cell.Occupied {189+ continue190+ }191+192+ cellX := layout.boardX + float64(x)*layout.cellSize193+ cellY := layout.boardY + float64(y)*layout.cellSize194+ if b.hasHorizontalEdge(x, y) {195+ pdf.Line(cellX, cellY, cellX+layout.cellSize, cellY)196+ }197+ if b.hasVerticalEdge(x, y) {198+ pdf.Line(cellX, cellY, cellX, cellY+layout.cellSize)199+ }200+ if b.hasHorizontalEdge(x, y+1) {201+ pdf.Line(cellX, cellY+layout.cellSize, cellX+layout.cellSize, cellY+layout.cellSize)202+ }203+ if b.hasVerticalEdge(x+1, y) {204+ pdf.Line(cellX+layout.cellSize, cellY, cellX+layout.cellSize, cellY+layout.cellSize)205+ }206+ }207+ }208+}209+210+func drawSpellPuzzleBank(pdf *fpdf.Fpdf, bank []rune, layout printLayout) {211+ if len(bank) == 0 {212+ return213+ }214+215+ fontSize := pdfexport.ClampStandardCellFontSize(pdfexport.StandardCellFontSize(layout.tileSize, 0.56))216+ lineH := fontSize * 0.9217+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)218+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)219+ pdf.SetDrawColor(45, 45, 45)220+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)221+222+ for i, letter := range bank {223+ x := layout.bankX + float64(i)*(layout.tileSize+layout.tileGap)224+ pdf.Rect(x, layout.bankY, layout.tileSize, layout.tileSize, "D")225+ pdf.SetXY(x, layout.bankY+(layout.tileSize-lineH)/2)226+ pdf.CellFormat(layout.tileSize, lineH, strings.ToUpper(string(letter)), "", 0, "C", false, 0, "")227+ }228+}229+230+func init() {231+ pdfexport.RegisterPrintAdapter(printAdapter{})232+}@@ -0,0 +1,201 @@
1+package spellpuzzle2+3+import (4+ "encoding/json"5+ "os"6+ "path/filepath"7+ "testing"8+ "time"9+10+ "github.com/FelineStateMachine/puzzletea/game"11+ "github.com/FelineStateMachine/puzzletea/pdfexport"12+)13+14+func TestPrintAdapterReturnsNilForBlankSave(t *testing.T) {15+ payload, err := printAdapter{}.BuildPDFPayload(nil)16+ if err != nil {17+ t.Fatal(err)18+ }19+ if payload != nil {20+ t.Fatalf("payload = %#v, want nil", payload)21+ }22+}23+24+func TestPrintAdapterBuildsPayloadAndIgnoresFoundState(t *testing.T) {25+ save := Save{26+ ModeTitle: "print-test",27+ Bank: "PATACN",28+ Placements: []WordPlacement{29+ {Text: "CATNAP", Start: Position{X: 3, Y: 0}, Orientation: Vertical, Found: true},30+ {Text: "CAPTAN", Start: Position{X: 0, Y: 2}, Orientation: Vertical, Found: false},31+ {Text: "CANT", Start: Position{X: 0, Y: 2}, Orientation: Horizontal, Found: true},32+ },33+ BonusWords: []string{"TAN", "ACT"},34+ Trace: []int{0, 1},35+ BankCursor: 1,36+ Solved: true,37+ Feedback: feedback{Kind: feedbackSuccess, Message: "bonus word counted"},38+ }39+40+ data, err := json.Marshal(save)41+ if err != nil {42+ t.Fatal(err)43+ }44+45+ payload, err := printAdapter{}.BuildPDFPayload(data)46+ if err != nil {47+ t.Fatal(err)48+ }49+50+ got, ok := payload.(*printPayload)51+ if !ok || got == nil {52+ t.Fatal("expected print payload")53+ }54+ if got.Board.Width <= 0 || got.Board.Height <= 0 {55+ t.Fatalf("board dims = %dx%d, want positive", got.Board.Width, got.Board.Height)56+ }57+ if string(got.Bank) != "PATACN" {58+ t.Fatalf("bank = %q, want PATACN", string(got.Bank))59+ }60+ for y := range got.Board.Height {61+ for x := range got.Board.Width {62+ if got.Board.Cells[y][x].Revealed {63+ t.Fatalf("cell (%d,%d) unexpectedly revealed", x, y)64+ }65+ }66+ }67+}68+69+func TestPrintAdapterDeduplicatesDuplicatePlacements(t *testing.T) {70+ save := Save{71+ ModeTitle: "opal-crane",72+ Bank: "PATACN",73+ Placements: []WordPlacement{74+ {Text: "CATNAP", Start: Position{X: 3, Y: 0}, Orientation: Vertical, Found: true},75+ {Text: "CAPTAN", Start: Position{X: 0, Y: 2}, Orientation: Vertical, Found: true},76+ {Text: "CANT", Start: Position{X: 0, Y: 2}, Orientation: Horizontal, Found: false},77+ {Text: "CANT", Start: Position{X: 0, Y: 2}, Orientation: Horizontal, Found: true},78+ },79+ }80+81+ data, err := json.Marshal(save)82+ if err != nil {83+ t.Fatal(err)84+ }85+86+ payload, err := printAdapter{}.BuildPDFPayload(data)87+ if err != nil {88+ t.Fatal(err)89+ }90+91+ got := payload.(*printPayload)92+ if len(got.Placements) != 3 {93+ t.Fatalf("placement count = %d, want 3", len(got.Placements))94+ }95+}96+97+func TestSpellPuzzlePrintAdapterRegistration(t *testing.T) {98+ for _, gameType := range []string{"Spell Puzzle", "spell", "spellpuzzle"} {99+ if !game.HasPrintAdapter(gameType) {100+ t.Fatalf("expected print adapter for %q", gameType)101+ }102+ }103+}104+105+func TestSpellPuzzleJSONLHydratesPrintPayload(t *testing.T) {106+ record := pdfexport.JSONLRecord{107+ Schema: pdfexport.ExportSchemaV1,108+ Pack: pdfexport.JSONLPackMeta{109+ Generated: "2026-03-10T10:00:00Z",110+ Version: "v-test",111+ Category: "Spell Puzzle",112+ ModeSelection: "Beginner",113+ Count: 1,114+ },115+ Puzzle: pdfexport.JSONLPuzzle{116+ Index: 1,117+ Name: "print-bank",118+ Game: "Spell Puzzle",119+ Mode: "Beginner",120+ Save: json.RawMessage(`{121+ "mode_title":"print-bank",122+ "bank":"PATACN",123+ "placements":[124+ {"text":"CATNAP","start":{"x":3,"y":0},"orientation":1,"found":true},125+ {"text":"CAPTAN","start":{"x":0,"y":2},"orientation":1,"found":false},126+ {"text":"CANT","start":{"x":0,"y":2},"orientation":0,"found":true}127+ ]128+ }`),129+ },130+ }131+132+ path := filepath.Join(t.TempDir(), "spellpuzzle.jsonl")133+ data, err := json.Marshal(record)134+ if err != nil {135+ t.Fatal(err)136+ }137+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {138+ t.Fatal(err)139+ }140+141+ doc, err := pdfexport.ParseJSONLFile(path)142+ if err != nil {143+ t.Fatal(err)144+ }145+ if got, want := len(doc.Puzzles), 1; got != want {146+ t.Fatalf("puzzles = %d, want %d", got, want)147+ }148+ if _, ok := doc.Puzzles[0].PrintPayload.(*printPayload); !ok {149+ t.Fatalf("payload type = %T, want *printPayload", doc.Puzzles[0].PrintPayload)150+ }151+}152+153+func TestSpellPuzzlePDFRenderSmokeForAllModes(t *testing.T) {154+ puzzles := make([]pdfexport.Puzzle, 0, len(Modes))155+ for i, item := range Modes {156+ mode := item.(SpellPuzzleMode)157+ generated, err := GeneratePuzzleSeeded(mode, newTestRNG(uint64(100+i)))158+ if err != nil {159+ t.Fatalf("%s: %v", mode.Title(), err)160+ }161+162+ saveData, err := json.Marshal(Save{163+ ModeTitle: mode.Title(),164+ Bank: generated.Bank,165+ Placements: generated.Placements,166+ })167+ if err != nil {168+ t.Fatal(err)169+ }170+171+ payload, err := printAdapter{}.BuildPDFPayload(saveData)172+ if err != nil {173+ t.Fatalf("%s payload: %v", mode.Title(), err)174+ }175+ puzzles = append(puzzles, pdfexport.Puzzle{176+ Category: "Spell Puzzle",177+ Name: mode.Title(),178+ Index: i + 1,179+ ModeSelection: mode.Title(),180+ PrintPayload: payload,181+ DifficultyScore: 0.5,182+ })183+ }184+185+ output := filepath.Join(t.TempDir(), "spellpuzzle.pdf")186+ err := pdfexport.WritePDF(output, nil, puzzles, pdfexport.RenderConfig{187+ VolumeNumber: 1,188+ GeneratedAt: time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC),189+ })190+ if err != nil {191+ t.Fatal(err)192+ }193+194+ info, err := os.Stat(output)195+ if err != nil {196+ t.Fatal(err)197+ }198+ if info.Size() == 0 {199+ t.Fatal("expected non-empty pdf output")200+ }201+}