summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md4
-rw-r--r--README.md84
-rw-r--r--cmd/comicforge/cli.go80
-rw-r--r--cmd/comicforge/cli_test.go104
-rw-r--r--config.yaml.example15
-rw-r--r--docs/ARCHITECTURE.md2
-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
-rw-r--r--prompts/back_cover_prompt.md5
-rw-r--r--prompts/cover_prompt.md5
-rw-r--r--prompts/gallery_page_prompt.md5
-rw-r--r--prompts/manual_prompt.md1
-rw-r--r--prompts/story_page_prompt.md1
-rw-r--r--sumer_vocab_bull.txt15
-rw-r--r--sumer_vocab_underworld.txt16
22 files changed, 762 insertions, 20 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 459fd4a..04c97ae 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,3 +13,7 @@
- Put application logic under `internal/`.
- Use `mage` for project tasks.
+## Page shape and PDF
+
+- Documented in `README.md` (**Page shape and PDF export** and **Usage**): `--page-format`, `--aspect-ratio`, `comic.aspect_ratio`, `pdf.*` / `--pdf-*` (`print` / `book`: ISO A4 portrait PDF pages; `book` adds aged art, tilt, shadow, thick edge), language env vars for vocab/script alignment, and matching `COMICFORGE_` env vars.
+
diff --git a/README.md b/README.md
index f03b894..819da09 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,13 @@ Environment variables also work. `COMICFORGE_` is the prefix, and dots in config
- `COMICFORGE_PROVIDER_TEXT`
- `COMICFORGE_MODELS_IMAGE`
- `COMICFORGE_COMIC_STORY_PAGES`
+- `COMICFORGE_COMIC_GALLERY_PAGES`
+- `COMICFORGE_COMIC_ASPECT_RATIO`
+- `COMICFORGE_LANGUAGE_STORY_LANGUAGE`
+- `COMICFORGE_LANGUAGE_SCRIPT`
+- `COMICFORGE_PDF_DENSITY`
+- `COMICFORGE_PDF_JPEG_QUALITY`
+- `COMICFORGE_PDF_PRESENTATION`
If `COMICFORGE_API_GOOGLE_API_KEY` is not set, ComicForge falls back to `GOOGLE_API_KEY`.
@@ -76,12 +83,22 @@ models:
comic:
story_pages: 5
gallery_pages: 5
+ # Layout prompts: 4 panels require at least three horizontal rows (no 2×2-only grid).
panels_per_page: 4
+ # Gemini image aspect ratio (e.g. 16:9 widescreen, 2:3 portrait comic page).
aspect_ratio: "16:9"
prompt_max_chars: 900
page_max_retries: 5
page_retry_base_seconds: 15
+# Final PDF assembly via ImageMagick `convert` (requires ImageMagick on PATH).
+pdf:
+ density: 150
+ # 0 = default PDF encoding; 1–100 = JPEG compression (smaller files).
+ jpeg_quality: 0
+ # none = full-bleed; print/book = ISO A4 portrait PDF pages (print = matte; book = aged + tilt + shadow + thick edge).
+ presentation: none
+
story:
realistic_weight: 0.4
@@ -101,6 +118,28 @@ prompts_dir: ./prompts
`provider.*` currently supports Gemini in this codebase. Set `api.google_api_key`, `COMICFORGE_API_GOOGLE_API_KEY`, or fallback `GOOGLE_API_KEY` for Gemini-backed generation.
+### Page shape and PDF export
+
+**Image aspect ratio** controls both the Gemini generation API and the wording in prompt templates (widescreen vs tall comic page). It comes from configuration unless overridden on the command line.
+
+| Source | Effect |
+|--------|--------|
+| `comic.aspect_ratio` in YAML / env | Default ratio for the run (default `16:9`). Overridden when `pdf.presentation` is `print` or `book` (see next row), unless you set `--aspect-ratio`. |
+| `pdf.presentation` `print` or `book` (YAML / `COMICFORGE_PDF_PRESENTATION` / `--pdf-presentation`) | Unless `--aspect-ratio` is set on the CLI, forces `3:4` (closest Gemini ratio to **ISO A4** portrait, matching the PDF page size). This wins over `--page-format`. |
+| `--page-format screen` | Sets aspect ratio to `16:9` only when PDF mode is `none` and `--aspect-ratio` is unset. No effect when `pdf.presentation` is `print` or `book` (those modes force `3:4` unless you pass `--aspect-ratio`). |
+| `--page-format comic` | Sets aspect ratio to `2:3` under the same conditions as `screen` (not applied for `print` / `book` unless you override with `--aspect-ratio`). |
+| `--aspect-ratio <W:H>` | Explicit ratio (e.g. `16:9`, `2:3`, `3:4`, `21:9`). **Highest precedence** over `--page-format` and `pdf.presentation`. |
+
+**PDF assembly** (`pdf` in YAML or the flags below) only affects the multi-page comic PDF produced after all PNGs are rendered. It does not apply to manual `--prompt` mode (single PNG, no PDF). ImageMagick’s `convert` must be available to build the PDF.
+
+| Key / flag | Meaning |
+|------------|---------|
+| `pdf.density` / `COMICFORGE_PDF_DENSITY` | Passed to ImageMagick `-density` (default `150`). |
+| `pdf.jpeg_quality` / `COMICFORGE_PDF_JPEG_QUALITY` | `0` keeps the default encoding for the assembled PDF. Values `1`–`100` enable JPEG compression inside the PDF (smaller files, similar to a “compressed” comic PDF). |
+| `pdf.presentation` / `COMICFORGE_PDF_PRESENTATION` | `none` (default): full-bleed PDF pages (size follows source rasters). `print`: cream matte around the art, then **each page is fitted to ISO A4 portrait** (210×297 mm at `pdf.density`); cover, story, gallery, and back pages each become one A4 PDF page. Prompts add extra “single full sheet” instructions for cover / gallery / back. `book`: same **A4 page size** and the same per-page layout, after art is **aged** (mild sepia / desaturation, grain, vignette), then **tilt**, **drop shadow**, and a **thick page-edge** strip. |
+| `--pdf-jpeg-quality` | Same as `pdf.jpeg_quality` when set on the CLI. |
+| `--pdf-presentation` | `none`, `print`, or `book`; same as `pdf.presentation` when set on the CLI. |
+
## Usage
ComicForge requires a vocabulary file:
@@ -116,12 +155,23 @@ Vocabulary lines can be:
- `стол`
- `= translation only`
+**Story language vs vocabulary script:** The story and visible text in panels must match `language.story_language` and `language.script` in config (e.g. Bulgarian + Cyrillic). If your word list is **English (Latin letters)** but the story is generated in **Cyrillic**, validation will fail when those Latin words appear in the story. For English stories with English/Latin vocabulary, set for example `story_language: English` and `script: Latin` in YAML, or for one run:
+
+```bash
+COMICFORGE_LANGUAGE_STORY_LANGUAGE=English COMICFORGE_LANGUAGE_SCRIPT=Latin \
+ comicforge --vocab english-words.txt --config config.yaml --output out
+```
+
Useful flags:
- `--output` sets the root output directory
- `--prompt` generates a single image from a direct prompt and skips the story flow
- `--prompts-dir` overrides the prompt template directory
- `--style` and `--theme` override story generation hints and are also applied as context in manual prompt mode
+- `--page-format` `screen` or `comic` presets aspect ratio (`16:9` vs `2:3`) when PDF mode is `none`; see [Page shape and PDF export](#page-shape-and-pdf-export)
+- `--aspect-ratio` explicit `W:H` for Gemini (highest precedence over `--page-format`, `pdf.presentation`, and `comic.aspect_ratio`)
+- `--pdf-jpeg-quality` JPEG quality `0`–`100` for the assembled PDF (`0` = default encoding)
+- `--pdf-presentation` `none` (default), `print` (matte + **ISO A4** PDF pages), or `book` (aged art + tilt + shadow + thick edge + **same A4** pages); also sets generation to `3:4` unless `--aspect-ratio` is set
- `--slug` forces the output folder name
- `--narrate` enables narration output
- `--narrator-voice` picks the Gemini narration voice
@@ -130,7 +180,7 @@ Useful flags:
- `--ultra-realistic` and `--no-ultra-realistic` force photorealistic or classic comic rendering; without either flag ComicForge randomly chooses from the configured style families
- `--version` prints the application version
-Example:
+Example (default PDF: full-bleed, aspect ratio from config):
```bash
comicforge \
@@ -143,6 +193,36 @@ comicforge \
The generated files are written under `out/comics/assets/demo-comic/`, with the gallery copied to `out/comics/gallery/` and the PDF written to `out/comics/PDF/`.
+**ISO A4 print PDF** (`print` fits every page to 210×297 mm at `pdf.density`; Gemini uses `3:4` unless you pass `--aspect-ratio`):
+
+```bash
+comicforge \
+ --vocab vocab.txt \
+ --config config.yaml \
+ --output out \
+ --slug my-a4-comic \
+ --pdf-presentation print
+```
+
+Shorter test run (fewer story and gallery pages) via env:
+
+```bash
+COMICFORGE_COMIC_STORY_PAGES=2 COMICFORGE_COMIC_GALLERY_PAGES=2 \
+ comicforge --vocab vocab.txt --config config.yaml --output out \
+ --slug a4-smoke --pdf-presentation print
+```
+
+**Book-style A4 PDF** (same page dimensions as `print`, plus aged paper, tilt, shadow, and page-edge treatment):
+
+```bash
+comicforge \
+ --vocab vocab.txt \
+ --config config.yaml \
+ --output out \
+ --slug my-a4-book \
+ --pdf-presentation book
+```
+
For manual prompt mode:
```bash
@@ -151,4 +231,4 @@ comicforge --prompt "a robot reading a newspaper" --output out --slug manual-rob
This writes a single image to `out/comics/assets/manual-robot/prompt.png`. Without `--slug`, ComicForge asks the text model for a short title and uses that as the directory slug, with a short deterministic fallback if title generation fails.
-Manual prompt mode can be combined with `--style`, `--theme`, and the ultra-realistic flags to shape the generated image prompt.
+Manual prompt mode can be combined with `--style`, `--theme`, `--page-format`, `--aspect-ratio`, and the ultra-realistic flags to shape the generated image prompt (page-shape flags affect the image API and the manual prompt template; PDF flags have no effect because no PDF is built).
diff --git a/cmd/comicforge/cli.go b/cmd/comicforge/cli.go
index d3141bf..683b8c7 100644
--- a/cmd/comicforge/cli.go
+++ b/cmd/comicforge/cli.go
@@ -47,6 +47,10 @@ type cliFlags struct {
ultraRealistic bool
noUltraRealistic bool
version bool
+ pageFormat string
+ aspectRatio string
+ pdfJPEGQuality int
+ pdfPresentation string
}
func defaultCommandDeps() commandDeps {
@@ -86,6 +90,12 @@ func newRootCommandWithDeps(deps commandDeps) *cobra.Command {
if err := validateUltraRealisticFlags(flags); err != nil {
return err
}
+ if err := validatePageFormatFlag(cmd, flags); err != nil {
+ return err
+ }
+ if err := validatePDFCLIOnlyFlags(cmd, flags); err != nil {
+ return err
+ }
if cmd.Flags().Changed("prompt") && strings.TrimSpace(flags.prompt) == "" {
return fmt.Errorf("--prompt is required when set")
}
@@ -122,6 +132,10 @@ func newRootCommandWithDeps(deps commandDeps) *cobra.Command {
cmd.Flags().StringVar(&flags.imageModel, "image-model", "", "image model override")
cmd.Flags().StringVar(&flags.imageTextModel, "image-text-model", "", "image text model override")
cmd.Flags().StringVar(&flags.ttsModel, "tts-model", "", "text-to-speech model override")
+ cmd.Flags().StringVar(&flags.pageFormat, "page-format", "", "page shape preset: screen (16:9) or comic (2:3); ignored if --aspect-ratio is set")
+ cmd.Flags().StringVar(&flags.aspectRatio, "aspect-ratio", "", "override Gemini image aspect ratio (e.g. 16:9, 2:3, 3:4); wins over --page-format and config")
+ cmd.Flags().IntVar(&flags.pdfJPEGQuality, "pdf-jpeg-quality", 0, "if 1–100, JPEG-compress the assembled PDF for smaller files; 0 keeps default encoding")
+ cmd.Flags().StringVar(&flags.pdfPresentation, "pdf-presentation", "", "PDF framing: none, print (matte + ISO A4 pages), or book (aged, tilt, shadow, thick edge + ISO A4 pages)")
return cmd
}
@@ -137,6 +151,7 @@ func runCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, flags
}
applyConfigOverrides(cmd, cfg, flags)
+ applyOutputFlags(cmd, cfg, flags)
voice := resolveNarratorVoice(flags, cfg)
textProvider, err := deps.newTextProvider(cfg)
@@ -186,6 +201,11 @@ func runCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps, flags
StoryPages: cfg.Comic.StoryPages,
GalleryPages: cfg.Comic.GalleryPages,
PanelsPerPage: cfg.Comic.PanelsPerPage,
+ PDF: comic.PDFAssembleOptions{
+ Density: cfg.PDF.Density,
+ JPEGQuality: cfg.PDF.JPEGQuality,
+ Presentation: cfg.PDF.Presentation,
+ },
})
return runner.Run(ctx, flags.vocab)
@@ -202,6 +222,7 @@ func runPromptCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps,
}
applyConfigOverrides(cmd, cfg, flags)
+ applyOutputFlags(cmd, cfg, flags)
textProvider, err := deps.newTextProvider(cfg)
if err != nil {
return fmt.Errorf("build text provider: %w", err)
@@ -234,6 +255,11 @@ func runPromptCommand(ctx context.Context, cmd *cobra.Command, deps commandDeps,
PromptMaxChars: cfg.Comic.PromptMaxChars,
PageMaxRetries: cfg.Comic.PageMaxRetries,
PageRetryBase: time.Duration(cfg.Comic.PageRetryBaseSeconds) * time.Second,
+ PDF: comic.PDFAssembleOptions{
+ Density: cfg.PDF.Density,
+ JPEGQuality: cfg.PDF.JPEGQuality,
+ Presentation: cfg.PDF.Presentation,
+ },
})
return runner.RunPrompt(ctx, flags.prompt)
@@ -246,6 +272,60 @@ func validateUltraRealisticFlags(flags cliFlags) error {
return nil
}
+func validatePageFormatFlag(cmd *cobra.Command, flags cliFlags) error {
+ if !cmd.Flags().Changed("page-format") {
+ return nil
+ }
+ switch strings.ToLower(strings.TrimSpace(flags.pageFormat)) {
+ case "screen", "comic":
+ return nil
+ default:
+ return fmt.Errorf("--page-format must be screen or comic")
+ }
+}
+
+func validatePDFCLIOnlyFlags(cmd *cobra.Command, flags cliFlags) error {
+ if cmd.Flags().Changed("pdf-presentation") && strings.TrimSpace(flags.pdfPresentation) != "" {
+ switch strings.ToLower(strings.TrimSpace(flags.pdfPresentation)) {
+ case "none", "print", "book":
+ default:
+ return fmt.Errorf("--pdf-presentation must be none, print, or book")
+ }
+ }
+ if cmd.Flags().Changed("pdf-jpeg-quality") {
+ if flags.pdfJPEGQuality < 0 || flags.pdfJPEGQuality > 100 {
+ return fmt.Errorf("--pdf-jpeg-quality must be between 0 and 100")
+ }
+ }
+ return nil
+}
+
+func applyOutputFlags(cmd *cobra.Command, cfg *config.Config, flags cliFlags) {
+ if cfg == nil {
+ return
+ }
+ if cmd.Flags().Changed("pdf-jpeg-quality") {
+ cfg.PDF.JPEGQuality = flags.pdfJPEGQuality
+ }
+ if cmd.Flags().Changed("pdf-presentation") && strings.TrimSpace(flags.pdfPresentation) != "" {
+ cfg.PDF.Presentation = strings.ToLower(strings.TrimSpace(flags.pdfPresentation))
+ }
+
+ // Print/book PDF: ISO A4 portrait pages — force Gemini 3:4 (closest ratio to 210×297 mm) unless --aspect-ratio is set.
+ if cmd.Flags().Changed("aspect-ratio") && strings.TrimSpace(flags.aspectRatio) != "" {
+ cfg.Comic.AspectRatio = strings.TrimSpace(flags.aspectRatio)
+ } else if comic.IsDINA4ClassPDFPresentation(cfg.PDF.Presentation) {
+ cfg.Comic.AspectRatio = comic.BookPageAspectRatio
+ } else if cmd.Flags().Changed("page-format") {
+ switch strings.ToLower(strings.TrimSpace(flags.pageFormat)) {
+ case "screen":
+ cfg.Comic.AspectRatio = "16:9"
+ case "comic":
+ cfg.Comic.AspectRatio = "2:3"
+ }
+ }
+}
+
func applyConfigOverrides(cmd *cobra.Command, cfg *config.Config, flags cliFlags) {
if cfg == nil {
return
diff --git a/cmd/comicforge/cli_test.go b/cmd/comicforge/cli_test.go
index 27dc09c..c6d2db1 100644
--- a/cmd/comicforge/cli_test.go
+++ b/cmd/comicforge/cli_test.go
@@ -184,6 +184,110 @@ prompts_dir: ./config-prompts
}
}
+func TestPDFPresentationPrintAndBookForcesA4AspectRatio(t *testing.T) {
+ for _, presentation := range []string{"print", "book"} {
+ t.Run(presentation, func(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(configPath, []byte(strings.TrimSpace(`
+comic:
+ aspect_ratio: "16:9"
+pdf:
+ presentation: none
+`)), 0o644); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+ vocabPath := filepath.Join(tmpDir, "vocab.txt")
+ if err := os.WriteFile(vocabPath, []byte("ябълка = apple\n"), 0o644); err != nil {
+ t.Fatalf("write vocab: %v", err)
+ }
+
+ var gotRunnerCfg *comic.RunnerConfig
+ cmd := newRootCommandWithDeps(commandDeps{
+ loadConfig: func(path string) (*config.Config, error) {
+ return config.Load(path)
+ },
+ newTextProvider: func(*config.Config) (provider.TextProvider, error) { return noopProvider{}, nil },
+ newImageProvider: func(*config.Config) (provider.ImageProvider, error) { return noopProvider{}, nil },
+ newTTSProvider: func(*config.Config, string) (provider.TTSProvider, error) { return noopProvider{}, nil },
+ newRunner: func(cfg *comic.RunnerConfig) comic.StoryRunner {
+ gotRunnerCfg = cfg
+ return &recordingRunner{}
+ },
+ })
+ buf := &bytes.Buffer{}
+ cmd.SetOut(buf)
+ cmd.SetErr(buf)
+ cmd.SetArgs([]string{
+ "--config", configPath,
+ "--vocab", vocabPath,
+ "--pdf-presentation", presentation,
+ })
+
+ if err := cmd.ExecuteContext(context.Background()); err != nil {
+ t.Fatalf("ExecuteContext() error = %v\n%s", err, buf.String())
+ }
+ if gotRunnerCfg == nil {
+ t.Fatal("runner config was not captured")
+ }
+ if got, want := gotRunnerCfg.AspectRatio, comic.BookPageAspectRatio; got != want {
+ t.Fatalf("aspect ratio = %q, want %q (A4-class 3:4)", got, want)
+ }
+ if got, want := gotRunnerCfg.PDF.Presentation, presentation; got != want {
+ t.Fatalf("pdf presentation = %q, want %q", got, want)
+ }
+ })
+ }
+}
+
+func TestPDFPresentationExplicitAspectRatioOverridesA4Default(t *testing.T) {
+ tmpDir := t.TempDir()
+ configPath := filepath.Join(tmpDir, "config.yaml")
+ if err := os.WriteFile(configPath, []byte(strings.TrimSpace(`
+comic:
+ aspect_ratio: "16:9"
+`)), 0o644); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+ vocabPath := filepath.Join(tmpDir, "vocab.txt")
+ if err := os.WriteFile(vocabPath, []byte("ябълка = apple\n"), 0o644); err != nil {
+ t.Fatalf("write vocab: %v", err)
+ }
+
+ var gotRunnerCfg *comic.RunnerConfig
+ cmd := newRootCommandWithDeps(commandDeps{
+ loadConfig: func(path string) (*config.Config, error) {
+ return config.Load(path)
+ },
+ newTextProvider: func(*config.Config) (provider.TextProvider, error) { return noopProvider{}, nil },
+ newImageProvider: func(*config.Config) (provider.ImageProvider, error) { return noopProvider{}, nil },
+ newTTSProvider: func(*config.Config, string) (provider.TTSProvider, error) { return noopProvider{}, nil },
+ newRunner: func(cfg *comic.RunnerConfig) comic.StoryRunner {
+ gotRunnerCfg = cfg
+ return &recordingRunner{}
+ },
+ })
+ buf := &bytes.Buffer{}
+ cmd.SetOut(buf)
+ cmd.SetErr(buf)
+ cmd.SetArgs([]string{
+ "--config", configPath,
+ "--vocab", vocabPath,
+ "--pdf-presentation", "print",
+ "--aspect-ratio", "21:9",
+ })
+
+ if err := cmd.ExecuteContext(context.Background()); err != nil {
+ t.Fatalf("ExecuteContext() error = %v\n%s", err, buf.String())
+ }
+ if gotRunnerCfg == nil {
+ t.Fatal("runner config was not captured")
+ }
+ if got, want := gotRunnerCfg.AspectRatio, "21:9"; got != want {
+ t.Fatalf("aspect ratio = %q, want %q", got, want)
+ }
+}
+
func TestRootCommandUsesRealisticWeightWhenUltraModeUnset(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
diff --git a/config.yaml.example b/config.yaml.example
index 5f10714..98e6455 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -23,7 +23,9 @@ models:
comic:
story_pages: 5
gallery_pages: 5
+ # 4 panels → prompt asks for ≥3 horizontal rows (not a 2×2-only layout).
panels_per_page: 4
+ # Gemini image aspect ratio. CLI: --aspect-ratio wins. With pdf.presentation print|book, defaults to 3:4 (A4-class) unless --aspect-ratio is set.
aspect_ratio: "16:9"
prompt_max_chars: 900
page_max_retries: 5
@@ -32,7 +34,9 @@ comic:
language:
input: Vocabulary
output: Story
- # Language used for story and narration prompts.
+ # Language used for story and narration prompts. Must match the script of visible text
+ # (e.g. English story + Latin script for English vocab; Cyrillic for Bulgarian).
+ # Env: COMICFORGE_LANGUAGE_STORY_LANGUAGE, COMICFORGE_LANGUAGE_SCRIPT
story_language: Bulgarian
script: Latin
@@ -72,5 +76,14 @@ narration:
- Fenrir
chunk_words: 100
+# Final PDF assembly (ImageMagick `convert` on PATH). CLI: --pdf-jpeg-quality, --pdf-presentation.
+# Env: COMICFORGE_PDF_DENSITY, COMICFORGE_PDF_JPEG_QUALITY, COMICFORGE_PDF_PRESENTATION
+pdf:
+ density: 150
+ # 0 = default; 1–100 = JPEG inside PDF (smaller file).
+ jpeg_quality: 0
+ # none | print | book — print/book output ISO A4 portrait PDF pages; book also ages, tilts, shadows, thick edge.
+ presentation: none
+
# Directory that ComicForge uses for prompt templates.
prompts_dir: ./prompts
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 41e34dc..9368cc6 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -489,5 +489,5 @@ comics/
- **New AI Providers**: Implement `TextProvider`/`ImageProvider`/`TTSProvider` and register in the respective registries
- **New Style Modes**: Add to `StyleConfig` and update `pickStyle()` in `types.go`
-- **Custom Output Formats**: The `assemblePDF` field in `Runner` is injectable for alternative exporters
+- **Custom Output Formats**: The `assemblePDF` field in `Runner` is injectable for alternative exporters; the default implementation takes `PDFAssembleOptions` (density, JPEG quality, print-style matting)
- **Additional Languages**: Extend `localization.go` with new script/language mappings
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