1package pdfexport23import (4 "fmt"5 "os"6 "path/filepath"7 "strings"8 "time"910 "codeberg.org/go-pdf/fpdf"11 "codeberg.org/go-pdf/fpdf/contrib/gofpdi"12)1314func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) error {15 if strings.TrimSpace(outputPath) == "" {16 return fmt.Errorf("output path is required")17 }1819 printablePuzzles := filterPrintablePuzzles(puzzles)20 if len(printablePuzzles) == 0 {21 return nil22 }2324 if cfg.GeneratedAt.IsZero() {25 cfg.GeneratedAt = time.Now()26 }27 if cfg.VolumeNumber < 1 {28 cfg.VolumeNumber = 129 }30 if strings.TrimSpace(cfg.CoverSubtitle) == "" {31 cfg.CoverSubtitle = defaultTitle(docs)32 }33 if strings.TrimSpace(cfg.Title) == "" {34 cfg.Title = fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber)35 }36 if strings.TrimSpace(cfg.AdvertText) == "" {37 cfg.AdvertText = "Find more puzzles at github.com/FelineStateMachine/puzzletea"38 }3940 logicalPages := buildLogicalPages(len(printablePuzzles), sheetLayoutIncludesCover(cfg.SheetLayout))4142 dir := filepath.Dir(outputPath)43 if dir != "" && dir != "." {44 if err := os.MkdirAll(dir, 0o755); err != nil {45 return fmt.Errorf("create output directory: %w", err)46 }47 }4849 switch cfg.SheetLayout {50 case SheetLayoutHalfLetter:51 pdf, err := newConfiguredPDF(SheetLayoutHalfLetter, cfg.Title, true)52 if err != nil {53 return err54 }55 if err := renderLogicalPages(pdf, logicalPages, docs, printablePuzzles, cfg); err != nil {56 return err57 }58 if got, want := pdf.PageNo(), len(logicalPages); got != want {59 return fmt.Errorf("rendered %d physical pages, want %d", got, want)60 }61 if err := pdf.OutputFileAndClose(outputPath); err != nil {62 return fmt.Errorf("write pdf file: %w", err)63 }64 case SheetLayoutDuplexBooklet:65 if err := writeDuplexBookletPDF(outputPath, logicalPages, docs, printablePuzzles, cfg); err != nil {66 return err67 }68 default:69 return fmt.Errorf("unsupported sheet layout %d", cfg.SheetLayout)70 }71 return nil72}7374func newRenderPDF(layout SheetLayout) *fpdf.Fpdf {75 init := &fpdf.InitType{76 OrientationStr: "P",77 UnitStr: "mm",78 Size: fpdf.SizeType{79 Wd: halfLetterWidthMM,80 Ht: halfLetterHeightMM,81 },82 }83 if layout == SheetLayoutDuplexBooklet {84 init.Size = fpdf.SizeType{85 Wd: letterWidthMM,86 Ht: letterHeightMM,87 }88 }89 return fpdf.NewCustom(init)90}9192func newConfiguredPDF(layout SheetLayout, title string, registerFonts bool) (*fpdf.Fpdf, error) {93 pdf := newRenderPDF(layout)94 if registerFonts {95 if err := registerPDFFonts(pdf); err != nil {96 return nil, err97 }98 }99 pdf.SetAutoPageBreak(false, 0)100 pdf.SetCreator("PuzzleTea", true)101 pdf.SetAuthor("PuzzleTea", true)102 pdf.SetTitle(title, true)103 return pdf, nil104}105106func sheetLayoutIncludesCover(layout SheetLayout) bool {107 return layout == SheetLayoutDuplexBooklet108}109110func saddleStitchPadCount(totalPages int) int {111 if totalPages <= 0 {112 return 0113 }114115 remainder := totalPages % 4116 if remainder == 0 {117 return 0118 }119 return 4 - remainder120}121122func filterPrintablePuzzles(puzzles []Puzzle) []Puzzle {123 printable := make([]Puzzle, 0, len(puzzles))124 for _, puzzle := range puzzles {125 if IsNilPrintPayload(puzzle.PrintPayload) {126 continue127 }128 if _, ok := LookupPrintAdapter(puzzle.Category); !ok {129 continue130 }131 printable = append(printable, puzzle)132 }133 return printable134}135136func renderLogicalPages(137 pdf *fpdf.Fpdf,138 pages []logicalPage,139 docs []PackDocument,140 puzzles []Puzzle,141 cfg RenderConfig,142) error {143 for _, page := range pages {144 pdf.AddPage()145 if err := drawLogicalPage(pdf, page, docs, puzzles, cfg); err != nil {146 return err147 }148 }149 return nil150}151152func drawLogicalPage(153 pdf *fpdf.Fpdf,154 page logicalPage,155 docs []PackDocument,156 puzzles []Puzzle,157 cfg RenderConfig,158) error {159 return withLogicalPageNumber(page.Number, func() error {160 switch page.Kind {161 case logicalPageCoverOutside:162 renderOutsideCoverPage(pdf, cfg, page.OutsideSlice)163 case logicalPageCoverBlank:164 renderCoverBlankPage(pdf)165 case logicalPageTitle:166 renderTitlePage(pdf, docs, puzzles, cfg)167 case logicalPagePuzzle:168 if page.PuzzleIndex < 0 || page.PuzzleIndex >= len(puzzles) {169 return fmt.Errorf("logical page %d references puzzle index %d", page.Number, page.PuzzleIndex)170 }171 puzzle := puzzles[page.PuzzleIndex]172 if err := renderPuzzlePage(pdf, puzzle); err != nil {173 return fmt.Errorf("render puzzle %q (%s #%d): %w", puzzle.Name, puzzle.Category, puzzle.Index, err)174 }175 case logicalPagePad:176 renderPadPage(pdf)177 default:178 return fmt.Errorf("unsupported logical page kind %d", page.Kind)179 }180181 if page.ShowFooter {182 drawPageFooter(pdf, page.Number)183 }184 return nil185 })186}187188func renderPadPage(_ *fpdf.Fpdf) {}189190func writeDuplexBookletPDF(191 outputPath string,192 logicalPages []logicalPage,193 docs []PackDocument,194 puzzles []Puzzle,195 cfg RenderConfig,196) error {197 tempFile, err := os.CreateTemp("", "puzzletea-booklet-*.pdf")198 if err != nil {199 return fmt.Errorf("create temporary booklet pdf: %w", err)200 }201 tempPath := tempFile.Name()202 if err := tempFile.Close(); err != nil {203 return fmt.Errorf("close temporary booklet pdf handle: %w", err)204 }205 defer os.Remove(tempPath)206207 sourcePDF, err := newConfiguredPDF(SheetLayoutHalfLetter, cfg.Title, true)208 if err != nil {209 return err210 }211 if err := renderLogicalPages(sourcePDF, logicalPages, docs, puzzles, cfg); err != nil {212 return err213 }214 if got, want := sourcePDF.PageNo(), len(logicalPages); got != want {215 return fmt.Errorf("rendered %d logical pages, want %d", got, want)216 }217 if err := sourcePDF.OutputFileAndClose(tempPath); err != nil {218 return fmt.Errorf("write temporary booklet pdf: %w", err)219 }220221 imposedPDF, err := newConfiguredPDF(SheetLayoutDuplexBooklet, cfg.Title, false)222 if err != nil {223 return err224 }225 importer := gofpdi.NewImporter()226 for _, sheet := range duplexBookletSheets(len(logicalPages)) {227 imposedPDF.AddPage()228 drawImportedPage(imposedPDF, importer, tempPath, sheet.Front.LeftPage, 0, 0)229 drawImportedPage(imposedPDF, importer, tempPath, sheet.Front.RightPage, halfLetterWidthMM, 0)230231 imposedPDF.AddPage()232 drawImportedPage(imposedPDF, importer, tempPath, sheet.Back.LeftPage, 0, 0)233 drawImportedPage(imposedPDF, importer, tempPath, sheet.Back.RightPage, halfLetterWidthMM, 0)234 }235 if got, want := imposedPDF.PageNo(), len(logicalPages)/2; got != want {236 return fmt.Errorf("rendered %d physical pages, want %d", got, want)237 }238 if err := imposedPDF.OutputFileAndClose(outputPath); err != nil {239 return fmt.Errorf("write pdf file: %w", err)240 }241 return nil242}243244func drawImportedPage(245 pdf *fpdf.Fpdf,246 importer *gofpdi.Importer,247 sourcePath string,248 pageNumber int,249 x, y float64,250) {251 templateID := importer.ImportPage(pdf, sourcePath, pageNumber, "/MediaBox")252 importer.UseImportedTemplate(pdf, templateID, x, y, halfLetterWidthMM, halfLetterHeightMM)253}254255func drawPageFooter(pdf *fpdf.Fpdf, pageNo int) {256 pdf.SetY(-8)257 pdf.SetFont(sansFontFamily, "", 8+pdfFontSizeDelta)258 pdf.SetTextColor(footerTextGray, footerTextGray, footerTextGray)259 pdf.CellFormat(0, 4, fmt.Sprintf("%d", pageNo), "", 0, "C", false, 0, "")260}261262func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error {263 if IsNilPrintPayload(puzzle.PrintPayload) {264 return nil265 }266 adapter, ok := LookupPrintAdapter(puzzle.Category)267 if !ok {268 return nil269 }270271 pageW, _ := pdf.GetPageSize()272273 setPuzzleTitleStyle(pdf)274 pdf.SetXY(0, 10)275 title := strings.TrimSpace(puzzle.Category)276 if name := strings.TrimSpace(puzzle.Name); name != "" {277 title = fmt.Sprintf("%s: %s", title, name)278 }279 pdf.CellFormat(pageW, 7, title, "", 0, "C", false, 0, "")280281 setPuzzleSubtitleStyle(pdf)282 renderPuzzleDifficultySubtitle(pdf, pageW, 17, puzzle)283 if err := adapter.RenderPDFBody(pdf, puzzle.PrintPayload); err != nil {284 return err285 }286 return nil287}