From 1834c8886b15aa3b868e988c185ac5c344392886 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 23 Apr 2026 09:06:37 +0300 Subject: 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 --- CLAUDE.md | 4 + README.md | 84 ++++++++++++- cmd/comicforge/cli.go | 80 ++++++++++++ cmd/comicforge/cli_test.go | 104 ++++++++++++++++ config.yaml.example | 15 ++- docs/ARCHITECTURE.md | 2 +- internal/comic/artist.go | 24 +++- internal/comic/comic_test.go | 16 ++- internal/comic/pageframe.go | 36 ++++++ internal/comic/pageframe_test.go | 22 ++++ internal/comic/pdf.go | 263 ++++++++++++++++++++++++++++++++++++++- internal/comic/pdf_test.go | 35 ++++++ internal/comic/runner.go | 6 +- internal/config/config.go | 40 ++++++ internal/config/config_test.go | 3 + prompts/back_cover_prompt.md | 5 +- prompts/cover_prompt.md | 5 +- prompts/gallery_page_prompt.md | 5 +- prompts/manual_prompt.md | 1 + prompts/story_page_prompt.md | 1 + sumer_vocab_bull.txt | 15 +++ sumer_vocab_underworld.txt | 16 +++ 22 files changed, 762 insertions(+), 20 deletions(-) create mode 100644 internal/comic/pageframe.go create mode 100644 internal/comic/pageframe_test.go create mode 100644 internal/comic/pdf_test.go create mode 100644 sumer_vocab_bull.txt create mode 100644 sumer_vocab_underworld.txt 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 ` | 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() { _ = 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{ diff --git a/prompts/back_cover_prompt.md b/prompts/back_cover_prompt.md index 6242bff..90d3b5d 100644 --- a/prompts/back_cover_prompt.md +++ b/prompts/back_cover_prompt.md @@ -1,8 +1,9 @@ This comic is in {{.LanguageName}}. All visible back-cover text, banners, boxes, and labels must use {{.ScriptName}} script only. Style: {{.Style}}.{{.RenderingRequirement}} -Full back cover {{if .RenderingRequirement}}photographic scene{{else}}illustration{{end}} in landscape 16:9 format. -No panel grid. No speech bubbles. +Full back cover {{if .RenderingRequirement}}photographic scene{{else}}illustration{{end}} in {{.PageFrame}}. +{{if .DINA4PDF}}This file is one ISO A4–class portrait page in the final PDF: use a single unified composition that fills the entire canvas edge to edge (one sheet only — no collage of multiple pages, no inset spreads, no “photo of several sheets” staging). +{{end}}No panel grid. No speech bubbles. {{if .Bible}}CHARACTER AND SETTING REFERENCE: {{.Bible}} diff --git a/prompts/cover_prompt.md b/prompts/cover_prompt.md index f268498..8bd14d6 100644 --- a/prompts/cover_prompt.md +++ b/prompts/cover_prompt.md @@ -1,8 +1,9 @@ This comic is in {{.LanguageName}}. All visible cover text, including the main title, must use {{.ScriptName}} script only. Style: {{.Style}}.{{.RenderingRequirement}} -Full front cover {{if .RenderingRequirement}}photographic scene{{else}}illustration{{end}} in landscape 16:9 format. -No panel grid. No speech bubbles. +Full front cover {{if .RenderingRequirement}}photographic scene{{else}}illustration{{end}} in {{.PageFrame}}. +{{if .DINA4PDF}}This file is one ISO A4–class portrait page in the final PDF: use a single unified composition that fills the entire canvas edge to edge (one sheet only — no collage of multiple pages, no inset spreads, no “photo of several sheets” staging). +{{end}}No panel grid. No speech bubbles. {{if .Bible}}CHARACTER AND SETTING REFERENCE: {{.Bible}} diff --git a/prompts/gallery_page_prompt.md b/prompts/gallery_page_prompt.md index 4cdbd04..9068bce 100644 --- a/prompts/gallery_page_prompt.md +++ b/prompts/gallery_page_prompt.md @@ -9,8 +9,9 @@ VARIETY REQUIREMENTS: • This gallery image should feel like a distinct portfolio shot from a different moment in the story world. Style: {{.Style}}.{{.RenderingRequirement}} -One full-frame {{if .RenderingRequirement}}photograph that looks 100% like a real camera shot{{else}}illustration{{end}} in landscape 16:9 format. -Do not split the image into panels or sections. The entire canvas is one single scene. +One full-frame {{if .RenderingRequirement}}photograph that looks 100% like a real camera shot{{else}}illustration{{end}} in {{.PageFrame}}. +{{if .DINA4PDF}}This file is one ISO A4–class portrait page in the final PDF: one continuous scene must fill the whole canvas edge to edge (never multiple physical sheets, comic spreads, or stacked pages in-frame). +{{end}}Do not split the image into panels or sections. The entire canvas is one single scene. No text of any kind. No title, labels, speech bubbles, panel borders, or UI elements. {{if .Bible}}CHARACTER AND SETTING REFERENCE: {{.Bible}} diff --git a/prompts/manual_prompt.md b/prompts/manual_prompt.md index 177bfca..cebee78 100644 --- a/prompts/manual_prompt.md +++ b/prompts/manual_prompt.md @@ -2,6 +2,7 @@ Create a single image based on this prompt: {{.Prompt}} +Target page shape: {{.PageFrame}}. {{if .Style}}Visual style: {{.Style}}. {{end}}{{if .Theme}}Theme context: {{.Theme}}. {{end}}{{if .RenderingRequirement}}{{.RenderingRequirement}} diff --git a/prompts/story_page_prompt.md b/prompts/story_page_prompt.md index dd2e452..93bc33a 100644 --- a/prompts/story_page_prompt.md +++ b/prompts/story_page_prompt.md @@ -1,4 +1,5 @@ This comic page is in {{.LanguageName}}. All speech, thoughts, captions, and labels inside panels must use {{.ScriptName}} script only. +Page dimensions: {{.PageFrame}}. UNIQUE STORY PAGE {{.PageNum}} OF {{.TotalPages}} — render this page as a NEW story beat, not a repeat of any previous page. The exact page-specific panel plan is the highest priority instruction: diff --git a/sumer_vocab_bull.txt b/sumer_vocab_bull.txt new file mode 100644 index 0000000..1b65a67 --- /dev/null +++ b/sumer_vocab_bull.txt @@ -0,0 +1,15 @@ +ziggurat +demigod +breach +oracle +cuneiform +priesthood +alliance +sandstorm +rubble +lightning +celestial +resilience +tablet +Uruk +heroism diff --git a/sumer_vocab_underworld.txt b/sumer_vocab_underworld.txt new file mode 100644 index 0000000..55839d1 --- /dev/null +++ b/sumer_vocab_underworld.txt @@ -0,0 +1,16 @@ +underworld +sisterhood +destiny +phantom +torchlight +labyrinth +betrayal +oath +chariot +inversion +mausoleum +covenant +tablet +specter +echo +crown -- cgit v1.2.3