summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-23 09:06:37 +0300
committerPaul Buetow <paul@buetow.org>2026-04-23 09:06:37 +0300
commit1834c8886b15aa3b868e988c185ac5c344392886 (patch)
tree89bab79059910638b7f8b3f865e9feddf95f4b81 /internal
parent0ccc8073852895996ed70c4b2a86c460e3a3ddae (diff)
Add ISO A4 PDF print/book pipeline, page-frame prompts, and usage docs
Print and book PDF modes now letterbox each raster to A4 portrait at pdf.density; print uses the matte color for pads. CLI forces 3:4 generation for print|book unless --aspect-ratio is set. Cover, gallery, and back prompts gain DINA4PDF instructions when those modes are active. Introduce DescribePageFrame and PDF helpers with tests; add CLI tests for presentation and aspect precedence. Expand README and config example with A4 examples, language env vars, and page-format vs PDF behavior. Add sample Sumer vocabulary files. Made-with: Cursor
Diffstat (limited to 'internal')
-rw-r--r--internal/comic/artist.go24
-rw-r--r--internal/comic/comic_test.go16
-rw-r--r--internal/comic/pageframe.go36
-rw-r--r--internal/comic/pageframe_test.go22
-rw-r--r--internal/comic/pdf.go263
-rw-r--r--internal/comic/pdf_test.go35
-rw-r--r--internal/comic/runner.go6
-rw-r--r--internal/config/config.go40
-rw-r--r--internal/config/config_test.go3
9 files changed, 435 insertions, 10 deletions
diff --git a/internal/comic/artist.go b/internal/comic/artist.go
index f58b56f..8fa2aa9 100644
--- a/internal/comic/artist.go
+++ b/internal/comic/artist.go
@@ -38,6 +38,8 @@ type ArtistConfig struct {
PromptMaxChars int
PageMaxRetries int
PageRetryBase time.Duration
+ // DINA4PDF is true when pdf.presentation is print or book (ISO A4 portrait PDF pages).
+ DINA4PDF bool
}
// Artist generates comic-book pages.
@@ -57,6 +59,7 @@ type Artist struct {
watercolorStyles []string
theme string
aspectRatio string
+ pageFrame string
language string
script string
ultraRealistic bool
@@ -66,6 +69,7 @@ type Artist struct {
promptMaxChars int
pageMaxRetries int
pageRetryBase time.Duration
+ dina4PDF bool
initErr error
}
@@ -110,6 +114,7 @@ func NewArtist(cfg *ArtistConfig) *Artist {
a.watercolorStyles = append([]string(nil), cfg.WatercolorStyles...)
a.theme = cfg.Theme
a.aspectRatio = orDefault(cfg.AspectRatio, comicPageAspectRatio)
+ a.pageFrame = DescribePageFrame(a.aspectRatio)
a.language = orDefault(cfg.Language, a.language)
a.script = orDefault(cfg.Script, a.script)
a.ultraRealistic = cfg.UltraRealistic
@@ -129,6 +134,7 @@ func NewArtist(cfg *ArtistConfig) *Artist {
} else {
a.pageRetryBase = pageRetryBase
}
+ a.dina4PDF = cfg.DINA4PDF
if a.imageProvider == nil {
a.initErr = fmt.Errorf("%w: image provider", ErrMissingProvider)
@@ -348,6 +354,8 @@ func (a *Artist) coverPromptData(storyText, style, bible string) map[string]any
"LanguageName": localizedLanguageName(a.language, a.script),
"Script": a.script,
"ScriptName": localizedScriptName(a.script),
+ "PageFrame": a.pageFrame,
+ "DINA4PDF": a.dina4PDF,
"Style": localizedStylePrompt(style, a.language, a.script),
"Bible": bible,
"Subtitle": localizedBrandName(a.language, a.script),
@@ -363,6 +371,7 @@ func (a *Artist) storyPagePromptData(section string, pageNum int, style, bible s
"LanguageName": localizedLanguageName(a.language, a.script),
"Script": a.script,
"ScriptName": localizedScriptName(a.script),
+ "PageFrame": a.pageFrame,
"Style": localizedStylePrompt(style, a.language, a.script),
"Bible": bible,
"Words": buildWordList(entries, ""),
@@ -384,6 +393,8 @@ func (a *Artist) galleryPromptData(style, bible string, galleryNum int) map[stri
"LanguageName": localizedLanguageName(a.language, a.script),
"Script": a.script,
"ScriptName": localizedScriptName(a.script),
+ "PageFrame": a.pageFrame,
+ "DINA4PDF": a.dina4PDF,
"Style": localizedStylePrompt(style, a.language, a.script),
"Bible": bible,
"GalleryNum": galleryNum,
@@ -400,6 +411,8 @@ func (a *Artist) backPromptData(storyText, style, bible, blurb string) map[strin
"LanguageName": localizedLanguageName(a.language, a.script),
"Script": a.script,
"ScriptName": localizedScriptName(a.script),
+ "PageFrame": a.pageFrame,
+ "DINA4PDF": a.dina4PDF,
"Style": localizedStylePrompt(style, a.language, a.script),
"Bible": bible,
"BlurbBox": blurbBoxInstruction(blurb),
@@ -413,6 +426,7 @@ func (a *Artist) backPromptData(storyText, style, bible, blurb string) map[strin
func (a *Artist) manualPromptData(prompt string) map[string]any {
return map[string]any{
"Prompt": strings.TrimSpace(prompt),
+ "PageFrame": a.pageFrame,
"Style": localizedStylePrompt(a.style, a.language, a.script),
"Theme": a.theme,
"RenderingRequirement": a.renderingRequirement(),
@@ -491,11 +505,15 @@ func panelLayoutLead(panelCount int) string {
case 2:
return "Divide the image into exactly 2 distinct panels in a balanced two-panel layout."
case 3:
- return "Divide the image into exactly 3 distinct panels in a balanced three-panel layout."
+ return "Divide the image into exactly 3 distinct panels in three horizontal rows (three stacked tiers, one panel per row)."
case 4:
- return "Divide the image into exactly 4 distinct panels in a 2x2 grid."
+ return "Divide the image into exactly 4 distinct panels in a layout with AT LEAST THREE horizontal rows — for example four stacked tiers (one panel per row), or banding such as 2+1+1, 1+2+1, or 1+1+2. Do NOT use a 2×2 grid with only two rows."
+ case 5:
+ return "Divide the image into exactly 5 distinct panels in a layout with at least three horizontal rows (for example 2+2+1, 1+2+2, or 2+1+2 banding)."
+ case 6:
+ return "Divide the image into exactly 6 distinct panels in a 3-row × 2-column grid (three horizontal rows, two panels wide), or another layout with at least three horizontal rows."
default:
- return fmt.Sprintf("Divide the image into exactly %d distinct panels in a balanced grid.", panelCount)
+ return fmt.Sprintf("Divide the image into exactly %d distinct panels in a balanced grid with at least three horizontal rows.", panelCount)
}
}
diff --git a/internal/comic/comic_test.go b/internal/comic/comic_test.go
index 5e5058d..d2067a2 100644
--- a/internal/comic/comic_test.go
+++ b/internal/comic/comic_test.go
@@ -74,6 +74,18 @@ func TestBuildPanelLayoutUsesFallbackExcerpt(t *testing.T) {
}
}
+func TestPanelLayoutLeadFourPanelsUsesAtLeastThreeRows(t *testing.T) {
+ t.Parallel()
+ got := panelLayoutLead(4)
+ if !strings.Contains(strings.ToLower(got), "three horizontal rows") {
+ t.Fatalf("panelLayoutLead(4) = %q, want at least three horizontal rows", got)
+ }
+ // Reject the old two-row prescription, not the phrase "do not use 2×2".
+ if strings.Contains(got, "in a 2×2 grid") || strings.Contains(got, "in a 2x2 grid") {
+ t.Fatalf("panelLayoutLead(4) = %q, must not prescribe a 2×2 grid layout", got)
+ }
+}
+
func TestSplitIntoSectionsUsesRuneBoundaries(t *testing.T) {
t.Parallel()
@@ -432,7 +444,7 @@ func TestArtistAndRunnerEndToEndWithFakes(t *testing.T) {
NarrateEnabled: true,
GalleryPages: 1,
})
- runner.assemblePDF = func(outputDir, titleSlug string, imagePaths []string) (string, error) {
+ runner.assemblePDF = func(outputDir, titleSlug string, imagePaths []string, _ PDFAssembleOptions) (string, error) {
path := filepath.Join(outputDir, titleSlug+".pdf")
return path, os.WriteFile(path, []byte("pdf"), 0o644)
}
@@ -740,7 +752,7 @@ func TestRunnerPropagatesRenderFailures(t *testing.T) {
Slug: "forced-slug",
NarrateEnabled: false,
})
- runner.assemblePDF = func(string, string, []string) (string, error) {
+ runner.assemblePDF = func(string, string, []string, PDFAssembleOptions) (string, error) {
t.Fatal("assemblePDF should not be called on render failure")
return "", nil
}
diff --git a/internal/comic/pageframe.go b/internal/comic/pageframe.go
new file mode 100644
index 0000000..82ab363
--- /dev/null
+++ b/internal/comic/pageframe.go
@@ -0,0 +1,36 @@
+package comic
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// DescribePageFrame returns human-readable page shape text for image prompts,
+// derived from a Gemini-style aspect ratio such as "16:9" or "2:3".
+func DescribePageFrame(aspectRatio string) string {
+ aspectRatio = strings.TrimSpace(aspectRatio)
+ if aspectRatio == "" {
+ aspectRatio = comicPageAspectRatio
+ }
+ parts := strings.Split(aspectRatio, ":")
+ if len(parts) != 2 {
+ return fmt.Sprintf("%s format", aspectRatio)
+ }
+ w, errW := strconv.Atoi(strings.TrimSpace(parts[0]))
+ h, errH := strconv.Atoi(strings.TrimSpace(parts[1]))
+ if errW != nil || errH != nil || w <= 0 || h <= 0 {
+ return fmt.Sprintf("%s format", aspectRatio)
+ }
+ switch {
+ case w < h:
+ if w == 3 && h == 4 {
+ return "portrait 3:4 format (ISO A4–class sheet: 210×297 mm target; image API uses 3:4 as the closest standard ratio)"
+ }
+ return fmt.Sprintf("portrait %s format (tall comic book page proportions)", aspectRatio)
+ case w > h:
+ return fmt.Sprintf("landscape %s format (widescreen)", aspectRatio)
+ default:
+ return fmt.Sprintf("square %s format", aspectRatio)
+ }
+}
diff --git a/internal/comic/pageframe_test.go b/internal/comic/pageframe_test.go
new file mode 100644
index 0000000..c9c7c26
--- /dev/null
+++ b/internal/comic/pageframe_test.go
@@ -0,0 +1,22 @@
+package comic
+
+import "testing"
+
+func TestDescribePageFrame(t *testing.T) {
+ tests := []struct {
+ ratio string
+ want string
+ }{
+ {"16:9", "landscape 16:9 format (widescreen)"},
+ {"2:3", "portrait 2:3 format (tall comic book page proportions)"},
+ {"3:2", "landscape 3:2 format (widescreen)"},
+ {"1:1", "square 1:1 format"},
+ {"", "landscape 16:9 format (widescreen)"},
+ {"bogus", "bogus format"},
+ }
+ for _, tt := range tests {
+ if got := DescribePageFrame(tt.ratio); got != tt.want {
+ t.Fatalf("DescribePageFrame(%q) = %q, want %q", tt.ratio, got, tt.want)
+ }
+ }
+}
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
+}
diff --git a/internal/comic/pdf_test.go b/internal/comic/pdf_test.go
new file mode 100644
index 0000000..79da814
--- /dev/null
+++ b/internal/comic/pdf_test.go
@@ -0,0 +1,35 @@
+package comic
+
+import "testing"
+
+func TestIsDINA4ClassPDFPresentation(t *testing.T) {
+ tests := []struct {
+ in string
+ want bool
+ }{
+ {"print", true},
+ {"PRINT", true},
+ {" book ", true},
+ {"book", true},
+ {"none", false},
+ {"", false},
+ {"fullbleed", false},
+ }
+ for _, tt := range tests {
+ if got := IsDINA4ClassPDFPresentation(tt.in); got != tt.want {
+ t.Errorf("IsDINA4ClassPDFPresentation(%q) = %v, want %v", tt.in, got, tt.want)
+ }
+ }
+}
+
+func TestIsBookPDFPresentation(t *testing.T) {
+ if !IsBookPDFPresentation("book") {
+ t.Fatal("IsBookPDFPresentation(book) = false")
+ }
+ if IsBookPDFPresentation("print") {
+ t.Fatal("IsBookPDFPresentation(print) = true, want false")
+ }
+ if IsBookPDFPresentation("none") {
+ t.Fatal("IsBookPDFPresentation(none) = true, want false")
+ }
+}
diff --git a/internal/comic/runner.go b/internal/comic/runner.go
index f3be5d9..82d8d81 100644
--- a/internal/comic/runner.go
+++ b/internal/comic/runner.go
@@ -54,6 +54,7 @@ type RunnerConfig struct {
StoryPages int
GalleryPages int
PanelsPerPage int
+ PDF PDFAssembleOptions
}
// Runner orchestrates the full pipeline.
@@ -62,7 +63,7 @@ type Runner struct {
generator *Generator
artist *Artist
narrator *Narrator
- assemblePDF func(outputDir, titleSlug string, imagePaths []string) (string, error)
+ assemblePDF func(outputDir, titleSlug string, imagePaths []string, opts PDFAssembleOptions) (string, error)
}
// NewRunner wires together the generator, artist, and narrator.
@@ -113,6 +114,7 @@ func NewRunner(cfg *RunnerConfig) *Runner {
StoryPages: cfg.StoryPages,
GalleryPages: cfg.GalleryPages,
PanelsPerPage: cfg.PanelsPerPage,
+ DINA4PDF: IsDINA4ClassPDFPresentation(cfg.PDF.Presentation),
})
r.narrator = NewNarrator(&NarratorConfig{
TextProvider: cfg.TextProvider,
@@ -190,7 +192,7 @@ func (r *Runner) Run(ctx context.Context, batchFile string) error {
fmt.Fprintf(os.Stderr, "Warning: could not copy gallery images to comics/gallery: %v\n", err)
}
if len(paths) > 0 {
- pdfPath, err := r.assemblePDF(comicsPDFDir(dir), slug, paths)
+ pdfPath, err := r.assemblePDF(comicsPDFDir(dir), slug, paths, r.config.PDF)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: PDF assembly failed: %v\n", err)
} else {
diff --git a/internal/config/config.go b/internal/config/config.go
index 9286be3..a79c1ce 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -35,6 +35,7 @@ type Config struct {
Story StoryConfig `mapstructure:"story" yaml:"story"`
Styles StyleConfig `mapstructure:"styles" yaml:"styles"`
Narration NarrationConfig `mapstructure:"narration" yaml:"narration"`
+ PDF PDFConfig `mapstructure:"pdf" yaml:"pdf"`
PromptsDir string `mapstructure:"prompts_dir" yaml:"prompts_dir"`
}
@@ -110,6 +111,16 @@ type NarrationConfig struct {
ChunkWords int `mapstructure:"chunk_words" yaml:"chunk_words"`
}
+// PDFConfig controls final PDF assembly (ImageMagick).
+type PDFConfig struct {
+ // Density is the ImageMagick -density value (DPI hint for the PDF).
+ Density int `mapstructure:"density" yaml:"density"`
+ // JPEGQuality is 0 for default encoding, or 1–100 to JPEG-compress the PDF (smaller files).
+ JPEGQuality int `mapstructure:"jpeg_quality" yaml:"jpeg_quality"`
+ // Presentation is "none", "print" (matte + ISO A4 portrait pages), or "book" (aged tilt + ISO A4 portrait pages).
+ Presentation string `mapstructure:"presentation" yaml:"presentation"`
+}
+
// DefaultConfig returns a configuration populated with the initial Gemini-first defaults.
func DefaultConfig() *Config {
return &Config{
@@ -185,6 +196,11 @@ func DefaultConfig() *Config {
},
ChunkWords: 100,
},
+ PDF: PDFConfig{
+ Density: 150,
+ JPEGQuality: 0,
+ Presentation: "none",
+ },
PromptsDir: DefaultPromptsDir,
}
}
@@ -367,6 +383,20 @@ func (c *Config) normalize() {
if c.PromptsDir == "" {
c.PromptsDir = DefaultPromptsDir
}
+
+ if c.PDF.Density <= 0 {
+ c.PDF.Density = 150
+ }
+ if c.PDF.JPEGQuality < 0 {
+ c.PDF.JPEGQuality = 0
+ }
+ if c.PDF.JPEGQuality > 100 {
+ c.PDF.JPEGQuality = 100
+ }
+ c.PDF.Presentation = strings.ToLower(strings.TrimSpace(c.PDF.Presentation))
+ if c.PDF.Presentation == "" {
+ c.PDF.Presentation = "none"
+ }
}
func (c *Config) validate() error {
@@ -380,6 +410,12 @@ func (c *Config) validate() error {
return fmt.Errorf("unknown TTS provider: %s", c.Provider.TTS)
}
+ switch c.PDF.Presentation {
+ case "none", "print", "book":
+ default:
+ return fmt.Errorf("unknown pdf.presentation %q (use none, print, or book)", c.PDF.Presentation)
+ }
+
return nil
}
@@ -422,6 +458,10 @@ func setDefaults(v *viper.Viper, cfg *Config) {
v.SetDefault("narration.voices", cfg.Narration.Voices)
v.SetDefault("narration.chunk_words", cfg.Narration.ChunkWords)
+ v.SetDefault("pdf.density", cfg.PDF.Density)
+ v.SetDefault("pdf.jpeg_quality", cfg.PDF.JPEGQuality)
+ v.SetDefault("pdf.presentation", cfg.PDF.Presentation)
+
v.SetDefault("prompts_dir", cfg.PromptsDir)
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 902f432..98b72db 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
+ "codeberg.org/snonux/comicforge/internal/comic"
"codeberg.org/snonux/comicforge/internal/provider"
)
@@ -242,6 +243,7 @@ func TestEmbeddedPromptTemplatesRender(t *testing.T) {
"ScriptName": "кирилица",
"Genre": "a mystery with a surprising twist",
"Style": "cinematic realism",
+ "PageFrame": comic.DescribePageFrame("16:9"),
"Theme": "mystery",
"Prompt": "a robot reading a newspaper",
"Words": "- ябълка\n- книга\n",
@@ -266,6 +268,7 @@ func TestEmbeddedPromptTemplatesRender(t *testing.T) {
"Pose": "extreme close-up portrait",
"GalleryNum": 1,
"TotalGalleryPages": 5,
+ "DINA4PDF": false,
}
for _, name := range []string{