summaryrefslogtreecommitdiff
path: root/internal/comic/pdf.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/comic/pdf.go')
-rw-r--r--internal/comic/pdf.go263
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
+}