summaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'cmd')
-rw-r--r--cmd/comicforge/cli.go80
-rw-r--r--cmd/comicforge/cli_test.go104
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")