diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-23 09:06:37 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-23 09:06:37 +0300 |
| commit | 1834c8886b15aa3b868e988c185ac5c344392886 (patch) | |
| tree | 89bab79059910638b7f8b3f865e9feddf95f4b81 /cmd | |
| parent | 0ccc8073852895996ed70c4b2a86c460e3a3ddae (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 'cmd')
| -rw-r--r-- | cmd/comicforge/cli.go | 80 | ||||
| -rw-r--r-- | cmd/comicforge/cli_test.go | 104 |
2 files changed, 184 insertions, 0 deletions
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") |
