diff options
Diffstat (limited to 'internal/comic/pdf.go')
| -rw-r--r-- | internal/comic/pdf.go | 263 |
1 files changed, 260 insertions, 3 deletions
diff --git a/internal/comic/pdf.go b/internal/comic/pdf.go index 23a7e11..15288dc 100644 --- a/internal/comic/pdf.go +++ b/internal/comic/pdf.go @@ -2,13 +2,69 @@ package comic import ( "fmt" + "image" + _ "image/png" + "math" + "os" "os/exec" "path/filepath" "strings" ) +// PDFAssembleOptions configures ImageMagick conversion when building the comic PDF. +type PDFAssembleOptions struct { + // Density is passed to ImageMagick -density (DPI for the vector/page coordinate system). + Density int + // JPEGQuality is 0 for default lossless-style output, or 1–100 for JPEG compression + // inside the PDF (smaller files, similar to a "compressed" comic PDF). + JPEGQuality int + // Presentation is one of: + // none — full bleed + // print — light matte border, then each page is fitted to ISO A4 portrait at pdf.density + // book — aged newsprint, tilt, shadow, thick page-edge strip, then ISO A4 portrait pages + Presentation string +} + +// BookPageAspectRatio is the Gemini image aspect ratio for pdf.presentation print or book. +// API-supported ratio closest to ISO 216 A4 portrait (210×297 mm, 1:√2). +const BookPageAspectRatio = "3:4" + +const ( + pdfPresentationNone = "none" + pdfPresentationPrint = "print" + pdfPresentationBook = "book" + + printMatteColor = "#f4efe6" + bookDeskColor = "#ebe6dc" + // bookEdgeGradient is drawn on the outer vertical edge after tilt (paper → block shadow). + bookEdgeGradient = "gradient:#ddd7cd-#2c2926" + + // bookAged* tune the “yellowed / shelf-worn” pass (ImageMagick) before tilt/shadow. + bookAgedModulate = "100,86,100" // brightness,saturation,hue + bookAgedSepiaPercent = "16%" + bookAgedNoiseAtten = "0.4" + bookAgedVignetteBlur = "0x22" + bookAgedVignetteLevel = "50x100%" // edge darkening strength +) + +// IsBookPDFPresentation reports whether p selects the book (aged / tilt / shadow) PDF pipeline. +func IsBookPDFPresentation(p string) bool { + return strings.ToLower(strings.TrimSpace(p)) == pdfPresentationBook +} + +// IsDINA4ClassPDFPresentation reports whether p assembles each PDF page at ISO A4 portrait +// (210×297 mm at the configured density): print or book. +func IsDINA4ClassPDFPresentation(p string) bool { + switch strings.ToLower(strings.TrimSpace(p)) { + case pdfPresentationPrint, pdfPresentationBook: + return true + default: + return false + } +} + // AssembleComicPDF combines comic pages into a PDF using ImageMagick. -func AssembleComicPDF(outputDir, titleSlug string, imagePaths []string) (string, error) { +func AssembleComicPDF(outputDir, titleSlug string, imagePaths []string, opts PDFAssembleOptions) (string, error) { if len(imagePaths) == 0 { return "", fmt.Errorf("no comic images to assemble into PDF") } @@ -16,9 +72,53 @@ func AssembleComicPDF(outputDir, titleSlug string, imagePaths []string) (string, return "", fmt.Errorf("ImageMagick 'convert' not found — install ImageMagick to generate the PDF") } + opts = normalizePDFAssembleOptions(opts) pdfPath := filepath.Join(outputDir, titleSlug+".pdf") - args := []string{"-density", "150"} - args = append(args, imagePaths...) + + inputs := imagePaths + if opts.Presentation == pdfPresentationPrint || opts.Presentation == pdfPresentationBook { + tmpDir, err := os.MkdirTemp("", "comicforge-pdf-*") + if err != nil { + return "", fmt.Errorf("temp dir for pdf frames: %w", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + inputs = make([]string, len(imagePaths)) + for i, src := range imagePaths { + dst := filepath.Join(tmpDir, fmt.Sprintf("frame_%04d.png", i)) + var ferr error + switch opts.Presentation { + case pdfPresentationPrint: + ferr = renderPrintFrame(src, dst) + case pdfPresentationBook: + ferr = renderBookFrame(src, dst, tmpDir, i) + } + if ferr != nil { + return "", ferr + } + inputs[i] = dst + } + + fitBG := printMatteColor + if opts.Presentation == pdfPresentationBook { + fitBG = bookDeskColor + } + fitted := make([]string, len(inputs)) + for i, p := range inputs { + out := filepath.Join(tmpDir, fmt.Sprintf("din_a4_%04d.png", i)) + if err := fitRasterPageToDINA4(p, out, opts.Density, fitBG); err != nil { + return "", err + } + fitted[i] = out + } + inputs = fitted + } + + args := []string{"-density", fmt.Sprintf("%d", opts.Density)} + args = append(args, inputs...) + if opts.JPEGQuality > 0 { + args = append(args, "-compress", "JPEG", "-quality", fmt.Sprintf("%d", opts.JPEGQuality)) + } args = append(args, pdfPath) cmd := exec.Command("convert", args...) @@ -28,3 +128,160 @@ func AssembleComicPDF(outputDir, titleSlug string, imagePaths []string) (string, } return pdfPath, nil } + +func renderPrintFrame(src, dst string) error { + args := []string{ + src, + "-background", printMatteColor, + "-gravity", "center", + "-extent", "108%x108%", + dst, + } + cmd := exec.Command("convert", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("convert matte frame: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func renderBookAgedPage(src, dst string) error { + // Sepia + desaturation + fine grain + vignette ≈ old newsprint / stored comic. + args := []string{ + src, + "-modulate", bookAgedModulate, + "-sepia-tone", bookAgedSepiaPercent, + "-attenuate", bookAgedNoiseAtten, + "+noise", "Gaussian", + "(", "+clone", "-colorspace", "gray", "-blur", bookAgedVignetteBlur, "+level", bookAgedVignetteLevel, ")", + "-compose", "multiply", + "-composite", + dst, + } + cmd := exec.Command("convert", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("convert book aged page: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func renderBookFrame(src, dst, tmpDir string, pageIndex int) error { + aged := filepath.Join(tmpDir, fmt.Sprintf("book_aged_%04d.png", pageIndex)) + if err := renderBookAgedPage(src, aged); err != nil { + return err + } + + step1 := filepath.Join(tmpDir, fmt.Sprintf("book_step1_%04d.png", pageIndex)) + angle := 2.1 + if pageIndex%2 == 1 { + angle = -2.1 + } + // Room for shadow after rotation; cream fill for empty corners. + args := []string{ + aged, + "-background", printMatteColor, + "-gravity", "center", + "-extent", "125%x125%", + "-background", printMatteColor, + "-rotate", fmt.Sprintf("%.1f", angle), + "(", "+clone", "-background", "black", "-shadow", "78x5+14+22", ")", + "+swap", + "-background", bookDeskColor, + "-layers", "merge", + "+repage", + step1, + } + cmd := exec.Command("convert", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("convert book frame (tilt/shadow): %w\n%s", err, strings.TrimSpace(string(out))) + } + + h, err := imageHeight(step1) + if err != nil { + return fmt.Errorf("book frame dimensions: %w", err) + } + if h < 1 { + h = 1 + } + grad := filepath.Join(tmpDir, fmt.Sprintf("book_grad_%04d.png", pageIndex)) + cmd = exec.Command("convert", "-size", fmt.Sprintf("12x%d", h), bookEdgeGradient, grad) + out, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("convert book edge gradient: %w\n%s", err, strings.TrimSpace(string(out))) + } + + cmd = exec.Command("convert", step1, grad, "-gravity", "east", "-compose", "over", "-composite", dst) + out, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("convert book frame (edge): %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func dinAPortraitPixels(dpi int) (w, h int) { + const mmW, mmH = 210.0, 297.0 + w = int(math.Round(float64(dpi) * mmW / 25.4)) + h = int(math.Round(float64(dpi) * mmH / 25.4)) + if w < 1 { + w = 1 + } + if h < 1 { + h = 1 + } + return w, h +} + +// fitRasterPageToDINA4 letterboxes or fits raster art into exact ISO A4 portrait pixels at the given DPI. +// background is the letterbox / pad color (print matte vs book desk). +func fitRasterPageToDINA4(src, dst string, density int, background string) error { + w, h := dinAPortraitPixels(density) + if strings.TrimSpace(background) == "" { + background = printMatteColor + } + args := []string{ + src, + "-resize", fmt.Sprintf("%dx%d", w, h), + "-background", background, + "-gravity", "center", + "-extent", fmt.Sprintf("%dx%d", w, h), + dst, + } + cmd := exec.Command("convert", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("convert DIN A4 page: %w\n%s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func imageHeight(path string) (int, error) { + f, err := os.Open(path) + if err != nil { + return 0, err + } + defer func() { _ = f.Close() }() + cfg, _, err := image.DecodeConfig(f) + if err != nil { + return 0, err + } + return cfg.Height, nil +} + +func normalizePDFAssembleOptions(o PDFAssembleOptions) PDFAssembleOptions { + if o.Density <= 0 { + o.Density = 150 + } + if o.JPEGQuality < 0 { + o.JPEGQuality = 0 + } + if o.JPEGQuality > 100 { + o.JPEGQuality = 100 + } + o.Presentation = strings.ToLower(strings.TrimSpace(o.Presentation)) + if o.Presentation == "" { + o.Presentation = pdfPresentationNone + } + return o +} |
